refactor: eslint and prettier conflict will be resolved and formatted

This commit is contained in:
Enes Köksal
2026-04-23 17:05:43 +03:00
parent 385b61f29b
commit 3cfea40fbd
219 changed files with 16638 additions and 13663 deletions
@@ -51,11 +51,9 @@ export function ApprovalRequestDialog({
);
const toggleApprover = (userId: string) => {
setSelectedApproverIds((current) => (
current.includes(userId)
? current.filter((id) => id !== userId)
: [...current, userId]
));
setSelectedApproverIds((current) =>
current.includes(userId) ? current.filter((id) => id !== userId) : [...current, userId]
);
};
const handleCreate = async () => {
@@ -74,9 +72,7 @@ export function ApprovalRequestDialog({
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Request Approval</DialogTitle>
<DialogDescription>
Select one or more approvers for this version.
</DialogDescription>
<DialogDescription>Select one or more approvers for this version.</DialogDescription>
</DialogHeader>
{isBlockedByPendingRequest ? (
@@ -93,14 +89,23 @@ export function ApprovalRequestDialog({
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Approvers ({selectedApproverIds.length} selected)</p>
<Button size="sm" variant="ghost" onClick={onRefreshCandidates} disabled={isLoadingCandidates}>
<p className="text-xs text-muted-foreground">
Approvers ({selectedApproverIds.length} selected)
</p>
<Button
size="sm"
variant="ghost"
onClick={onRefreshCandidates}
disabled={isLoadingCandidates}
>
{isLoadingCandidates ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Refresh'}
</Button>
</div>
<div className="max-h-56 overflow-y-auto rounded-md border p-2 space-y-1">
{selectableCandidates.length === 0 ? (
<p className="text-sm text-muted-foreground px-2 py-3">No eligible approvers found.</p>
<p className="text-sm text-muted-foreground px-2 py-3">
No eligible approvers found.
</p>
) : (
selectableCandidates.map((candidate) => {
const selected = selectedApproverIds.includes(candidate.id);
@@ -116,11 +121,17 @@ export function ApprovalRequestDialog({
<div className="flex items-center gap-2 min-w-0">
<Avatar className="h-7 w-7">
<AvatarImage src={candidate.image ?? undefined} />
<AvatarFallback>{(candidate.name || candidate.email || 'U').charAt(0).toUpperCase()}</AvatarFallback>
<AvatarFallback>
{(candidate.name || candidate.email || 'U').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{candidate.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground truncate">{candidate.email || 'No email'}</p>
<p className="text-sm font-medium truncate">
{candidate.name || 'Unnamed'}
</p>
<p className="text-xs text-muted-foreground truncate">
{candidate.email || 'No email'}
</p>
</div>
</div>
{selected ? (
@@ -152,7 +163,9 @@ export function ApprovalRequestDialog({
<DialogFooter>
<Button
onClick={handleCreate}
disabled={isSubmittingRequest || isBlockedByPendingRequest || selectedApproverIds.length === 0}
disabled={
isSubmittingRequest || isBlockedByPendingRequest || selectedApproverIds.length === 0
}
>
{isSubmittingRequest ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Create Request
@@ -26,21 +26,45 @@ interface ApprovalRequestsPanelProps {
isCancelingRequest: boolean;
error: string;
onRefresh: () => void;
onSubmitDecision: (requestId: string, decision: 'APPROVED' | 'REJECTED', note?: string) => Promise<boolean>;
onSubmitDecision: (
requestId: string,
decision: 'APPROVED' | 'REJECTED',
note?: string
) => Promise<boolean>;
onCancelRequest: (requestId: string) => Promise<boolean>;
}
function statusBadge(status: ApprovalRequest['status']) {
if (status === 'PENDING') {
return <Badge variant="secondary" className="gap-1"><Clock3 className="h-3 w-3" />Pending</Badge>;
return (
<Badge variant="secondary" className="gap-1">
<Clock3 className="h-3 w-3" />
Pending
</Badge>
);
}
if (status === 'APPROVED') {
return <Badge className="gap-1 bg-emerald-600 hover:bg-emerald-600"><CheckCircle2 className="h-3 w-3" />Approved</Badge>;
return (
<Badge className="gap-1 bg-emerald-600 hover:bg-emerald-600">
<CheckCircle2 className="h-3 w-3" />
Approved
</Badge>
);
}
if (status === 'REJECTED') {
return <Badge variant="destructive" className="gap-1"><XCircle className="h-3 w-3" />Rejected</Badge>;
return (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Rejected
</Badge>
);
}
return <Badge variant="outline" className="gap-1"><ShieldX className="h-3 w-3" />Canceled</Badge>;
return (
<Badge variant="outline" className="gap-1">
<ShieldX className="h-3 w-3" />
Canceled
</Badge>
);
}
function decisionLabel(
@@ -76,18 +100,25 @@ export function ApprovalRequestsPanel({
);
const myPendingDecision = useMemo(() => {
if (!currentUserId || !pendingRequest) return null;
return pendingRequest.decisions.find(
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
) || null;
return (
pendingRequest.decisions.find(
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
) || null
);
}, [currentUserId, pendingRequest]);
const canCancelPendingRequest = !!pendingRequest
&& !!currentUserId
&& (pendingRequest.requestedById === currentUserId || canRequestApproval);
const canCancelPendingRequest =
!!pendingRequest &&
!!currentUserId &&
(pendingRequest.requestedById === currentUserId || canRequestApproval);
const handleDecision = async (decision: 'APPROVED' | 'REJECTED') => {
if (!pendingRequest) return;
const success = await onSubmitDecision(pendingRequest.id, decision, decisionNote.trim() || undefined);
const success = await onSubmitDecision(
pendingRequest.id,
decision,
decisionNote.trim() || undefined
);
if (success) {
setDecisionNote('');
}
@@ -98,7 +129,9 @@ export function ApprovalRequestsPanel({
<SheetContent side="right" className="w-full sm:max-w-xl p-0">
<SheetHeader>
<SheetTitle>Approvals</SheetTitle>
<SheetDescription>Review request history and respond to pending approvals.</SheetDescription>
<SheetDescription>
Review request history and respond to pending approvals.
</SheetDescription>
</SheetHeader>
<div className="px-4 pb-4 space-y-3 overflow-y-auto">
@@ -114,7 +147,11 @@ export function ApprovalRequestsPanel({
Request Approval
</Button>
<Button size="sm" variant="ghost" onClick={onRefresh} disabled={isLoadingRequests}>
{isLoadingRequests ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
{isLoadingRequests ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</Button>
</div>
</div>
@@ -129,7 +166,8 @@ export function ApprovalRequestsPanel({
<div className="rounded-md border p-3 space-y-2">
<p className="text-sm font-medium">Your response is required</p>
<p className="text-xs text-muted-foreground">
{pendingRequest.requestedBy.name || pendingRequest.requestedBy.email || 'A user'} requested approval.
{pendingRequest.requestedBy.name || pendingRequest.requestedBy.email || 'A user'}{' '}
requested approval.
</p>
<Textarea
value={decisionNote}
@@ -176,29 +214,39 @@ export function ApprovalRequestsPanel({
<div className="space-y-2">
{requests.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No approval requests yet.</p>
<p className="text-sm text-muted-foreground py-4 text-center">
No approval requests yet.
</p>
) : (
requests.map((request) => (
<div key={request.id} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">
Requested by {request.requestedBy.name || request.requestedBy.email || 'Unknown'}
Requested by{' '}
{request.requestedBy.name || request.requestedBy.email || 'Unknown'}
</p>
{statusBadge(request.status)}
</div>
{request.message ? (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{request.message}</p>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{request.message}
</p>
) : null}
<p className="text-xs text-muted-foreground">
{new Date(request.createdAt).toLocaleString()}
</p>
<div className="space-y-1">
{request.decisions.map((decision) => (
<div key={decision.id} className="flex items-center justify-between gap-2 text-xs">
<div
key={decision.id}
className="flex items-center justify-between gap-2 text-xs"
>
<span className="truncate">
{decision.approver.name || decision.approver.email || 'Unknown'}
</span>
<span className="text-muted-foreground">{decisionLabel(request.status, decision.status)}</span>
<span className="text-muted-foreground">
{decisionLabel(request.status, decision.status)}
</span>
</div>
))}
</div>
+46 -14
View File
@@ -68,9 +68,10 @@ export const AssetListSection = memo(function AssetListSection({
return (
<div className="space-y-2">
{assets.map((asset) => {
const isBunnyProcessing = asset.provider === 'BUNNY'
&& !!bunnyProcessingByAssetId[asset.id]
&& !bunnyReadyByAssetId[asset.id];
const isBunnyProcessing =
asset.provider === 'BUNNY' &&
!!bunnyProcessingByAssetId[asset.id] &&
!bunnyReadyByAssetId[asset.id];
return (
<div
key={asset.id}
@@ -96,22 +97,42 @@ export const AssetListSection = memo(function AssetListSection({
</div>
</div>
<p className="text-xs text-muted-foreground">
{asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} {new Date(asset.createdAt).toLocaleDateString()}
{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={asset.kind === 'VIDEO' ? 'Play video' : asset.kind === 'AUDIO' ? 'Play recording' : 'View image'}
aria-label={asset.kind === 'VIDEO' ? 'Play video' : asset.kind === 'AUDIO' ? 'Play recording' : 'View image'}
title={
asset.kind === 'VIDEO'
? 'Play video'
: asset.kind === 'AUDIO'
? 'Play recording'
: 'View image'
}
aria-label={
asset.kind === 'VIDEO'
? 'Play video'
: asset.kind === 'AUDIO'
? 'Play recording'
: 'View image'
}
onClick={() => onViewAsset(asset)}
>
{asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : asset.kind === 'AUDIO' ? <Volume2 className="h-3 w-3" /> : <Play className="h-3 w-3" />}
{asset.kind === 'IMAGE' ? (
<ImageIcon className="h-3 w-3" />
) : asset.kind === 'AUDIO' ? (
<Volume2 className="h-3 w-3" />
) : (
<Play className="h-3 w-3" />
)}
</Button>
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? (
{canDownloadAssets &&
asset.provider !== 'YOUTUBE' &&
(asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -122,7 +143,11 @@ export const AssetListSection = memo(function AssetListSection({
aria-label="Download asset"
disabled={activeDownloadAssetId === asset.id || isBunnyProcessing}
>
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
{activeDownloadAssetId === asset.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
@@ -146,10 +171,13 @@ export const AssetListSection = memo(function AssetListSection({
disabled={activeDownloadAssetId === asset.id || isBunnyProcessing}
onClick={() => onDownloadAsset(asset)}
>
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
{activeDownloadAssetId === asset.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Download className="h-3 w-3" />
)}
</Button>
)
)}
))}
{asset.canDelete && (
<Button
@@ -161,7 +189,11 @@ export const AssetListSection = memo(function AssetListSection({
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" />}
{activeDeleteAssetId === asset.id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3" />
)}
</Button>
)}
</div>
+441 -256
View File
@@ -3,7 +3,20 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as tus from 'tus-js-client';
import { toast } from 'sonner';
import { Download, FileVideo, Image as ImageIcon, Loader2, Mic, Pause, Play, Square, UploadCloud, Volume2, X, Youtube } from 'lucide-react';
import {
Download,
FileVideo,
Image as ImageIcon,
Loader2,
Mic,
Pause,
Play,
Square,
UploadCloud,
Volume2,
X,
Youtube,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
@@ -16,10 +29,16 @@ import {
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 {
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';
import {
extractPastedImageFile,
validateImageFile,
} from '@/components/video-page/image-upload-utils';
import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cn } from '@/lib/utils';
@@ -113,10 +132,16 @@ export const AssetsPane = memo(function AssetsPane({
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [isUploadingBunny, setIsUploadingBunny] = useState(false);
const [bunnyProgress, setBunnyProgress] = useState(0);
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>({});
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>(
{}
);
const [bunnyReadyByAssetId, setBunnyReadyByAssetId] = useState<Record<string, boolean>>({});
const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState<Record<string, number>>({});
const [bunnyThumbnailLoadErrorByAssetId, setBunnyThumbnailLoadErrorByAssetId] = useState<Record<string, boolean>>({});
const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState<
Record<string, number>
>({});
const [bunnyThumbnailLoadErrorByAssetId, setBunnyThumbnailLoadErrorByAssetId] = useState<
Record<string, boolean>
>({});
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null);
const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null);
@@ -145,7 +170,15 @@ export const AssetsPane = memo(function AssetsPane({
const dragCounterRef = useRef(0);
// Audio playback for asset preview dialog and recording preview
const { playingVoiceId, voiceProgress, voiceCurrentTime, voicePlaybackRate, playVoice, stopVoice, toggleVoiceSpeed } = useCommentMedia();
const {
playingVoiceId,
voiceProgress,
voiceCurrentTime,
voicePlaybackRate,
playVoice,
stopVoice,
toggleVoiceSpeed,
} = useCommentMedia();
const sortedAssets = useMemo(() => {
return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
@@ -158,7 +191,10 @@ export const AssetsPane = memo(function AssetsPane({
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
setFocusedAssetId(highlightedAssetId);
window.setTimeout(() => setFocusedAssetId((prev) => (prev === highlightedAssetId ? null : prev)), 2500);
window.setTimeout(
() => setFocusedAssetId((prev) => (prev === highlightedAssetId ? null : prev)),
2500
);
}
onHighlightedAssetHandled();
@@ -170,11 +206,14 @@ export const AssetsPane = memo(function AssetsPane({
const sendYouTubeCommand = (func: string, args: unknown[] = []) => {
const iframe = youtubeIframeRef.current;
if (!iframe?.contentWindow) return;
iframe.contentWindow.postMessage(JSON.stringify({
event: 'command',
func,
args,
}), '*');
iframe.contentWindow.postMessage(
JSON.stringify({
event: 'command',
func,
args,
}),
'*'
);
};
const onMessage = (event: MessageEvent) => {
@@ -186,7 +225,9 @@ export const AssetsPane = memo(function AssetsPane({
} catch {
return;
}
const info = (parsed as { info?: { currentTime?: number; playerState?: number; muted?: boolean } })?.info;
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;
@@ -202,9 +243,22 @@ export const AssetsPane = memo(function AssetsPane({
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;
if (
target &&
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
)
return;
const handledKeys = new Set(['Space', 'KeyK', 'ArrowLeft', 'ArrowRight', 'KeyJ', 'KeyL', 'KeyM', 'Escape']);
const handledKeys = new Set([
'Space',
'KeyK',
'ArrowLeft',
'ArrowRight',
'KeyJ',
'KeyL',
'KeyM',
'Escape',
]);
if (!handledKeys.has(event.code)) return;
event.preventDefault();
@@ -280,53 +334,61 @@ export const AssetsPane = memo(function AssetsPane({
useEffect(() => {
if (!selectedAsset || selectedAsset.provider !== 'BUNNY') return;
if (bunnyReadyByAssetId[selectedAsset.id]) return;
setBunnyProcessingByAssetId((prev) => (prev[selectedAsset.id] ? prev : { ...prev, [selectedAsset.id]: true }));
setBunnyProcessingByAssetId((prev) =>
prev[selectedAsset.id] ? prev : { ...prev, [selectedAsset.id]: true }
);
}, [bunnyReadyByAssetId, selectedAsset]);
const handleImageUpload = useCallback(async (file: File) => {
if (!file) return;
const handleImageUpload = useCallback(
async (file: File) => {
if (!file) return;
const imageError = await validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
setIsUploadingImage(true);
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; reservationId?: string | null }; error?: string } | null;
const uploadedImageUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedImageUrl) {
toast.error(uploadPayload?.error || 'Failed to upload image');
const imageError = await validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
await createAsset({
provider: 'R2_IMAGE',
sourceUrl: uploadedImageUrl,
displayName: imageTitle.trim() || file.name,
reservationId: uploadPayload?.data?.reservationId ?? null,
});
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');
} finally {
setIsUploadingImage(false);
}
}, [videoId, getGuestUploadToken, createAsset, imageTitle]);
setIsUploadingImage(true);
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; reservationId?: string | null };
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,
reservationId: uploadPayload?.data?.reservationId ?? null,
});
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');
} finally {
setIsUploadingImage(false);
}
},
[videoId, getGuestUploadToken, createAsset, imageTitle]
);
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -367,101 +429,104 @@ export const AssetsPane = memo(function AssetsPane({
}
};
const handleBunnyFileUpload = useCallback(async (file: File) => {
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');
const handleBunnyFileUpload = useCallback(
async (file: File) => {
if (!file.type.startsWith('video/')) {
toast.error('Please select a video file');
return;
}
const initData = initPayload.data;
uploadedVideoId = initData.videoId;
uploadToken = initData.uploadToken;
let uploadedVideoId: string | null = null;
let uploadToken: string | null = null;
try {
setIsUploadingBunny(true);
setBunnyProgress(0);
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 = bunnyCdnHostname
? `https://${bunnyCdnHostname}/${initData.videoId}/thumbnail.jpg`
: undefined;
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');
}
setBunnyReadyByAssetId((prev) => ({ ...prev, [createdAsset.id]: false }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [createdAsset.id]: true }));
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',
const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: uploadedVideoId, uploadToken }),
}).catch(() => undefined);
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 = bunnyCdnHostname
? `https://${bunnyCdnHostname}/${initData.videoId}/thumbnail.jpg`
: undefined;
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');
}
setBunnyReadyByAssetId((prev) => ({ ...prev, [createdAsset.id]: false }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [createdAsset.id]: true }));
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);
}
} finally {
setIsUploadingBunny(false);
setBunnyProgress(0);
}
}, [videoId, bunnyTitle, bunnyCdnHostname, createAsset]);
},
[videoId, bunnyTitle, bunnyCdnHostname, createAsset]
);
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -536,7 +601,10 @@ export const AssetsPane = memo(function AssetsPane({
setIsRecording(false);
setRecordingTime(0);
setAudioBlob(null);
setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
setAudioBlobUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
setPendingAudioFile(null);
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
@@ -574,9 +642,9 @@ export const AssetsPane = memo(function AssetsPane({
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'
uploadPayload?.error ||
(uploadRes.status === 413 ? MAX_AUDIO_UPLOAD_SIZE_MESSAGE : null) ||
'Failed to upload voice recording'
);
return;
}
@@ -592,7 +660,10 @@ export const AssetsPane = memo(function AssetsPane({
});
setVoiceTitle('');
setAudioBlob(null);
setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
setAudioBlobUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
setPendingAudioFile(null);
} catch (error) {
console.error('Failed to upload voice asset:', error);
@@ -602,12 +673,15 @@ export const AssetsPane = memo(function AssetsPane({
}
}, [pendingAudioFile, audioBlob, videoId, getGuestUploadToken, createAsset, voiceTitle]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!canUploadAssets) return;
dragCounterRef.current += 1;
if (e.dataTransfer.types.includes('Files')) setIsDragOver(true);
}, [canUploadAssets]);
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
if (!canUploadAssets) return;
dragCounterRef.current += 1;
if (e.dataTransfer.types.includes('Files')) setIsDragOver(true);
},
[canUploadAssets]
);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -619,38 +693,44 @@ export const AssetsPane = memo(function AssetsPane({
e.preventDefault();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0;
setIsDragOver(false);
if (!canUploadAssets) return;
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0;
setIsDragOver(false);
if (!canUploadAssets) return;
const file = Array.from(e.dataTransfer.files)[0];
if (!file) return;
const file = Array.from(e.dataTransfer.files)[0];
if (!file) return;
if (file.type.startsWith('image/')) {
const imageError = await validateImageFile(file);
if (imageError) { toast.error(imageError); return; }
// Stage the file so the user can optionally set a name before uploading
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;
if (file.type.startsWith('image/')) {
const imageError = await validateImageFile(file);
if (imageError) {
toast.error(imageError);
return;
}
// Stage the file so the user can optionally set a name before uploading
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
setUploadTab('voice');
setPendingAudioFile(file);
} else {
toast.error('Unsupported file type. Drop an image, video, or audio file.');
}
// Stage the file so the user can optionally set a name before uploading
setUploadTab('voice');
setPendingAudioFile(file);
} else {
toast.error('Unsupported file type. Drop an image, video, or audio file.');
}
}, [canUploadAssets, handleBunnyFileUpload]);
},
[canUploadAssets, handleBunnyFileUpload]
);
const renderAssetPreview = (asset: VideoAsset) => {
if (asset.kind === 'AUDIO') {
@@ -681,7 +761,10 @@ export const AssetsPane = memo(function AssetsPane({
<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`}
src={
asset.thumbnailUrl ||
`https://img.youtube.com/vi/${asset.providerVideoId}/mqdefault.jpg`
}
alt={asset.displayName}
className="h-full w-full object-contain"
/>
@@ -693,7 +776,9 @@ export const AssetsPane = memo(function AssetsPane({
const isProcessing = !!bunnyProcessingByAssetId[asset.id];
const isReadyToPlay = !!bunnyReadyByAssetId[asset.id];
const hasThumbnailLoadError = !!bunnyThumbnailLoadErrorByAssetId[asset.id];
const thumbnailSrc = asset.thumbnailUrl ? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}` : null;
const thumbnailSrc = asset.thumbnailUrl
? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}`
: null;
const showThumbnailImage = !!thumbnailSrc && !hasThumbnailLoadError;
return (
@@ -718,9 +803,7 @@ export const AssetsPane = memo(function AssetsPane({
{isProcessing && !isReadyToPlay && (
<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>
<span className="text-[10px] text-white/90 font-medium">Processing...</span>
</div>
)}
</div>
@@ -742,7 +825,9 @@ export const AssetsPane = memo(function AssetsPane({
return;
}
if (asset.provider === 'BUNNY' && !bunnyReadyByAssetId[asset.id]) {
setBunnyProcessingByAssetId((prev) => (prev[asset.id] ? prev : { ...prev, [asset.id]: true }));
setBunnyProcessingByAssetId((prev) =>
prev[asset.id] ? prev : { ...prev, [asset.id]: true }
);
}
setSelectedAsset(asset);
};
@@ -769,14 +854,24 @@ export const AssetsPane = memo(function AssetsPane({
</div>
{canUploadAssets ? (
<div className={cn('rounded-lg border p-3 space-y-3 relative transition-colors', isDragOver && 'border-primary bg-primary/5')}>
<div
className={cn(
'rounded-lg border p-3 space-y-3 relative transition-colors',
isDragOver && 'border-primary bg-primary/5'
)}
>
{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>
</div>
)}
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny' | 'voice')}>
<Tabs
value={uploadTab}
onValueChange={(value) =>
setUploadTab(value as 'image' | 'youtube' | 'bunny' | 'voice')
}
>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="image">Image</TabsTrigger>
<TabsTrigger value="youtube">YouTube</TabsTrigger>
@@ -792,8 +887,12 @@ export const AssetsPane = memo(function AssetsPane({
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>
<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>
@@ -823,10 +922,18 @@ export const AssetsPane = memo(function AssetsPane({
imageInputRef.current?.click();
}}
>
{isUploadingImage || isCreatingAsset
? <Loader2 className="h-4 w-4 mr-2 animate-spin" />
: <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingImage ? 'Uploading...' : isCreatingAsset ? 'Saving...' : pendingImageFile ? 'Upload Image' : 'Select Image'}
{isUploadingImage || isCreatingAsset ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<UploadCloud className="h-4 w-4 mr-2" />
)}
{isUploadingImage
? 'Uploading...'
: isCreatingAsset
? 'Saving...'
: pendingImageFile
? 'Upload Image'
: 'Select Image'}
</Button>
<input
ref={imageInputRef}
@@ -868,14 +975,20 @@ export const AssetsPane = memo(function AssetsPane({
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>
<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 ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<UploadCloud className="h-4 w-4 mr-2" />
)}
{isUploadingBunny ? 'Uploading...' : 'Upload Video'}
</Button>
<input
@@ -887,7 +1000,10 @@ export const AssetsPane = memo(function AssetsPane({
/>
{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="bg-primary h-2 rounded-full"
style={{ width: `${bunnyProgress}%` }}
/>
</div>
)}
</div>
@@ -920,7 +1036,11 @@ export const AssetsPane = memo(function AssetsPane({
disabled={isUploadingVoice || isCreatingAsset}
onClick={handleVoiceUpload}
>
{isUploadingVoice ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingVoice ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<UploadCloud className="h-4 w-4 mr-2" />
)}
{isUploadingVoice ? 'Uploading...' : 'Upload File'}
</Button>
</div>
@@ -933,13 +1053,26 @@ export const AssetsPane = memo(function AssetsPane({
</span>
<span className="text-red-500 font-medium">Recording</span>
<span className="ml-auto tabular-nums text-muted-foreground">
{String(Math.floor(recordingTime / 60)).padStart(2, '0')}:{String(recordingTime % 60).padStart(2, '0')}
{String(Math.floor(recordingTime / 60)).padStart(2, '0')}:
{String(recordingTime % 60).padStart(2, '0')}
</span>
</div>
<Button size="icon" variant="outline" className="h-9 w-9 shrink-0" title="Stop recording" onClick={stopRecording}>
<Button
size="icon"
variant="outline"
className="h-9 w-9 shrink-0"
title="Stop recording"
onClick={stopRecording}
>
<Square className="h-3.5 w-3.5 fill-current" />
</Button>
<Button size="icon" variant="ghost" className="h-9 w-9 shrink-0" title="Cancel recording" onClick={cancelRecording}>
<Button
size="icon"
variant="ghost"
className="h-9 w-9 shrink-0"
title="Cancel recording"
onClick={cancelRecording}
>
<X className="h-4 w-4" />
</Button>
</div>
@@ -950,14 +1083,23 @@ export const AssetsPane = memo(function AssetsPane({
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => audioBlobUrl && playVoice('recording-preview', audioBlobUrl, recordingTime)}
onClick={() =>
audioBlobUrl && playVoice('recording-preview', audioBlobUrl, recordingTime)
}
>
{playingVoiceId === 'recording-preview' ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{playingVoiceId === 'recording-preview' ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === 'recording-preview' ? `${voiceProgress}%` : '0%' }}
style={{
width:
playingVoiceId === 'recording-preview' ? `${voiceProgress}%` : '0%',
}}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
@@ -980,21 +1122,38 @@ export const AssetsPane = memo(function AssetsPane({
disabled={isUploadingVoice || isCreatingAsset}
onClick={handleVoiceUpload}
>
{isUploadingVoice ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingVoice ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<UploadCloud className="h-4 w-4 mr-2" />
)}
{isUploadingVoice ? 'Uploading...' : 'Upload Recording'}
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" title="Discard and re-record" onClick={cancelRecording}>
<Button
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
title="Discard and re-record"
onClick={cancelRecording}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<Button variant="outline" className="w-full" onClick={startRecording} disabled={isUploadingVoice || isCreatingAsset}>
<Button
variant="outline"
className="w-full"
onClick={startRecording}
disabled={isUploadingVoice || isCreatingAsset}
>
<Mic className="h-4 w-4 mr-2" />
Start Recording
</Button>
)}
<p className="text-xs text-muted-foreground">Or drag an audio file anywhere onto this panel.</p>
<p className="text-xs text-muted-foreground">
Or drag an audio file anywhere onto this panel.
</p>
</div>
)}
</div>
@@ -1033,7 +1192,15 @@ export const AssetsPane = memo(function AssetsPane({
}}
/>
<Dialog open={selectedAsset?.kind === 'AUDIO'} onOpenChange={(open) => { if (!open) { stopVoice(); setSelectedAsset(null); } }}>
<Dialog
open={selectedAsset?.kind === 'AUDIO'}
onOpenChange={(open) => {
if (!open) {
stopVoice();
setSelectedAsset(null);
}
}}
>
<DialogContent className="max-w-sm">
<DialogTitle>{selectedAsset?.displayName || 'Voice Recording'}</DialogTitle>
{selectedAsset?.sourceUrl ? (
@@ -1042,14 +1209,22 @@ export const AssetsPane = memo(function AssetsPane({
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => selectedAsset.sourceUrl && playVoice(selectedAsset.id, selectedAsset.sourceUrl)}
onClick={() =>
selectedAsset.sourceUrl && playVoice(selectedAsset.id, selectedAsset.sourceUrl)
}
>
{playingVoiceId === selectedAsset?.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
{playingVoiceId === selectedAsset?.id ? (
<Pause className="h-4 w-4" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === selectedAsset?.id ? `${voiceProgress}%` : '0%' }}
style={{
width: playingVoiceId === selectedAsset?.id ? `${voiceProgress}%` : '0%',
}}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
@@ -1070,7 +1245,10 @@ export const AssetsPane = memo(function AssetsPane({
</DialogContent>
</Dialog>
<Dialog open={selectedAsset?.kind === 'VIDEO'} onOpenChange={(open) => !open && setSelectedAsset(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"
@@ -1083,20 +1261,23 @@ export const AssetsPane = memo(function AssetsPane({
}
}}
>
<DialogTitle className="sr-only">{selectedAsset?.displayName || 'Video Preview'}</DialogTitle>
<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="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}>
<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"
>
<Button asChild variant="outline" size="sm" className="h-8 shrink-0">
<a
href={`https://www.youtube.com/watch?v=${selectedAsset.providerVideoId}`}
target="_blank"
@@ -1116,8 +1297,7 @@ export const AssetsPane = memo(function AssetsPane({
title="Download Bunny video"
aria-label="Download Bunny video"
disabled={
activeDownloadAssetId === selectedAsset.id
|| isSelectedBunnyProcessing
activeDownloadAssetId === selectedAsset.id || isSelectedBunnyProcessing
}
>
{activeDownloadAssetId === selectedAsset.id ? (
@@ -1132,7 +1312,9 @@ export const AssetsPane = memo(function AssetsPane({
<Download className="h-3 w-3 mr-2" />
Original
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void downloadAsset(selectedAsset, 'compressed')}>
<DropdownMenuItem
onClick={() => void downloadAsset(selectedAsset, 'compressed')}
>
<Download className="h-3 w-3 mr-2" />
Compressed
</DropdownMenuItem>
@@ -1151,32 +1333,35 @@ export const AssetsPane = memo(function AssetsPane({
</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
{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={isSelectedBunnyProcessing}
onReadyToPlay={() => {
if (!selectedBunnyAssetId) return;
setBunnyReadyByAssetId((prev) => ({ ...prev, [selectedBunnyAssetId]: true }));
setBunnyProcessingByAssetId((prev) => ({
...prev,
[selectedBunnyAssetId]: false,
}));
}}
/>
</div>
) : (
<BunnyPreviewPlayer
ref={bunnyPreviewPlayerRef}
providerVideoId={selectedAsset.providerVideoId}
isProcessing={isSelectedBunnyProcessing}
onReadyToPlay={() => {
if (!selectedBunnyAssetId) return;
setBunnyReadyByAssetId((prev) => ({ ...prev, [selectedBunnyAssetId]: true }));
setBunnyProcessingByAssetId((prev) => ({ ...prev, [selectedBunnyAssetId]: false }));
}}
/>
)
) : null}
)
) : null}
</div>
</div>
</DialogContent>
File diff suppressed because it is too large Load Diff
+65 -24
View File
@@ -2,7 +2,18 @@
import { memo, type RefObject } from 'react';
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 {
DropdownMenu,
@@ -145,12 +156,7 @@ export const CommentComposer = memo(function CommentComposer({
{voicePlaybackRate}x
</button>
)}
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={cancelRecording}
>
<Button size="icon" variant="ghost" className="h-8 w-8" onClick={cancelRecording}>
<X className="h-4 w-4" />
</Button>
</div>
@@ -158,12 +164,20 @@ export const CommentComposer = memo(function CommentComposer({
{imageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
<img
src={URL.createObjectURL(imageBlob)}
alt="Preview"
className="max-h-40 w-auto object-contain"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button size="icon" variant="destructive" onClick={() => {
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}>
<Button
size="icon"
variant="destructive"
onClick={() => {
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
@@ -205,7 +219,10 @@ export const CommentComposer = memo(function CommentComposer({
<span className="text-xs text-violet-400 font-medium">Annotation attached</span>
<button
className="ml-auto text-xs text-muted-foreground hover:text-destructive transition-colors"
onClick={() => { setAnnotationStrokes(null); setIsAnnotating(false); }}
onClick={() => {
setAnnotationStrokes(null);
setIsAnnotating(false);
}}
>
Remove
</button>
@@ -214,12 +231,20 @@ export const CommentComposer = memo(function CommentComposer({
{imageBlob && (
<div className="relative group rounded-md overflow-hidden bg-muted flex items-center justify-center max-h-40 mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={URL.createObjectURL(imageBlob)} alt="Preview" className="max-h-40 w-auto object-contain" />
<img
src={URL.createObjectURL(imageBlob)}
alt="Preview"
className="max-h-40 w-auto object-contain"
/>
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<Button size="icon" variant="destructive" onClick={() => {
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}>
<Button
size="icon"
variant="destructive"
onClick={() => {
setImageBlob(null);
if (imageInputRef.current) imageInputRef.current.value = '';
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
@@ -246,7 +271,11 @@ export const CommentComposer = memo(function CommentComposer({
<Button
size="icon"
onClick={handleAddComment}
disabled={(!commentText.trim() && !imageBlob && !annotationStrokes) || isSubmittingComment || isUploadingImage}
disabled={
(!commentText.trim() && !imageBlob && !annotationStrokes) ||
isSubmittingComment ||
isUploadingImage
}
>
{isSubmittingComment || isUploadingImage ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -279,7 +308,11 @@ export const CommentComposer = memo(function CommentComposer({
pauseVideoForAnnotation();
setIsAnnotating(true);
}}
title={annotationStrokes ? 'Annotation added ✓ (click to redraw)' : 'Draw annotation on video'}
title={
annotationStrokes
? 'Annotation added ✓ (click to redraw)'
: 'Draw annotation on video'
}
>
<Pencil className="h-4 w-4" />
</Button>
@@ -297,9 +330,14 @@ export const CommentComposer = memo(function CommentComposer({
size="icon"
variant={selectedTagId ? 'default' : 'outline'}
title="Select tag"
style={selectedTagId ? {
backgroundColor: availableTags.find(t => t.id === selectedTagId)?.color
} : undefined}
style={
selectedTagId
? {
backgroundColor: availableTags.find((t) => t.id === selectedTagId)
?.color,
}
: undefined
}
>
<Tag className="h-4 w-4" />
</Button>
@@ -323,7 +361,10 @@ export const CommentComposer = memo(function CommentComposer({
<>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/settings#comment-tags`} className="gap-2 text-muted-foreground">
<Link
href={`/projects/${projectId}/settings#comment-tags`}
className="gap-2 text-muted-foreground"
>
<Tag className="h-3 w-3" />
Manage Tags
</Link>
+10 -6
View File
@@ -68,15 +68,19 @@ export function CommentRichText({ text, onAssetMentionClick, assets = [] }: Comm
assetKind === 'VIDEO'
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-violet-500/25 text-violet-200'
: assetKind === 'AUDIO'
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-blue-500/25 text-blue-200'
: 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-emerald-500/25 text-emerald-200'
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-blue-500/25 text-blue-200'
: 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-emerald-500/25 text-emerald-200'
}
>
{assetKind === 'VIDEO' ? <Video className="h-2.5 w-2.5" /> : assetKind === 'AUDIO' ? <Volume2 className="h-2.5 w-2.5" /> : <ImageIcon className="h-2.5 w-2.5" />}
</span>
<span className="truncate max-w-[190px] sm:max-w-[240px]">
@{label}
{assetKind === 'VIDEO' ? (
<Video className="h-2.5 w-2.5" />
) : assetKind === 'AUDIO' ? (
<Volume2 className="h-2.5 w-2.5" />
) : (
<ImageIcon className="h-2.5 w-2.5" />
)}
</span>
<span className="truncate max-w-[190px] sm:max-w-[240px]">@{label}</span>
</button>
);
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,13 @@ import { memo } from 'react';
import { CheckCircle2, GitCompareArrows } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
import type { Version } from '@/components/video-page/types';
@@ -30,9 +36,7 @@ export const CompareVersionsDialog = memo(function CompareVersionsDialog({
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Select Versions to Compare</DialogTitle>
<DialogDescription>
Choose 2 or more versions to compare side by side.
</DialogDescription>
<DialogDescription>Choose 2 or more versions to compare side by side.</DialogDescription>
</DialogHeader>
<div className="space-y-2 mt-2 max-h-64 overflow-y-auto">
{versions
@@ -46,9 +50,7 @@ export const CompareVersionsDialog = memo(function CompareVersionsDialog({
type="button"
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border text-left transition-colors',
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:bg-accent/50'
isSelected ? 'border-primary bg-primary/5' : 'border-border hover:bg-accent/50'
)}
onClick={() => onToggleVersion(v.id)}
>
@@ -60,9 +62,7 @@ export const CompareVersionsDialog = memo(function CompareVersionsDialog({
: 'border-muted-foreground/40'
)}
>
{isSelected && (
<CheckCircle2 className="h-3 w-3" />
)}
{isSelected && <CheckCircle2 className="h-3 w-3" />}
</div>
<Badge variant="secondary">v{v.versionNumber}</Badge>
<span className="text-sm font-medium truncate">
+11 -5
View File
@@ -10,7 +10,11 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
import type {
BunnyDownloadPreference,
DownloadTarget,
Version,
} from '@/components/video-page/types';
interface DownloadControlsProps {
activeVersion: Version | null | undefined;
@@ -39,8 +43,9 @@ export const DownloadControls = memo(function DownloadControls({
}: DownloadControlsProps) {
if (!activeVersion) return null;
const isVideoDownloadAvailable = videoCanDownload
&& (activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
const isVideoDownloadAvailable =
videoCanDownload &&
(activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
if (activeVersion.providerId === 'bunny') {
return (
@@ -173,8 +178,9 @@ export const DownloadMenuItems = memo(function DownloadMenuItems({
}: DownloadMenuItemsProps) {
if (!activeVersion) return null;
const isVideoDownloadAvailable = videoCanDownload
&& (activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
const isVideoDownloadAvailable =
videoCanDownload &&
(activeVersion.providerId === 'bunny' || activeVersion.providerId === 'direct');
if (activeVersion.providerId === 'bunny') {
return (
+1 -5
View File
@@ -41,11 +41,7 @@ export const GuestNameGate = memo(function GuestNameGate({
}}
autoFocus
/>
<Button
className="w-full"
disabled={!guestName.trim()}
onClick={onConfirm}
>
<Button className="w-full" disabled={!guestName.trim()} onClick={onConfirm}>
Continue
</Button>
</div>
+79 -70
View File
@@ -50,7 +50,9 @@ export function useApprovals({ projectId, activeVersionId, currentUserId }: UseA
setIsLoadingCandidates(true);
setError('');
try {
const res = await fetch(`/api/projects/${projectId}/approval-candidates`, { cache: 'no-store' });
const res = await fetch(`/api/projects/${projectId}/approval-candidates`, {
cache: 'no-store',
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to fetch approvers');
@@ -64,80 +66,85 @@ export function useApprovals({ projectId, activeVersionId, currentUserId }: UseA
}
}, [projectId]);
const createRequest = useCallback(async (approverIds: string[], message?: string) => {
if (!activeVersionId) return false;
setIsSubmittingRequest(true);
setError('');
try {
const res = await fetch(`/api/versions/${activeVersionId}/approvals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approverIds, message: message || undefined }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to create approval request');
const createRequest = useCallback(
async (approverIds: string[], message?: string) => {
if (!activeVersionId) return false;
setIsSubmittingRequest(true);
setError('');
try {
const res = await fetch(`/api/versions/${activeVersionId}/approvals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approverIds, message: message || undefined }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to create approval request');
return false;
}
await fetchRequests();
return true;
} catch {
setError('Failed to create approval request');
return false;
} finally {
setIsSubmittingRequest(false);
}
await fetchRequests();
return true;
} catch {
setError('Failed to create approval request');
return false;
} finally {
setIsSubmittingRequest(false);
}
}, [activeVersionId, fetchRequests]);
},
[activeVersionId, fetchRequests]
);
const submitDecision = useCallback(async (
requestId: string,
decision: 'APPROVED' | 'REJECTED',
note?: string
) => {
setIsSubmittingDecision(true);
setError('');
try {
const res = await fetch(`/api/approvals/${requestId}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision, note: note || undefined }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to submit approval decision');
const submitDecision = useCallback(
async (requestId: string, decision: 'APPROVED' | 'REJECTED', note?: string) => {
setIsSubmittingDecision(true);
setError('');
try {
const res = await fetch(`/api/approvals/${requestId}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision, note: note || undefined }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to submit approval decision');
return false;
}
await fetchRequests();
return true;
} catch {
setError('Failed to submit approval decision');
return false;
} finally {
setIsSubmittingDecision(false);
}
await fetchRequests();
return true;
} catch {
setError('Failed to submit approval decision');
return false;
} finally {
setIsSubmittingDecision(false);
}
}, [fetchRequests]);
},
[fetchRequests]
);
const cancelRequest = useCallback(async (requestId: string) => {
setIsCancelingRequest(true);
setError('');
try {
const res = await fetch(`/api/approvals/${requestId}/cancel`, {
method: 'POST',
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to cancel approval request');
const cancelRequest = useCallback(
async (requestId: string) => {
setIsCancelingRequest(true);
setError('');
try {
const res = await fetch(`/api/approvals/${requestId}/cancel`, {
method: 'POST',
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to cancel approval request');
return false;
}
await fetchRequests();
return true;
} catch {
setError('Failed to cancel approval request');
return false;
} finally {
setIsCancelingRequest(false);
}
await fetchRequests();
return true;
} catch {
setError('Failed to cancel approval request');
return false;
} finally {
setIsCancelingRequest(false);
}
}, [fetchRequests]);
},
[fetchRequests]
);
const activePendingRequest = useMemo(
() => requests.find((request) => request.status === 'PENDING') || null,
@@ -146,9 +153,11 @@ export function useApprovals({ projectId, activeVersionId, currentUserId }: UseA
const myPendingDecision = useMemo(() => {
if (!currentUserId || !activePendingRequest) return null;
return activePendingRequest.decisions.find(
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
) || null;
return (
activePendingRequest.decisions.find(
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
) || null
);
}, [activePendingRequest, currentUserId]);
return {
File diff suppressed because it is too large Load Diff
@@ -24,9 +24,10 @@ export function useCommentMedia() {
const tick = () => {
const audio = audioPlayerRef.current;
if (audio) {
const dur = isFinite(audio.duration) && audio.duration > 0
? audio.duration
: voiceKnownDurationRef.current;
const dur =
isFinite(audio.duration) && audio.duration > 0
? audio.duration
: voiceKnownDurationRef.current;
if (dur > 0) {
setVoiceProgress((audio.currentTime / dur) * 100);
setVoiceCurrentTime(audio.currentTime);
@@ -37,54 +38,57 @@ export function useCommentMedia() {
voiceRafRef.current = requestAnimationFrame(tick);
}, [stopVoiceTracking]);
const playVoice = useCallback((commentId: string, voiceUrl: string, knownDuration?: number) => {
if (playingVoiceId === commentId) {
const playVoice = useCallback(
(commentId: string, voiceUrl: string, knownDuration?: number) => {
if (playingVoiceId === commentId) {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
return;
}
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
setPlayingVoiceId(null);
voiceKnownDurationRef.current = knownDuration || 0;
const audio = new Audio(voiceUrl);
audio.playbackRate = voicePlaybackRate;
audioPlayerRef.current = audio;
setPlayingVoiceId(commentId);
setVoiceProgress(0);
setVoiceCurrentTime(0);
return;
}
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
}
stopVoiceTracking();
audio.onplay = () => {
startVoiceTracking();
};
voiceKnownDurationRef.current = knownDuration || 0;
const audio = new Audio(voiceUrl);
audio.playbackRate = voicePlaybackRate;
audioPlayerRef.current = audio;
setPlayingVoiceId(commentId);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audio.onended = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.onplay = () => {
startVoiceTracking();
};
audio.onerror = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.onended = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
audio.onerror = () => {
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
audioPlayerRef.current = null;
};
void audio.play();
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
void audio.play();
},
[playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]
);
const stopVoice = useCallback(() => {
if (audioPlayerRef.current) {
@@ -2,7 +2,13 @@
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import type { BunnyDownloadPreference, Comment, DownloadTarget, Version, VideoData } from '@/components/video-page/types';
import type {
BunnyDownloadPreference,
Comment,
DownloadTarget,
Version,
VideoData,
} from '@/components/video-page/types';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
function sanitizeDownloadFileName(value: string): string {
@@ -54,73 +60,80 @@ export function useDownloadActions({ activeVersion, video }: UseDownloadActionsP
const [activeDownloadTarget, setActiveDownloadTarget] = useState<DownloadTarget | null>(null);
const isDownloadingVideo = activeDownloadTarget !== null;
const startDownload = useCallback(async (preference: BunnyDownloadPreference = 'compressed') => {
if (!activeVersion || !video || isDownloadingVideo) return;
if (!video.canDownload) {
toast.error('Download is disabled for this shared link');
return;
}
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
toast.error('This video source does not support direct download');
return;
}
const startDownload = useCallback(
async (preference: BunnyDownloadPreference = 'compressed') => {
if (!activeVersion || !video || isDownloadingVideo) return;
if (!video.canDownload) {
toast.error('Download is disabled for this shared link');
return;
}
if (activeVersion.providerId !== 'bunny' && activeVersion.providerId !== 'direct') {
toast.error('This video source does not support direct download');
return;
}
const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct';
setActiveDownloadTarget(target);
try {
let downloadUrl: string | null = null;
const target: DownloadTarget = activeVersion.providerId === 'bunny' ? preference : 'direct';
setActiveDownloadTarget(target);
try {
let downloadUrl: string | null = null;
if (activeVersion.providerId === 'bunny') {
const prepareRes = await fetch(`/api/versions/${activeVersion.id}/download?source=${preference}&prepare=1`, {
cache: 'no-store',
});
if (activeVersion.providerId === 'bunny') {
const prepareRes = await fetch(
`/api/versions/${activeVersion.id}/download?source=${preference}&prepare=1`,
{
cache: 'no-store',
}
);
if (!prepareRes.ok) {
const prepareBody = await prepareRes.json().catch(() => null);
const fallbackError = preference === 'original'
? 'Original file is not available for this video'
: 'Compressed file is not available for this video';
const errorMessage = typeof prepareBody?.error === 'string'
? prepareBody.error
: fallbackError;
throw new Error(errorMessage);
if (!prepareRes.ok) {
const prepareBody = await prepareRes.json().catch(() => null);
const fallbackError =
preference === 'original'
? 'Original file is not available for this video'
: 'Compressed file is not available for this video';
const errorMessage =
typeof prepareBody?.error === 'string' ? prepareBody.error : fallbackError;
throw new Error(errorMessage);
}
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
} else {
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
if (!downloadUrl) {
throw new Error('Direct download URL is not allowed');
}
}
downloadUrl = `/api/versions/${activeVersion.id}/download?source=${preference}`;
} else {
downloadUrl = getSafeDirectDownloadUrl(activeVersion.originalUrl);
if (!downloadUrl) {
throw new Error('Direct download URL is not allowed');
throw new Error('Missing download URL');
}
}
if (!downloadUrl) {
throw new Error('Missing download URL');
const versionLabel =
activeVersion.versionLabel?.trim() || `v${activeVersion.versionNumber}`;
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
const a = document.createElement('a');
a.href = downloadUrl;
if (activeVersion.providerId === 'direct') {
a.download = `${baseName}.mp4`;
}
document.body.appendChild(a);
a.click();
a.remove();
} catch (error) {
console.error('Failed to start video download:', error);
if (error instanceof Error && error.message === 'Direct download URL is not allowed') {
toast.error('This direct download host is not allowed');
} else if (error instanceof Error && error.message) {
toast.error(error.message);
} else {
toast.error('Failed to start download');
}
} finally {
setActiveDownloadTarget(null);
}
const versionLabel = activeVersion.versionLabel?.trim() || `v${activeVersion.versionNumber}`;
const baseName = sanitizeDownloadFileName(`${video.title} ${versionLabel}`) || 'video';
const a = document.createElement('a');
a.href = downloadUrl;
if (activeVersion.providerId === 'direct') {
a.download = `${baseName}.mp4`;
}
document.body.appendChild(a);
a.click();
a.remove();
} catch (error) {
console.error('Failed to start video download:', error);
if (error instanceof Error && error.message === 'Direct download URL is not allowed') {
toast.error('This direct download host is not allowed');
} else if (error instanceof Error && error.message) {
toast.error(error.message);
} else {
toast.error('Failed to start download');
}
} finally {
setActiveDownloadTarget(null);
}
}, [activeVersion, video, isDownloadingVideo]);
},
[activeVersion, video, isDownloadingVideo]
);
return {
activeDownloadTarget,
@@ -3,7 +3,12 @@
import { useState, type Dispatch, type SetStateAction } from 'react';
import { toast } from 'sonner';
import * as tus from 'tus-js-client';
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
import {
parseVideoUrl,
getThumbnailUrl,
fetchVideoMetadata,
type VideoSource,
} from '@/lib/video-providers';
import type { VersionActionsConfig, VideoData } from '@/components/video-page/types';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
@@ -94,7 +99,9 @@ export function useVersionActions({
});
if (!initRes.ok) throw new Error('Failed to initialize upload');
const { data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken } } = await initRes.json();
const {
data: { videoId: bunnyVideoId, libraryId, signature, expirationTime, uploadToken },
} = await initRes.json();
uploadedBunnyVideoId = bunnyVideoId;
uploadedBunnyUploadToken = uploadToken;
@@ -179,7 +186,10 @@ export function useVersionActions({
await fetch(`/api/projects/${projectId}/videos/bunny-init`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ videoId: uploadedBunnyVideoId, uploadToken: uploadedBunnyUploadToken }),
body: JSON.stringify({
videoId: uploadedBunnyVideoId,
uploadToken: uploadedBunnyUploadToken,
}),
}).catch((cleanupError) => {
console.error('Failed to cleanup pending Bunny version upload:', cleanupError);
});
+167 -138
View File
@@ -64,45 +64,54 @@ export function useVideoAssets({
const assetsEtagRef = useRef<string | null>(null);
const isMutatingRef = useRef(false);
const fetchAssets = useCallback(async (options?: { useEtag?: boolean; silent?: boolean }) => {
const useEtag = options?.useEtag ?? false;
const silent = options?.silent ?? false;
if (!silent) setIsLoadingAssets(true);
try {
const headers: HeadersInit = {};
if (useEtag && assetsEtagRef.current) {
headers['If-None-Match'] = assetsEtagRef.current;
}
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=0`, { cache: 'no-store', headers });
if (res.status === 304) return;
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
if (!res.ok) {
if (!silent) {
toast.error(payload?.error || 'Failed to fetch assets');
const fetchAssets = useCallback(
async (options?: { useEtag?: boolean; silent?: boolean }) => {
const useEtag = options?.useEtag ?? false;
const silent = options?.silent ?? false;
if (!silent) setIsLoadingAssets(true);
try {
const headers: HeadersInit = {};
if (useEtag && assetsEtagRef.current) {
headers['If-None-Match'] = assetsEtagRef.current;
}
return;
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=0`, {
cache: 'no-store',
headers,
});
if (res.status === 304) return;
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
if (!res.ok) {
if (!silent) {
toast.error(payload?.error || 'Failed to fetch assets');
}
return;
}
const etag = res.headers.get('etag');
if (etag) assetsEtagRef.current = etag;
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 {
if (!silent) {
toast.error('Failed to fetch assets');
}
} finally {
if (!silent) setIsLoadingAssets(false);
}
const etag = res.headers.get('etag');
if (etag) assetsEtagRef.current = etag;
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 {
if (!silent) {
toast.error('Failed to fetch assets');
}
} finally {
if (!silent) setIsLoadingAssets(false);
}
}, [videoId]);
},
[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 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');
@@ -110,7 +119,10 @@ export function useVideoAssets({
}
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))]);
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 {
@@ -145,118 +157,135 @@ export function useVideoAssets({
};
}, [fetchAssets, isLoadingMoreAssets]);
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);
isMutatingRef.current = 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');
const createAsset = useCallback(
async (payload: CreateAssetPayload): Promise<VideoAsset | null> => {
if (!canUploadAssets) {
toast.error('You do not have permission to upload assets');
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);
isMutatingRef.current = false;
}
}, [canUploadAssets, videoId, isAuthenticated, guestName]);
const deleteAsset = useCallback(async (assetId: string) => {
setActiveDeleteAssetId(assetId);
isMutatingRef.current = true;
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);
isMutatingRef.current = false;
}
}, [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;
setIsCreatingAsset(true);
isMutatingRef.current = 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;
}
downloadUrl = `${downloadUrl}?source=${preference}`;
setAssets((prev) => [body.data!, ...prev]);
setNextAssetsOffset((prev) => prev + 1);
return body.data;
} catch {
toast.error('Failed to create asset');
return null;
} finally {
setIsCreatingAsset(false);
isMutatingRef.current = false;
}
},
[canUploadAssets, videoId, isAuthenticated, guestName]
);
const deleteAsset = useCallback(
async (assetId: string) => {
setActiveDeleteAssetId(assetId);
isMutatingRef.current = true;
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);
isMutatingRef.current = false;
}
},
[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;
}
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]);
setActiveDownloadAssetId(asset.id);
try {
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
const getGuestUploadToken = useCallback(async (intent: 'image' | 'audio') => {
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]);
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' | 'audio') => {
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,
@@ -9,11 +9,7 @@ interface UseVideoPageDataParams {
propProjectId?: string;
}
export function useVideoPageData({
mode,
videoId,
propProjectId,
}: UseVideoPageDataParams) {
export function useVideoPageData({ mode, videoId, propProjectId }: UseVideoPageDataParams) {
const [video, setVideo] = useState<VideoData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -79,11 +75,11 @@ export function useVideoPageData({
return {
...prev,
versions: prev.versions.map((version) => (
versions: prev.versions.map((version) =>
version.id === versionId
? { ...version, comments: commentsList, _count: { comments: totalComments } }
: version
)),
),
};
});
}, []);
@@ -94,9 +90,10 @@ export function useVideoPageData({
const res = await fetch(apiBasePath, { cache: 'no-store' });
if (!res.ok) {
const errorText = mode === 'dashboard' ? await res.text() : '';
setError(mode === 'dashboard'
? `Failed to load video: ${res.status} ${errorText}`
: 'Video not found or access denied'
setError(
mode === 'dashboard'
? `Failed to load video: ${res.status} ${errorText}`
: 'Video not found or access denied'
);
setLoading(false);
return;
@@ -114,7 +111,8 @@ export function useVideoPageData({
};
setVideo(normalizedData);
const active = normalizedData.versions?.find((v) => v.isActive) || normalizedData.versions?.[0];
const active =
normalizedData.versions?.find((v) => v.isActive) || normalizedData.versions?.[0];
if (active) setActiveVersionId(active.id);
} catch (err) {
console.error('Error fetching video:', err);
+202 -131
View File
@@ -27,12 +27,9 @@ interface UseVideoPlayerParams {
playerRef: RefObject<YT.Player | PlayerAdapter | null>;
formatBunnyQualityLabel: (level: { height?: number; bitrate?: number }, index: number) => string;
speedOptions: number[];
scheduleWatchProgressSaveRef: RefObject<(input: {
progress: number;
duration?: number;
immediate?: boolean;
force?: boolean;
}) => void>;
scheduleWatchProgressSaveRef: RefObject<
(input: { progress: number; duration?: number; immediate?: boolean; force?: boolean }) => void
>;
setViewingAnnotation: (strokes: AnnotationStroke[] | null) => void;
}
@@ -166,11 +163,19 @@ export function useVideoPlayer({
setIsBunnyPortraitSource(false);
if (playerRef.current) {
try { playerRef.current.destroy(); } catch { /* ignore */ }
try {
playerRef.current.destroy();
} catch {
/* ignore */
}
playerRef.current = null;
}
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
try {
hlsRef.current.destroy();
} catch {
/* ignore */
}
hlsRef.current = null;
}
if (bunnyRetryTimerRef.current) {
@@ -222,7 +227,8 @@ export function useVideoPlayer({
let retryAttempt = 0;
let usingHlsJs = false;
let hlsInstance: Hls | null = null;
let sourceMode: 'hls' | 'original' = bunnySourcePreference === 'original' ? 'original' : 'hls';
let sourceMode: 'hls' | 'original' =
bunnySourcePreference === 'original' ? 'original' : 'hls';
const clearRetryTimer = () => {
if (bunnyRetryTimerRef.current) {
clearTimeout(bunnyRetryTimerRef.current);
@@ -268,7 +274,11 @@ export function useVideoPlayer({
usingHlsJs = false;
clearRetryTimer();
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
try {
hlsRef.current.destroy();
} catch {
/* ignore */
}
hlsRef.current = null;
}
hlsInstance = null;
@@ -288,7 +298,10 @@ export function useVideoPlayer({
const saveProgress = () => {
const current = videoEl.currentTime || 0;
const duration = Number.isFinite(videoEl.duration) && videoEl.duration > 0 ? videoEl.duration : cachedDuration;
const duration =
Number.isFinite(videoEl.duration) && videoEl.duration > 0
? videoEl.duration
: cachedDuration;
scheduleWatchProgressSaveRef.current({
progress: current,
duration,
@@ -310,17 +323,23 @@ export function useVideoPlayer({
setIsReady(true);
const resumeState = bunnySourceSwitchResumeRef.current;
if (resumeState) {
const knownDuration = Number.isFinite(videoEl.duration) && videoEl.duration > 0
? videoEl.duration
: cachedDuration;
const targetTime = knownDuration > 0
? Math.min(Math.max(0, resumeState.time), Math.max(0, knownDuration - 0.01))
: Math.max(0, resumeState.time);
const knownDuration =
Number.isFinite(videoEl.duration) && videoEl.duration > 0
? videoEl.duration
: cachedDuration;
const targetTime =
knownDuration > 0
? Math.min(Math.max(0, resumeState.time), Math.max(0, knownDuration - 0.01))
: Math.max(0, resumeState.time);
videoEl.currentTime = targetTime;
setCurrentTime(targetTime);
bunnySourceSwitchResumeRef.current = null;
if (resumeState.wasPlaying) {
videoEl.play().catch((err) => console.error('Error resuming Bunny video after source switch:', err));
videoEl
.play()
.catch((err) =>
console.error('Error resuming Bunny video after source switch:', err)
);
}
}
syncDuration();
@@ -348,7 +367,11 @@ export function useVideoPlayer({
if (!isDraggingRef.current) {
setCurrentTime(videoEl.currentTime || 0);
}
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0 && videoEl.duration !== cachedDuration) {
if (
Number.isFinite(videoEl.duration) &&
videoEl.duration > 0 &&
videoEl.duration !== cachedDuration
) {
cachedDuration = videoEl.duration;
setVideoDuration(videoEl.duration);
}
@@ -380,10 +403,12 @@ export function useVideoPlayer({
videoEl.addEventListener('error', onVideoError);
const configureHlsLevels = (levels: Level[]) => {
setQualityOptions(levels.map((level, index) => ({
level: index,
label: formatBunnyQualityLabel(level, index),
})));
setQualityOptions(
levels.map((level, index) => ({
level: index,
label: formatBunnyQualityLabel(level, index),
}))
);
const pendingQuality = pendingHlsQualityRef.current;
pendingHlsQualityRef.current = null;
@@ -438,24 +463,29 @@ export function useVideoPlayer({
hls.on(Hls.Events.ERROR, (_, data) => {
if (destroyed) return;
const responseCode = (data as { response?: { code?: number } }).response?.code;
const isManifestLoadFailure = data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR
|| data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
const hasProcessingLikeStatus = responseCode === undefined
|| responseCode === 0
|| responseCode === 403
|| responseCode === 404
|| responseCode === 423
|| responseCode === 429
|| responseCode === 503;
const isLikelyProcessing = isManifestLoadFailure
&& hasProcessingLikeStatus;
const isNetworkPreMetadataProcessing = data.type === Hls.ErrorTypes.NETWORK_ERROR
&& hasProcessingLikeStatus
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
const isUnknownPreMetadataProcessing = !data.details
&& !data.type
&& videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
if (isLikelyProcessing || isNetworkPreMetadataProcessing || isUnknownPreMetadataProcessing) {
const isManifestLoadFailure =
data.details === Hls.ErrorDetails.MANIFEST_LOAD_ERROR ||
data.details === Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT;
const hasProcessingLikeStatus =
responseCode === undefined ||
responseCode === 0 ||
responseCode === 403 ||
responseCode === 404 ||
responseCode === 423 ||
responseCode === 429 ||
responseCode === 503;
const isLikelyProcessing = isManifestLoadFailure && hasProcessingLikeStatus;
const isNetworkPreMetadataProcessing =
data.type === Hls.ErrorTypes.NETWORK_ERROR &&
hasProcessingLikeStatus &&
videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
const isUnknownPreMetadataProcessing =
!data.details && !data.type && videoEl.readyState < HTMLMediaElement.HAVE_METADATA;
if (
isLikelyProcessing ||
isNetworkPreMetadataProcessing ||
isUnknownPreMetadataProcessing
) {
if (activateOriginalFallback()) {
return;
}
@@ -495,11 +525,10 @@ export function useVideoPlayer({
if (Number.isFinite(videoEl.duration) && videoEl.duration > 0) return videoEl.duration;
return cachedDuration;
},
getPlayerState: () => (
getPlayerState: () =>
videoEl.paused
? (window.YT?.PlayerState?.PAUSED ?? 2)
: (window.YT?.PlayerState?.PLAYING ?? 1)
),
: (window.YT?.PlayerState?.PLAYING ?? 1),
setPlaybackRate: (rate: number) => {
videoEl.playbackRate = rate;
},
@@ -513,7 +542,11 @@ export function useVideoPlayer({
videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('error', onVideoError);
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
try {
hlsRef.current.destroy();
} catch {
/* ignore */
}
hlsRef.current = null;
}
videoEl.removeAttribute('src');
@@ -541,11 +574,19 @@ export function useVideoPlayer({
window.onYouTubeIframeAPIReady = undefined;
}
if (playerRef.current) {
try { playerRef.current.destroy(); } catch { /* ignore */ }
try {
playerRef.current.destroy();
} catch {
/* ignore */
}
playerRef.current = null;
}
if (hlsRef.current) {
try { hlsRef.current.destroy(); } catch { /* ignore */ }
try {
hlsRef.current.destroy();
} catch {
/* ignore */
}
hlsRef.current = null;
}
if (bunnyRetryTimerRef.current) {
@@ -553,25 +594,44 @@ export function useVideoPlayer({
bunnyRetryTimerRef.current = null;
}
};
}, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, canInitializePlayer, formatBunnyQualityLabel, bunnySourcePreference, hlsRef, iframeRef, playerRef, scheduleWatchProgressSaveRef, videoRef]);
}, [
activeProviderId,
activeVersionId,
embedUrl,
isApiLoaded,
canInitializePlayer,
formatBunnyQualityLabel,
bunnySourcePreference,
hlsRef,
iframeRef,
playerRef,
scheduleWatchProgressSaveRef,
videoRef,
]);
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().then(() => {
setIsFullscreenMode(true);
setShowComments(false);
}).catch((err) => {
console.error('Fullscreen failed:', err);
toast.error('Unable to enter fullscreen mode');
});
document.documentElement
.requestFullscreen()
.then(() => {
setIsFullscreenMode(true);
setShowComments(false);
})
.catch((err) => {
console.error('Fullscreen failed:', err);
toast.error('Unable to enter fullscreen mode');
});
} else {
document.exitFullscreen().then(() => {
setIsFullscreenMode(false);
setShowComments(true);
}).catch((err) => {
console.error('Exit fullscreen failed:', err);
toast.error('Unable to exit fullscreen mode');
});
document
.exitFullscreen()
.then(() => {
setIsFullscreenMode(false);
setShowComments(true);
})
.catch((err) => {
console.error('Exit fullscreen failed:', err);
toast.error('Unable to exit fullscreen mode');
});
}
}, []);
@@ -730,7 +790,16 @@ export function useVideoPlayer({
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isPlaying, currentTime, duration, isMuted, playbackSpeed, speedOptions, toggleFullscreen, playerRef]);
}, [
isPlaying,
currentTime,
duration,
isMuted,
playbackSpeed,
speedOptions,
toggleFullscreen,
playerRef,
]);
const handlePlayPause = useCallback(() => {
if (!playerRef.current) return;
@@ -741,41 +810,41 @@ export function useVideoPlayer({
}
}, [isPlaying, playerRef]);
const handleSeekToTimestamp = useCallback((
timestamp: number,
annotation?: string | null,
options?: { pauseAfterSeek?: boolean }
) => {
setCurrentTime(timestamp);
if (playerRef.current?.seekTo) {
const playerState = playerRef.current.getPlayerState?.();
const ytPlayingState = window.YT?.PlayerState?.PLAYING ?? 1;
const ytBufferingState = window.YT?.PlayerState?.BUFFERING ?? 3;
const wasPlayingBeforeSeek = typeof playerState === 'number'
? playerState === ytPlayingState || playerState === ytBufferingState
: isPlaying;
const handleSeekToTimestamp = useCallback(
(timestamp: number, annotation?: string | null, options?: { pauseAfterSeek?: boolean }) => {
setCurrentTime(timestamp);
if (playerRef.current?.seekTo) {
const playerState = playerRef.current.getPlayerState?.();
const ytPlayingState = window.YT?.PlayerState?.PLAYING ?? 1;
const ytBufferingState = window.YT?.PlayerState?.BUFFERING ?? 3;
const wasPlayingBeforeSeek =
typeof playerState === 'number'
? playerState === ytPlayingState || playerState === ytBufferingState
: isPlaying;
playerRef.current.seekTo(timestamp, true);
if (options?.pauseAfterSeek) {
playerRef.current.pauseVideo();
} else if (wasPlayingBeforeSeek) {
playerRef.current.playVideo();
} else {
playerRef.current.pauseVideo();
playerRef.current.seekTo(timestamp, true);
if (options?.pauseAfterSeek) {
playerRef.current.pauseVideo();
} else if (wasPlayingBeforeSeek) {
playerRef.current.playVideo();
} else {
playerRef.current.pauseVideo();
}
}
}
if (annotation) {
try {
const parsed = JSON.parse(annotation);
const safe = validateAnnotationStrokes(parsed);
setViewingAnnotation(safe as AnnotationStroke[] | null);
} catch {
if (annotation) {
try {
const parsed = JSON.parse(annotation);
const safe = validateAnnotationStrokes(parsed);
setViewingAnnotation(safe as AnnotationStroke[] | null);
} catch {
setViewingAnnotation(null);
}
} else {
setViewingAnnotation(null);
}
} else {
setViewingAnnotation(null);
}
}, [isPlaying, playerRef, setViewingAnnotation]);
},
[isPlaying, playerRef, setViewingAnnotation]
);
const handleMuteToggle = useCallback(() => {
if (!playerRef.current) return;
@@ -803,49 +872,51 @@ export function useVideoPlayer({
[playerRef]
);
const handleQualityChange = useCallback((level: number) => {
const shouldCaptureSourceSwitch = (
activeProviderId === 'bunny'
&& ((level === -2 && bunnySourcePreference !== 'original')
|| (level !== -2 && bunnySourcePreference === 'original'))
);
const handleQualityChange = useCallback(
(level: number) => {
const shouldCaptureSourceSwitch =
activeProviderId === 'bunny' &&
((level === -2 && bunnySourcePreference !== 'original') ||
(level !== -2 && bunnySourcePreference === 'original'));
if (shouldCaptureSourceSwitch) {
const fallbackCurrentTime = videoRef.current?.currentTime ?? 0;
const current = playerRef.current?.getCurrentTime?.() ?? fallbackCurrentTime;
bunnySourceSwitchResumeRef.current = {
time: Number.isFinite(current) ? Math.max(0, current) : 0,
wasPlaying: isPlaying,
};
}
if (shouldCaptureSourceSwitch) {
const fallbackCurrentTime = videoRef.current?.currentTime ?? 0;
const current = playerRef.current?.getCurrentTime?.() ?? fallbackCurrentTime;
bunnySourceSwitchResumeRef.current = {
time: Number.isFinite(current) ? Math.max(0, current) : 0,
wasPlaying: isPlaying,
};
}
if (level === -2) {
pendingHlsQualityRef.current = null;
setBunnySourcePreference('original');
setSelectedQualityLevel(-2);
return;
}
if (level === -2) {
pendingHlsQualityRef.current = null;
setBunnySourcePreference('original');
setSelectedQualityLevel(-2);
return;
}
pendingHlsQualityRef.current = level;
setBunnySourcePreference('auto');
pendingHlsQualityRef.current = level;
setBunnySourcePreference('auto');
const hls = hlsRef.current;
if (!hls) {
setSelectedQualityLevel(level === -1 ? -1 : level);
return;
}
const hls = hlsRef.current;
if (!hls) {
setSelectedQualityLevel(level === -1 ? -1 : level);
return;
}
if (level === -1) {
hls.currentLevel = -1;
hls.nextLevel = -1;
setSelectedQualityLevel(-1);
return;
}
if (level === -1) {
hls.currentLevel = -1;
hls.nextLevel = -1;
setSelectedQualityLevel(-1);
return;
}
hls.currentLevel = level;
hls.nextLevel = level;
setSelectedQualityLevel(level);
}, [activeProviderId, bunnySourcePreference, hlsRef, isPlaying, playerRef, videoRef]);
hls.currentLevel = level;
hls.nextLevel = level;
setSelectedQualityLevel(level);
},
[activeProviderId, bunnySourcePreference, hlsRef, isPlaying, playerRef, videoRef]
);
const handleTimelineClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
@@ -28,7 +28,11 @@ export function useWatchProgress({
const progressSaveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const progressDebounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const progressWriteInFlightRef = useRef(false);
const pendingProgressPayloadRef = useRef<{ progress: number; duration: number; force: boolean } | null>(null);
const pendingProgressPayloadRef = useRef<{
progress: number;
duration: number;
force: boolean;
} | null>(null);
const lastSavedProgressRef = useRef<number>(0);
const lastPathnameRef = useRef<string>(pathname);
@@ -69,51 +73,49 @@ export function useWatchProgress({
}
}, [isAuthenticated, activeVersionId, videoId]);
const scheduleWatchProgressSave = useCallback((input: {
progress: number;
duration?: number;
immediate?: boolean;
force?: boolean;
}) => {
if (!isAuthenticated || !activeVersionId) return;
const scheduleWatchProgressSave = useCallback(
(input: { progress: number; duration?: number; immediate?: boolean; force?: boolean }) => {
if (!isAuthenticated || !activeVersionId) return;
const progress = Math.max(0, input.progress);
if (progress <= 0) return;
const progress = Math.max(0, input.progress);
if (progress <= 0) return;
const duration = Math.max(0, input.duration ?? videoDurationRef.current ?? 0);
const force = input.force ?? false;
const duration = Math.max(0, input.duration ?? videoDurationRef.current ?? 0);
const force = input.force ?? false;
if (!force && Math.abs(progress - lastSavedProgressRef.current) < 2) {
return;
}
if (!force && Math.abs(progress - lastSavedProgressRef.current) < 2) {
return;
}
const existingPayload = pendingProgressPayloadRef.current;
pendingProgressPayloadRef.current = existingPayload
? {
progress: Math.max(existingPayload.progress, progress),
duration: Math.max(existingPayload.duration, duration),
force: existingPayload.force || force,
const existingPayload = pendingProgressPayloadRef.current;
pendingProgressPayloadRef.current = existingPayload
? {
progress: Math.max(existingPayload.progress, progress),
duration: Math.max(existingPayload.duration, duration),
force: existingPayload.force || force,
}
: { progress, duration, force };
if (input.immediate) {
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
: { progress, duration, force };
void flushScheduledWatchProgress();
return;
}
if (input.immediate) {
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
void flushScheduledWatchProgress();
return;
}
if (progressDebounceTimerRef.current) {
clearTimeout(progressDebounceTimerRef.current);
}
progressDebounceTimerRef.current = setTimeout(() => {
progressDebounceTimerRef.current = null;
void flushScheduledWatchProgress();
}, 800);
}, [isAuthenticated, activeVersionId, flushScheduledWatchProgress]);
progressDebounceTimerRef.current = setTimeout(() => {
progressDebounceTimerRef.current = null;
void flushScheduledWatchProgress();
}, 800);
},
[isAuthenticated, activeVersionId, flushScheduledWatchProgress]
);
useEffect(() => {
return () => {
@@ -138,28 +140,31 @@ export function useWatchProgress({
}
}, [videoId, activeVersionId]);
const loadWatchProgress = useCallback(async (showPrompt = true) => {
if (!isAuthenticated || !activeVersionId) return;
const loadWatchProgress = useCallback(
async (showPrompt = true) => {
if (!isAuthenticated || !activeVersionId) return;
setSavedProgress(null);
setShowResumePrompt(false);
setSavedProgress(null);
setShowResumePrompt(false);
try {
const res = await fetch(`/api/watch/${videoId}/progress`, { cache: 'no-store' });
if (res.ok) {
const response = await res.json();
const progress = response.data?.progress || 0;
const percentage = response.data?.percentage || 0;
try {
const res = await fetch(`/api/watch/${videoId}/progress`, { cache: 'no-store' });
if (res.ok) {
const response = await res.json();
const progress = response.data?.progress || 0;
const percentage = response.data?.percentage || 0;
if (showPrompt && percentage > 5 && percentage < 95) {
setSavedProgress(progress);
setShowResumePrompt(true);
if (showPrompt && percentage > 5 && percentage < 95) {
setSavedProgress(progress);
setShowResumePrompt(true);
}
}
} catch (err) {
console.error('Error loading watch progress:', err);
}
} catch (err) {
console.error('Error loading watch progress:', err);
}
}, [isAuthenticated, activeVersionId, videoId]);
},
[isAuthenticated, activeVersionId, videoId]
);
useEffect(() => {
loadWatchProgress();
@@ -194,7 +199,14 @@ export function useWatchProgress({
progressSaveTimerRef.current = null;
}
};
}, [isAuthenticated, isReady, videoDuration, activeVersionId, scheduleWatchProgressSave, playerRef]);
}, [
isAuthenticated,
isReady,
videoDuration,
activeVersionId,
scheduleWatchProgressSave,
playerRef,
]);
useEffect(() => {
if (!isAuthenticated) return;
@@ -211,11 +223,16 @@ export function useWatchProgress({
clearTimeout(progressDebounceTimerRef.current);
progressDebounceTimerRef.current = null;
}
const data = new Blob([JSON.stringify({
progress: finalProgress,
duration: finalDuration,
versionId: activeVersionId,
})], { type: 'application/json' });
const data = new Blob(
[
JSON.stringify({
progress: finalProgress,
duration: finalDuration,
versionId: activeVersionId,
}),
],
{ type: 'application/json' }
);
navigator.sendBeacon(`/api/watch/${videoId}/progress`, data);
}
};
@@ -240,7 +257,15 @@ export function useWatchProgress({
window.removeEventListener('beforeunload', saveProgressOnLeave);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [isAuthenticated, currentTime, videoDuration, activeVersionId, videoId, scheduleWatchProgressSave, playerRef]);
}, [
isAuthenticated,
currentTime,
videoDuration,
activeVersionId,
videoId,
scheduleWatchProgressSave,
playerRef,
]);
const handleResumeFromSaved = useCallback(() => {
if (savedProgress !== null && playerRef.current) {
+11 -3
View File
@@ -21,7 +21,9 @@ export const ImagePreviewDialog = memo(function ImagePreviewDialog({
downloadFileName,
canDownload = true,
}: ImagePreviewDialogProps) {
const resolvedDownloadName = downloadFileName || (previewImage ? previewImage.split('/').pop() || 'attachment.png' : 'attachment.png');
const resolvedDownloadName =
downloadFileName ||
(previewImage ? previewImage.split('/').pop() || 'attachment.png' : 'attachment.png');
return (
<Dialog open={!!previewImage} onOpenChange={(open) => !open && onClose()}>
@@ -38,9 +40,15 @@ export const ImagePreviewDialog = memo(function ImagePreviewDialog({
}}
>
<DialogTitle className="sr-only">{title || 'Image 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="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}>
<p
className="flex-1 min-w-0 text-sm text-foreground truncate"
title={title || undefined}
>
{title || 'Image Preview'}
</p>
{canDownload ? (
+42 -19
View File
@@ -26,7 +26,11 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { AnnotationCanvas, type AnnotationCanvasHandle, type AnnotationStroke } from '@/components/annotation-canvas';
import {
AnnotationCanvas,
type AnnotationCanvasHandle,
type AnnotationStroke,
} from '@/components/annotation-canvas';
import type { BunnyQualityOption, CommentMarker } from '@/components/video-page/types';
interface PlayerCoreProps {
@@ -157,13 +161,20 @@ export const PlayerCore = memo(function PlayerCore({
>
<div className={cn('relative w-full h-full', isFullscreenMode && 'absolute inset-0')}>
{activeProviderId === 'bunny' ? (
<div ref={bunnyViewportRef} className="absolute inset-0 flex items-center justify-center bg-black">
<div
ref={bunnyViewportRef}
className="absolute inset-0 flex items-center justify-center bg-black"
>
<div
className={cn(
'relative flex items-center justify-center bg-black',
isBunnyPortraitSource ? 'h-full overflow-hidden' : 'w-full h-full'
)}
style={isBunnyPortraitSource && bunnyPortraitFrameWidth > 0 ? { width: `${bunnyPortraitFrameWidth}px` } : undefined}
style={
isBunnyPortraitSource && bunnyPortraitFrameWidth > 0
? { width: `${bunnyPortraitFrameWidth}px` }
: undefined
}
>
<video
key={activeVersionId}
@@ -198,9 +209,12 @@ export const PlayerCore = memo(function PlayerCore({
<div
className={cn(
'absolute inset-0 flex items-center justify-center bg-black/20 transition-opacity duration-300',
(showBunnyProcessingOverlay || showBunnyErrorOverlay) && 'opacity-0 pointer-events-none',
(showBunnyProcessingOverlay || showBunnyErrorOverlay) &&
'opacity-0 pointer-events-none',
isPlaying
? cursorIdle ? 'opacity-0' : 'opacity-0 group-hover:opacity-100'
? cursorIdle
? 'opacity-0'
: 'opacity-0 group-hover:opacity-100'
: 'opacity-100'
)}
>
@@ -318,11 +332,15 @@ export const PlayerCore = memo(function PlayerCore({
</div>
</div>
<div className={cn(
'shrink-0 px-4 py-2 bg-background border-t',
isFullscreenMode ? 'absolute bottom-0 left-0 right-0 z-50 transition-opacity duration-300' : '',
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
)}>
<div
className={cn(
'shrink-0 px-4 py-2 bg-background border-t',
isFullscreenMode
? 'absolute bottom-0 left-0 right-0 z-50 transition-opacity duration-300'
: '',
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
)}
>
<div className="flex items-center gap-1 mb-2">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handlePlayPause}>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4 ml-0.5" />}
@@ -348,12 +366,7 @@ export const PlayerCore = memo(function PlayerCore({
<SkipForward className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleMuteToggle}
>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleMuteToggle}>
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</Button>
@@ -387,7 +400,9 @@ export const PlayerCore = memo(function PlayerCore({
<DropdownMenuItem
key={option.level}
onClick={() => handleQualityChange(option.level)}
className={cn(option.level === selectedQualityLevel && 'font-bold text-primary')}
className={cn(
option.level === selectedQualityLevel && 'font-bold text-primary'
)}
>
{option.label}
</DropdownMenuItem>
@@ -423,7 +438,11 @@ export const PlayerCore = memo(function PlayerCore({
onClick={toggleFullscreen}
title={isFullscreenMode ? 'Exit fullscreen (F)' : 'Fullscreen (F)'}
>
{isFullscreenMode ? <Minimize className="h-4 w-4" /> : <Maximize className="h-4 w-4" />}
{isFullscreenMode ? (
<Minimize className="h-4 w-4" />
) : (
<Maximize className="h-4 w-4" />
)}
</Button>
{isFullscreenMode ? (
@@ -434,7 +453,11 @@ export const PlayerCore = memo(function PlayerCore({
onClick={() => setShowComments(!showComments)}
title={showComments ? 'Hide comments' : 'Show comments'}
>
{showComments ? <MessageSquareOff className="h-4 w-4" /> : <MessageSquare className="h-4 w-4" />}
{showComments ? (
<MessageSquareOff className="h-4 w-4" />
) : (
<MessageSquare className="h-4 w-4" />
)}
</Button>
) : (
<Button
+5 -1
View File
@@ -200,7 +200,11 @@ export interface VideoPageCommentsActions {
onResolveComment: (commentId: string, currentlyResolved: boolean) => void;
onEditComment: (commentId: string) => void;
onDeleteComment: (commentId: string) => void;
onReplyComment: (parentId: string, voiceData?: { url: string; duration: number }, imageData?: { url: string }) => void;
onReplyComment: (
parentId: string,
voiceData?: { url: string; duration: number },
imageData?: { url: string }
) => void;
onSubmitReplyWithMedia: (parentId: string) => void;
onStartEditAnnotation: () => void;
}
@@ -1,10 +1,25 @@
'use client';
import { memo } from 'react';
import { AlertCircle, CheckCircle2, FileVideo, Link as LinkIcon, Loader2, Plus, UploadCloud } from 'lucide-react';
import {
AlertCircle,
CheckCircle2,
FileVideo,
Link as LinkIcon,
Loader2,
Plus,
UploadCloud,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -67,8 +82,14 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-2">
<Tabs value={newVersionMode} onValueChange={(v) => onNewVersionModeChange(v as 'url' | 'file')} className="mb-2">
<TabsList className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}>
<Tabs
value={newVersionMode}
onValueChange={(v) => onNewVersionModeChange(v as 'url' | 'file')}
className="mb-2"
>
<TabsList
className={`grid w-full ${bunnyUploadsEnabled ? 'grid-cols-2' : 'grid-cols-1'}`}
>
<TabsTrigger value="url">Link URL</TabsTrigger>
{bunnyUploadsEnabled ? <TabsTrigger value="file">Upload File</TabsTrigger> : null}
</TabsList>
@@ -106,12 +127,17 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
<div className="space-y-2">
<Label htmlFor="versionFile">Video File</Label>
<div className="flex items-center justify-center w-full">
<label htmlFor="versionFile" className={`flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${newVersionFile ? 'border-primary' : 'border-border'}`}>
<label
htmlFor="versionFile"
className={`flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-muted/30 hover:bg-muted/50 transition-colors ${newVersionFile ? 'border-primary' : 'border-border'}`}
>
<div className="flex flex-col items-center justify-center pt-5 pb-6">
{newVersionFile ? (
<>
<FileVideo className="w-8 h-8 mb-2 text-primary" />
<p className="mb-1 text-sm text-foreground font-medium truncate max-w-[200px]">{newVersionFile.name}</p>
<p className="mb-1 text-sm text-foreground font-medium truncate max-w-[200px]">
{newVersionFile.name}
</p>
<p className="text-xs text-muted-foreground">
{(newVersionFile.size / (1024 * 1024)).toFixed(2)} MB
</p>
@@ -126,14 +152,21 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
</>
)}
</div>
<input id="versionFile" type="file" accept="video/*" className="hidden" onChange={(e) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('video/')) {
onNewVersionFileChange(file);
} else {
toast.error('Please select a valid video file');
}
}} disabled={isCreatingVersion} />
<input
id="versionFile"
type="file"
accept="video/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file && file.type.startsWith('video/')) {
onNewVersionFileChange(file);
} else {
toast.error('Please select a valid video file');
}
}}
disabled={isCreatingVersion}
/>
</label>
</div>
</div>
@@ -154,7 +187,10 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
<p className="text-sm text-muted-foreground">{newVersionUploadStatus}</p>
{newVersionUploadProgress > 0 && newVersionUploadProgress < 100 && (
<div className="w-full bg-secondary rounded-full h-2">
<div className="bg-primary h-2 rounded-full transition-all" style={{ width: `${newVersionUploadProgress}%` }}></div>
<div
className="bg-primary h-2 rounded-full transition-all"
style={{ width: `${newVersionUploadProgress}%` }}
></div>
</div>
)}
</div>
@@ -162,7 +198,11 @@ export const VersionActionsDialog = memo(function VersionActionsDialog({
<Button
onClick={onCreateVersion}
disabled={(newVersionMode === 'url' && !newVersionSource) || (newVersionMode === 'file' && !newVersionFile) || isCreatingVersion}
disabled={
(newVersionMode === 'url' && !newVersionSource) ||
(newVersionMode === 'file' && !newVersionFile) ||
isCreatingVersion
}
className="w-full"
>
{isCreatingVersion && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
+47 -15
View File
@@ -2,7 +2,17 @@
import { memo } from 'react';
import Link from 'next/link';
import { ArrowLeft, ChevronDown, GitCompareArrows, ListChecks, MoreVertical, Plus, Share2, ShieldCheck, Trash2 } from 'lucide-react';
import {
ArrowLeft,
ChevronDown,
GitCompareArrows,
ListChecks,
MoreVertical,
Plus,
Share2,
ShieldCheck,
Trash2,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -17,7 +27,11 @@ import { cn } from '@/lib/utils';
import { DownloadControls } from '@/components/video-page/download-controls';
import { VersionDeleteDialog } from '@/components/video-page/version-delete-dialog';
import { VersionActionsDialog } from '@/components/video-page/version-actions-dialog';
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
import type {
BunnyDownloadPreference,
DownloadTarget,
Version,
} from '@/components/video-page/types';
import type { VideoSource } from '@/lib/video-providers';
interface VideoPageHeaderProps {
@@ -118,11 +132,15 @@ export const VideoPageHeader = memo(function VideoPageHeader({
const canManageVideo = canShareVideo || canRequestApproval;
return (
<div className={cn(
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50 gap-3',
isFullscreenMode ? 'absolute top-0 left-0 right-0 z-50 transition-opacity duration-300' : '',
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
)}>
<div
className={cn(
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50 gap-3',
isFullscreenMode
? 'absolute top-0 left-0 right-0 z-50 transition-opacity duration-300'
: '',
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
)}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<Link
href={backHref}
@@ -152,10 +170,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{versions.map((version) => (
<DropdownMenuItem
key={version.id}
onClick={() => onVersionSelect(version.id)}
>
<DropdownMenuItem key={version.id} onClick={() => onVersionSelect(version.id)}>
<Badge
variant={version.id === activeVersionId ? 'default' : 'secondary'}
className="mr-2"
@@ -203,22 +218,39 @@ export const VideoPageHeader = memo(function VideoPageHeader({
{mode === 'dashboard' && (
<>
{canManageVideo ? (
<Button variant="outline" size="sm" onClick={() => setShowVersionDialog(true)} className="hidden sm:inline-flex">
<Button
variant="outline"
size="sm"
onClick={() => setShowVersionDialog(true)}
className="hidden sm:inline-flex"
>
<Plus className="h-4 w-4 mr-1" />
New Version
</Button>
) : null}
<Button variant="outline" size="sm" onClick={onOpenApprovalsPanel} className="hidden sm:inline-flex">
<Button
variant="outline"
size="sm"
onClick={onOpenApprovalsPanel}
className="hidden sm:inline-flex"
>
<ListChecks className="h-4 w-4 mr-1" />
Approvals
{hasPendingApprovalRequest ? (
<Badge variant="default" className="ml-2 hidden xl:inline-flex">Pending</Badge>
<Badge variant="default" className="ml-2 hidden xl:inline-flex">
Pending
</Badge>
) : null}
</Button>
{versions.length >= 2 && (
<Button variant="outline" size="sm" onClick={onOpenCompare} className="hidden sm:inline-flex">
<Button
variant="outline"
size="sm"
onClick={onOpenCompare}
className="hidden sm:inline-flex"
>
<GitCompareArrows className="h-4 w-4 mr-1" />
Compare
</Button>
+30 -4
View File
@@ -25,8 +25,21 @@ export const VideoPageLoading = memo(function VideoPageLoading({
return (
<div className={cn(containerHeight, 'flex flex-col bg-background overflow-hidden')}>
<div className="flex-1 flex overflow-hidden min-h-0">
<div className={cn('flex-1 flex flex-col overflow-hidden min-h-0', isFullscreenMode && 'relative')}>
<div className={cn('shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50', isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none transition-opacity duration-300')}>
<div
className={cn(
'flex-1 flex flex-col overflow-hidden min-h-0',
isFullscreenMode && 'relative'
)}
>
<div
className={cn(
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50',
isFullscreenMode &&
cursorIdle &&
isPlaying &&
'opacity-0 pointer-events-none transition-opacity duration-300'
)}
>
<div className="flex items-center gap-3">
<Skeleton className="h-4 w-12" />
<Separator orientation="vertical" className="h-5" />
@@ -41,7 +54,15 @@ export const VideoPageLoading = memo(function VideoPageLoading({
</div>
</div>
<div className="flex-1 bg-black min-h-0" />
<div className={cn('shrink-0 px-4 py-2 bg-background border-t', isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none transition-opacity duration-300')}>
<div
className={cn(
'shrink-0 px-4 py-2 bg-background border-t',
isFullscreenMode &&
cursorIdle &&
isPlaying &&
'opacity-0 pointer-events-none transition-opacity duration-300'
)}
>
<div className="flex items-center gap-1 mb-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-8 w-8 rounded-md" />
@@ -55,7 +76,12 @@ export const VideoPageLoading = memo(function VideoPageLoading({
<Skeleton className="h-8 w-full rounded" />
</div>
</div>
<div className={cn('hidden lg:flex w-80 shrink-0 border-l bg-card flex-col overflow-hidden', isFullscreenMode && !showComments && 'hidden')}>
<div
className={cn(
'hidden lg:flex w-80 shrink-0 border-l bg-card flex-col overflow-hidden',
isFullscreenMode && !showComments && 'hidden'
)}
>
<div className="shrink-0 flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5" />