mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(video-assets): add full video asset system (uploads, downloads, @mentions, and cleanup/billing integration)
This commit is contained in:
@@ -23,6 +23,7 @@ import { useDownloadActions } from '@/components/video-page/hooks/use-download-a
|
||||
import { useVersionDurationSync } from '@/components/video-page/hooks/use-version-duration-sync';
|
||||
import { CommentComposer } from '@/components/video-page/comment-composer';
|
||||
import { CommentsPane } from '@/components/video-page/comments-pane';
|
||||
import { AssetsPane } from '@/components/video-page/assets-pane';
|
||||
import { ApprovalRequestDialog } from '@/components/video-page/approval-request-dialog';
|
||||
import { ApprovalRequestsPanel } from '@/components/video-page/approval-requests-panel';
|
||||
import type {
|
||||
@@ -34,6 +35,7 @@ import type {
|
||||
VideoPageHeaderActions,
|
||||
} from '@/components/video-page/types';
|
||||
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
|
||||
import { useVideoAssets } from '@/components/video-page/hooks/use-video-assets';
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
@@ -92,6 +94,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
toggleVoiceSpeed,
|
||||
} = useCommentMedia();
|
||||
const [showResolved, setShowResolved] = useState(false);
|
||||
const [activeSidePane, setActiveSidePane] = useState<'comments' | 'assets'>('comments');
|
||||
const [highlightedAssetId, setHighlightedAssetId] = useState<string | null>(null);
|
||||
|
||||
const editAnnotationCanvasRef = useRef<AnnotationCanvasHandle>(null);
|
||||
|
||||
@@ -139,6 +143,29 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
const isGuest = video ? !video.isAuthenticated : false;
|
||||
const canInitializePlayer = mode !== 'watch' || !isGuest || guestNameConfirmed;
|
||||
const normalizedGuestName = guestName.trim();
|
||||
const canUploadAssets = !!video?.canUploadAssets;
|
||||
const canDownloadAssets = !!video?.canDownloadAssets;
|
||||
|
||||
const {
|
||||
assets,
|
||||
isLoadingAssets,
|
||||
isCreatingAsset,
|
||||
activeDeleteAssetId,
|
||||
activeDownloadAssetId,
|
||||
hasMoreAssets,
|
||||
isLoadingMoreAssets,
|
||||
loadMoreAssets,
|
||||
createAsset,
|
||||
deleteAsset,
|
||||
downloadAsset,
|
||||
getGuestUploadToken,
|
||||
} = useVideoAssets({
|
||||
videoId,
|
||||
isAuthenticated: !!video?.isAuthenticated,
|
||||
canUploadAssets,
|
||||
canDownloadAssets,
|
||||
guestName: normalizedGuestName,
|
||||
});
|
||||
|
||||
const {
|
||||
showVersionDialog,
|
||||
@@ -181,6 +208,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
setShowResolved(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const handleAssetMentionClick = useCallback((assetId: string) => {
|
||||
setActiveSidePane('assets');
|
||||
setHighlightedAssetId(assetId);
|
||||
}, []);
|
||||
|
||||
const { isExportingCsv, isExportingPdf, exportComments } = useCommentExport({
|
||||
activeVersionId,
|
||||
showResolved,
|
||||
@@ -191,6 +223,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
const currentUserName = video?.currentUserName || null;
|
||||
const canResolveComments = !!video?.canResolveComments;
|
||||
const canRequestApproval = !!video?.canRequestApproval;
|
||||
const canShareVideo = !!video?.canShareVideo;
|
||||
|
||||
const {
|
||||
requests: approvalRequests,
|
||||
@@ -655,6 +688,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
onCreateVersion={headerActions.onCreateVersion}
|
||||
onOpenCompare={headerActions.onOpenCompare}
|
||||
canRequestApproval={canRequestApproval}
|
||||
canShareVideo={canShareVideo}
|
||||
hasPendingApprovalRequest={!!activePendingRequest}
|
||||
onOpenApprovalRequest={handleOpenApprovalRequestDialog}
|
||||
onOpenApprovalsPanel={handleOpenApprovalsPanel}
|
||||
@@ -779,6 +813,31 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
isSubmittingReply={isSubmittingReply}
|
||||
isUploadingReplyAudio={isUploadingReplyAudio}
|
||||
isUploadingReplyImage={isUploadingReplyImage}
|
||||
assets={assets}
|
||||
onAssetMentionClick={handleAssetMentionClick}
|
||||
activePane={activeSidePane}
|
||||
setActivePane={setActiveSidePane}
|
||||
assetsPane={(
|
||||
<AssetsPane
|
||||
videoId={videoId}
|
||||
assets={assets}
|
||||
isLoadingAssets={isLoadingAssets}
|
||||
isCreatingAsset={isCreatingAsset}
|
||||
activeDeleteAssetId={activeDeleteAssetId}
|
||||
activeDownloadAssetId={activeDownloadAssetId}
|
||||
canUploadAssets={canUploadAssets}
|
||||
canDownloadAssets={canDownloadAssets}
|
||||
getGuestUploadToken={getGuestUploadToken}
|
||||
createAsset={createAsset}
|
||||
deleteAsset={deleteAsset}
|
||||
downloadAsset={downloadAsset}
|
||||
hasMoreAssets={hasMoreAssets}
|
||||
isLoadingMoreAssets={isLoadingMoreAssets}
|
||||
loadMoreAssets={loadMoreAssets}
|
||||
highlightedAssetId={highlightedAssetId}
|
||||
onHighlightedAssetHandled={() => setHighlightedAssetId(null)}
|
||||
/>
|
||||
)}
|
||||
composer={(
|
||||
<CommentComposer
|
||||
isRecording={isRecording}
|
||||
@@ -816,6 +875,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
canManageTags={!!video.canManageTags}
|
||||
projectId={projectId}
|
||||
pauseVideoForAnnotation={composerActions.onPauseVideoForAnnotation}
|
||||
assets={assets}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { memo, type ReactNode } from 'react';
|
||||
import { Download, ExternalLink, Image as ImageIcon, Loader2, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { VideoAsset } from '@/components/video-page/types';
|
||||
|
||||
interface AssetListSectionProps {
|
||||
assets: VideoAsset[];
|
||||
isLoadingAssets: boolean;
|
||||
focusedAssetId: string | null;
|
||||
bunnyProcessingByAssetId: Record<string, boolean>;
|
||||
activeDownloadAssetId: string | null;
|
||||
activeDeleteAssetId: string | null;
|
||||
canDownloadAssets: boolean;
|
||||
hasMoreAssets: boolean;
|
||||
isLoadingMoreAssets: boolean;
|
||||
onViewAsset: (asset: VideoAsset) => void;
|
||||
onDownloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => void;
|
||||
onDeleteAsset: (assetId: string) => void;
|
||||
onLoadMoreAssets: () => void;
|
||||
renderAssetPreview: (asset: VideoAsset) => ReactNode;
|
||||
}
|
||||
|
||||
export const AssetListSection = memo(function AssetListSection({
|
||||
assets,
|
||||
isLoadingAssets,
|
||||
focusedAssetId,
|
||||
bunnyProcessingByAssetId,
|
||||
activeDownloadAssetId,
|
||||
activeDeleteAssetId,
|
||||
canDownloadAssets,
|
||||
hasMoreAssets,
|
||||
isLoadingMoreAssets,
|
||||
onViewAsset,
|
||||
onDownloadAsset,
|
||||
onDeleteAsset,
|
||||
onLoadMoreAssets,
|
||||
renderAssetPreview,
|
||||
}: AssetListSectionProps) {
|
||||
if (isLoadingAssets) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2 py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading assets...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (assets.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center border rounded-lg">
|
||||
No assets uploaded yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{assets.map((asset) => (
|
||||
<div
|
||||
key={asset.id}
|
||||
id={`asset-card-${asset.id}`}
|
||||
className={cn(
|
||||
'rounded-lg border p-2 flex gap-3 transition-colors',
|
||||
focusedAssetId === asset.id && 'ring-2 ring-primary border-primary/60 bg-primary/5'
|
||||
)}
|
||||
>
|
||||
<button className="shrink-0" onClick={() => onViewAsset(asset)}>
|
||||
{renderAssetPreview(asset)}
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium truncate">{asset.displayName}</p>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{asset.provider === 'BUNNY' && bunnyProcessingByAssetId[asset.id] ? (
|
||||
<Badge variant="secondary" className="text-[10px] gap-1">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||
Processing
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{asset.uploadedByUser?.name || asset.uploadedByGuestName || 'Unknown'} • {new Date(asset.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
<div className="pt-1 flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-7 w-7"
|
||||
title="View asset"
|
||||
aria-label="View asset"
|
||||
disabled={asset.provider === 'BUNNY' && !!bunnyProcessingByAssetId[asset.id]}
|
||||
onClick={() => onViewAsset(asset)}
|
||||
>
|
||||
{asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : <ExternalLink className="h-3 w-3" />}
|
||||
</Button>
|
||||
|
||||
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
|
||||
asset.provider === 'BUNNY' ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-7 w-7"
|
||||
title="Download asset"
|
||||
aria-label="Download asset"
|
||||
disabled={activeDownloadAssetId === asset.id || !!bunnyProcessingByAssetId[asset.id]}
|
||||
>
|
||||
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'original')}>
|
||||
<Download className="h-3 w-3 mr-2" />
|
||||
Original
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onDownloadAsset(asset, 'compressed')}>
|
||||
<Download className="h-3 w-3 mr-2" />
|
||||
Compressed
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-7 w-7"
|
||||
title="Download asset"
|
||||
aria-label="Download asset"
|
||||
disabled={activeDownloadAssetId === asset.id || !!bunnyProcessingByAssetId[asset.id]}
|
||||
onClick={() => onDownloadAsset(asset)}
|
||||
>
|
||||
{activeDownloadAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Download className="h-3 w-3" />}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
|
||||
{asset.canDelete && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
className="h-7 w-7"
|
||||
title="Delete asset"
|
||||
aria-label="Delete asset"
|
||||
disabled={activeDeleteAssetId === asset.id}
|
||||
onClick={() => onDeleteAsset(asset.id)}
|
||||
>
|
||||
{activeDeleteAssetId === asset.id ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{hasMoreAssets ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={isLoadingMoreAssets}
|
||||
onClick={onLoadMoreAssets}
|
||||
>
|
||||
{isLoadingMoreAssets ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
|
||||
{isLoadingMoreAssets ? 'Loading more...' : 'Load more'}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,742 @@
|
||||
'use client';
|
||||
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as tus from 'tus-js-client';
|
||||
import { toast } from 'sonner';
|
||||
import { Download, FileVideo, Image as ImageIcon, Loader2, UploadCloud, X, Youtube } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { ImagePreviewDialog } from '@/components/video-page/image-preview-dialog';
|
||||
import { BunnyPreviewPlayer, type BunnyPreviewPlayerHandle } from '@/components/video-page/bunny-preview-player';
|
||||
import { AssetListSection } from '@/components/video-page/asset-list-section';
|
||||
import type { VideoAsset } from '@/components/video-page/types';
|
||||
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
|
||||
|
||||
interface AssetsPaneProps {
|
||||
videoId: string;
|
||||
assets: VideoAsset[];
|
||||
isLoadingAssets: boolean;
|
||||
isCreatingAsset: boolean;
|
||||
activeDeleteAssetId: string | null;
|
||||
activeDownloadAssetId: string | null;
|
||||
canUploadAssets: boolean;
|
||||
canDownloadAssets: boolean;
|
||||
getGuestUploadToken: (intent: 'image') => Promise<string | null>;
|
||||
createAsset: (payload: {
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
|
||||
displayName?: string;
|
||||
sourceUrl: string;
|
||||
providerVideoId?: string;
|
||||
thumbnailUrl?: string;
|
||||
uploadToken?: string;
|
||||
}) => Promise<VideoAsset | null>;
|
||||
deleteAsset: (assetId: string) => Promise<boolean>;
|
||||
downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise<void>;
|
||||
hasMoreAssets: boolean;
|
||||
isLoadingMoreAssets: boolean;
|
||||
loadMoreAssets: () => Promise<void>;
|
||||
highlightedAssetId: string | null;
|
||||
onHighlightedAssetHandled: () => void;
|
||||
}
|
||||
|
||||
export const AssetsPane = memo(function AssetsPane({
|
||||
videoId,
|
||||
assets,
|
||||
isLoadingAssets,
|
||||
isCreatingAsset,
|
||||
activeDeleteAssetId,
|
||||
activeDownloadAssetId,
|
||||
canUploadAssets,
|
||||
canDownloadAssets,
|
||||
getGuestUploadToken,
|
||||
createAsset,
|
||||
deleteAsset,
|
||||
downloadAsset,
|
||||
hasMoreAssets,
|
||||
isLoadingMoreAssets,
|
||||
loadMoreAssets,
|
||||
highlightedAssetId,
|
||||
onHighlightedAssetHandled,
|
||||
}: AssetsPaneProps) {
|
||||
const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny'>('image');
|
||||
const [imageTitle, setImageTitle] = useState('');
|
||||
const [pendingImageFile, setPendingImageFile] = useState<File | null>(null);
|
||||
const [youtubeUrl, setYoutubeUrl] = useState('');
|
||||
const [youtubeTitle, setYoutubeTitle] = useState('');
|
||||
const [bunnyTitle, setBunnyTitle] = useState('');
|
||||
const [isUploadingBunny, setIsUploadingBunny] = useState(false);
|
||||
const [bunnyProgress, setBunnyProgress] = useState(0);
|
||||
const [bunnyProcessingByAssetId, setBunnyProcessingByAssetId] = useState<Record<string, boolean>>({});
|
||||
const [bunnyThumbnailRetryKeyByAssetId, setBunnyThumbnailRetryKeyByAssetId] = useState<Record<string, number>>({});
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [previewImageTitle, setPreviewImageTitle] = useState<string | null>(null);
|
||||
const [selectedAsset, setSelectedAsset] = useState<VideoAsset | null>(null);
|
||||
const [focusedAssetId, setFocusedAssetId] = useState<string | null>(null);
|
||||
const bunnyPreviewPlayerRef = useRef<BunnyPreviewPlayerHandle | null>(null);
|
||||
const youtubeIframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const youtubePreviewStateRef = useRef({ currentTime: 0, isPlaying: false, isMuted: false });
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const bunnyInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const sortedAssets = useMemo(() => {
|
||||
return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
|
||||
}, [assets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!highlightedAssetId) return;
|
||||
|
||||
const element = document.getElementById(`asset-card-${highlightedAssetId}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setFocusedAssetId(highlightedAssetId);
|
||||
window.setTimeout(() => setFocusedAssetId((prev) => (prev === highlightedAssetId ? null : prev)), 2500);
|
||||
}
|
||||
|
||||
onHighlightedAssetHandled();
|
||||
}, [highlightedAssetId, onHighlightedAssetHandled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAsset || selectedAsset.kind !== 'VIDEO') return;
|
||||
|
||||
const sendYouTubeCommand = (func: string, args: unknown[] = []) => {
|
||||
const iframe = youtubeIframeRef.current;
|
||||
if (!iframe?.contentWindow) return;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({
|
||||
event: 'command',
|
||||
func,
|
||||
args,
|
||||
}), '*');
|
||||
};
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (!selectedAsset || selectedAsset.provider !== 'YOUTUBE') return;
|
||||
if (typeof event.data !== 'string') return;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const info = (parsed as { info?: { currentTime?: number; playerState?: number; muted?: boolean } })?.info;
|
||||
if (!info) return;
|
||||
if (typeof info.currentTime === 'number') {
|
||||
youtubePreviewStateRef.current.currentTime = info.currentTime;
|
||||
}
|
||||
if (typeof info.playerState === 'number') {
|
||||
youtubePreviewStateRef.current.isPlaying = info.playerState === 1;
|
||||
}
|
||||
if (typeof info.muted === 'boolean') {
|
||||
youtubePreviewStateRef.current.isMuted = info.muted;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!selectedAsset || selectedAsset.kind !== 'VIDEO') return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
|
||||
|
||||
const handledKeys = new Set(['Space', 'KeyK', 'ArrowLeft', 'ArrowRight', 'KeyJ', 'KeyL', 'KeyM', 'Escape']);
|
||||
if (!handledKeys.has(event.code)) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.code === 'Escape') {
|
||||
setSelectedAsset(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAsset.provider === 'BUNNY') {
|
||||
switch (event.code) {
|
||||
case 'Space':
|
||||
case 'KeyK':
|
||||
bunnyPreviewPlayerRef.current?.togglePlayPause();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'KeyJ':
|
||||
bunnyPreviewPlayerRef.current?.seekBy(-10);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'KeyL':
|
||||
bunnyPreviewPlayerRef.current?.seekBy(10);
|
||||
break;
|
||||
case 'KeyM':
|
||||
bunnyPreviewPlayerRef.current?.toggleMute();
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAsset.provider === 'YOUTUBE') {
|
||||
switch (event.code) {
|
||||
case 'Space':
|
||||
case 'KeyK': {
|
||||
const isPlaying = youtubePreviewStateRef.current.isPlaying;
|
||||
sendYouTubeCommand(isPlaying ? 'pauseVideo' : 'playVideo');
|
||||
youtubePreviewStateRef.current.isPlaying = !isPlaying;
|
||||
break;
|
||||
}
|
||||
case 'ArrowLeft':
|
||||
case 'KeyJ': {
|
||||
const next = Math.max(0, youtubePreviewStateRef.current.currentTime - 10);
|
||||
sendYouTubeCommand('seekTo', [next, true]);
|
||||
youtubePreviewStateRef.current.currentTime = next;
|
||||
break;
|
||||
}
|
||||
case 'ArrowRight':
|
||||
case 'KeyL': {
|
||||
const next = youtubePreviewStateRef.current.currentTime + 10;
|
||||
sendYouTubeCommand('seekTo', [next, true]);
|
||||
youtubePreviewStateRef.current.currentTime = next;
|
||||
break;
|
||||
}
|
||||
case 'KeyM': {
|
||||
const isMuted = youtubePreviewStateRef.current.isMuted;
|
||||
sendYouTubeCommand(isMuted ? 'unMute' : 'mute');
|
||||
youtubePreviewStateRef.current.isMuted = !isMuted;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, true);
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, true);
|
||||
window.removeEventListener('message', onMessage);
|
||||
};
|
||||
}, [selectedAsset]);
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
if (!file) return;
|
||||
|
||||
const imageError = validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('videoId', videoId);
|
||||
const guestUploadToken = await getGuestUploadToken('image');
|
||||
if (guestUploadToken) formData.append('uploadToken', guestUploadToken);
|
||||
|
||||
const uploadRes = await fetch('/api/upload/image', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string }; error?: string } | null;
|
||||
const uploadedImageUrl = uploadPayload?.data?.url;
|
||||
if (!uploadRes.ok || !uploadedImageUrl) {
|
||||
toast.error(uploadPayload?.error || 'Failed to upload image');
|
||||
return;
|
||||
}
|
||||
|
||||
await createAsset({
|
||||
provider: 'R2_IMAGE',
|
||||
sourceUrl: uploadedImageUrl,
|
||||
displayName: imageTitle.trim() || file.name,
|
||||
});
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
setImageTitle('');
|
||||
setPendingImageFile(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload image asset:', error);
|
||||
toast.error('Failed to upload image');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const imageError = validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
setPendingImageFile(file);
|
||||
toast.success('Image attached. Click Upload Image to send.');
|
||||
};
|
||||
|
||||
const handleImagePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (uploadTab !== 'image' || !canUploadAssets || isCreatingAsset) return;
|
||||
const pastedImage = extractPastedImageFile(event.clipboardData);
|
||||
if (!pastedImage) return;
|
||||
const imageError = validateImageFile(pastedImage);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setPendingImageFile(pastedImage);
|
||||
toast.success('Image attached from clipboard. Click Upload Image to send.');
|
||||
};
|
||||
|
||||
const handleCreateYoutubeAsset = async () => {
|
||||
if (!youtubeUrl.trim()) return;
|
||||
const created = await createAsset({
|
||||
provider: 'YOUTUBE',
|
||||
sourceUrl: youtubeUrl.trim(),
|
||||
displayName: youtubeTitle.trim() || undefined,
|
||||
});
|
||||
if (created) {
|
||||
setYoutubeUrl('');
|
||||
setYoutubeTitle('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('video/')) {
|
||||
toast.error('Please select a video file');
|
||||
return;
|
||||
}
|
||||
|
||||
let uploadedVideoId: string | null = null;
|
||||
let uploadToken: string | null = null;
|
||||
try {
|
||||
setIsUploadingBunny(true);
|
||||
setBunnyProgress(0);
|
||||
|
||||
const initRes = await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: bunnyTitle.trim() || file.name.replace(/\.[^/.]+$/, '') }),
|
||||
});
|
||||
const initPayload = (await initRes.json().catch(() => null)) as {
|
||||
data?: {
|
||||
videoId: string;
|
||||
libraryId: string;
|
||||
signature: string;
|
||||
expirationTime: number;
|
||||
uploadToken: string;
|
||||
};
|
||||
error?: string;
|
||||
} | null;
|
||||
|
||||
if (!initRes.ok || !initPayload?.data) {
|
||||
toast.error(initPayload?.error || 'Failed to initialize Bunny upload');
|
||||
return;
|
||||
}
|
||||
|
||||
const initData = initPayload.data;
|
||||
uploadedVideoId = initData.videoId;
|
||||
uploadToken = initData.uploadToken;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: 'https://video.bunnycdn.com/tusupload',
|
||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||
headers: {
|
||||
AuthorizationSignature: initData.signature,
|
||||
AuthorizationExpire: initData.expirationTime.toString(),
|
||||
VideoId: initData.videoId,
|
||||
LibraryId: initData.libraryId,
|
||||
},
|
||||
metadata: {
|
||||
filetype: file.type,
|
||||
title: file.name,
|
||||
},
|
||||
onError: (error) => reject(error),
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const percentage = bytesTotal > 0 ? (bytesUploaded / bytesTotal) * 100 : 0;
|
||||
setBunnyProgress(Math.min(100, Math.max(0, percentage)));
|
||||
},
|
||||
onSuccess: () => resolve(),
|
||||
});
|
||||
upload.start();
|
||||
});
|
||||
|
||||
const sourceUrl = `https://iframe.mediadelivery.net/embed/${initData.libraryId}/${initData.videoId}`;
|
||||
const thumbnailUrl = `https://vz-965f4f4a-fc1.b-cdn.net/${initData.videoId}/thumbnail.jpg`;
|
||||
const createdAsset = await createAsset({
|
||||
provider: 'BUNNY',
|
||||
sourceUrl,
|
||||
providerVideoId: initData.videoId,
|
||||
uploadToken: initData.uploadToken,
|
||||
thumbnailUrl,
|
||||
displayName: bunnyTitle.trim() || file.name,
|
||||
});
|
||||
if (!createdAsset) {
|
||||
throw new Error('Failed to finalize Bunny asset');
|
||||
}
|
||||
if (bunnyInputRef.current) bunnyInputRef.current.value = '';
|
||||
setBunnyTitle('');
|
||||
} catch (error) {
|
||||
console.error('Failed to upload Bunny asset:', error);
|
||||
toast.error('Failed to upload Bunny video');
|
||||
if (uploadedVideoId && uploadToken) {
|
||||
await fetch(`/api/videos/${videoId}/assets/bunny-init`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ videoId: uploadedVideoId, uploadToken }),
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
setIsUploadingBunny(false);
|
||||
setBunnyProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBunnyThumbnailError = (assetId: string) => {
|
||||
setBunnyProcessingByAssetId((prev) => ({ ...prev, [assetId]: true }));
|
||||
window.setTimeout(() => {
|
||||
setBunnyThumbnailRetryKeyByAssetId((prev) => ({ ...prev, [assetId]: Date.now() }));
|
||||
setBunnyProcessingByAssetId((prev) => ({ ...prev, [assetId]: false }));
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
const renderAssetPreview = (asset: VideoAsset) => {
|
||||
if (asset.kind === 'IMAGE') {
|
||||
const imageSrc = asset.thumbnailUrl || asset.sourceUrl;
|
||||
return (
|
||||
<div className="h-24 w-36 rounded border bg-black/20 flex items-center justify-center overflow-hidden">
|
||||
{imageSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={imageSrc} alt={asset.displayName} className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<ImageIcon className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.provider === 'YOUTUBE' && asset.providerVideoId) {
|
||||
return (
|
||||
<div className="h-24 w-36 rounded border overflow-hidden bg-black/70 flex items-center justify-center">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={asset.thumbnailUrl || `https://img.youtube.com/vi/${asset.providerVideoId}/mqdefault.jpg`}
|
||||
alt={asset.displayName}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const retryKey = bunnyThumbnailRetryKeyByAssetId[asset.id] || 0;
|
||||
const isProcessing = !!bunnyProcessingByAssetId[asset.id];
|
||||
const thumbnailSrc = asset.thumbnailUrl ? `${asset.thumbnailUrl}${retryKey ? `?t=${retryKey}` : ''}` : null;
|
||||
|
||||
return (
|
||||
<div className="h-24 w-36 rounded border overflow-hidden bg-muted relative flex items-center justify-center">
|
||||
{thumbnailSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={thumbnailSrc}
|
||||
alt={asset.displayName}
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => handleBunnyThumbnailError(asset.id)}
|
||||
/>
|
||||
) : (
|
||||
<FileVideo className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
{isProcessing && (
|
||||
<div className="absolute inset-0 bg-black/65 flex flex-col items-center justify-center gap-1">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-white" />
|
||||
<span className="text-[10px] text-white/90 font-medium">Processing...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleOpenAsset = (asset: VideoAsset) => {
|
||||
const isBunnyProcessing = asset.provider === 'BUNNY' && !!bunnyProcessingByAssetId[asset.id];
|
||||
if (isBunnyProcessing) {
|
||||
toast.info('This Bunny asset is still processing.');
|
||||
return;
|
||||
}
|
||||
if (asset.kind === 'IMAGE') {
|
||||
if (!asset.sourceUrl) {
|
||||
toast.error('Preview is unavailable for this asset');
|
||||
return;
|
||||
}
|
||||
setPreviewImage(asset.sourceUrl);
|
||||
setPreviewImageTitle(asset.displayName);
|
||||
return;
|
||||
}
|
||||
setSelectedAsset(asset);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" onPaste={handleImagePaste}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">Assets</span>
|
||||
<Badge variant="secondary">{assets.length}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canUploadAssets ? (
|
||||
<div className="rounded-lg border p-3 space-y-3">
|
||||
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny')}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="image">Image</TabsTrigger>
|
||||
<TabsTrigger value="youtube">YouTube</TabsTrigger>
|
||||
<TabsTrigger value="bunny">Video</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{uploadTab === 'image' && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Optional name for mentions/tagging"
|
||||
value={imageTitle}
|
||||
onChange={(event) => setImageTitle(event.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">If set, this name will be used in @asset mentions.</p>
|
||||
<p className="text-xs text-muted-foreground">Tip: you can paste an image here with Ctrl/Cmd+V.</p>
|
||||
{pendingImageFile ? (
|
||||
<div className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2">
|
||||
<span className="truncate">Attached: {pendingImageFile.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-2"
|
||||
onClick={() => {
|
||||
setPendingImageFile(null);
|
||||
if (imageInputRef.current) imageInputRef.current.value = '';
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={isCreatingAsset}
|
||||
onClick={() => {
|
||||
if (pendingImageFile) {
|
||||
void handleImageUpload(pendingImageFile);
|
||||
return;
|
||||
}
|
||||
imageInputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<UploadCloud className="h-4 w-4 mr-2" />
|
||||
{pendingImageFile ? 'Upload Image' : 'Select Image'}
|
||||
</Button>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageFileChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadTab === 'youtube' && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="https://youtube.com/watch?v=..."
|
||||
value={youtubeUrl}
|
||||
onChange={(event) => setYoutubeUrl(event.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Optional display name"
|
||||
value={youtubeTitle}
|
||||
onChange={(event) => setYoutubeTitle(event.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={isCreatingAsset || !youtubeUrl.trim()}
|
||||
onClick={handleCreateYoutubeAsset}
|
||||
>
|
||||
<Youtube className="h-4 w-4 mr-2" />
|
||||
Add YouTube Asset
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadTab === 'bunny' && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="Optional name for mentions/tagging"
|
||||
value={bunnyTitle}
|
||||
onChange={(event) => setBunnyTitle(event.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">If set, this name will be used in @asset mentions.</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={isUploadingBunny || isCreatingAsset}
|
||||
onClick={() => bunnyInputRef.current?.click()}
|
||||
>
|
||||
{isUploadingBunny ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
|
||||
{isUploadingBunny ? 'Uploading...' : 'Upload Video'}
|
||||
</Button>
|
||||
<input
|
||||
ref={bunnyInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={handleBunnyUpload}
|
||||
/>
|
||||
{isUploadingBunny && (
|
||||
<div className="w-full bg-secondary rounded-full h-2 overflow-hidden">
|
||||
<div className="bg-primary h-2 rounded-full" style={{ width: `${bunnyProgress}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border p-3 text-xs text-muted-foreground">
|
||||
You do not have permission to upload assets.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AssetListSection
|
||||
assets={sortedAssets}
|
||||
isLoadingAssets={isLoadingAssets}
|
||||
focusedAssetId={focusedAssetId}
|
||||
bunnyProcessingByAssetId={bunnyProcessingByAssetId}
|
||||
activeDownloadAssetId={activeDownloadAssetId}
|
||||
activeDeleteAssetId={activeDeleteAssetId}
|
||||
canDownloadAssets={canDownloadAssets}
|
||||
hasMoreAssets={hasMoreAssets}
|
||||
isLoadingMoreAssets={isLoadingMoreAssets}
|
||||
onViewAsset={handleOpenAsset}
|
||||
onDownloadAsset={(asset, preference) => void downloadAsset(asset, preference)}
|
||||
onDeleteAsset={(assetId) => void deleteAsset(assetId)}
|
||||
onLoadMoreAssets={() => void loadMoreAssets()}
|
||||
renderAssetPreview={renderAssetPreview}
|
||||
/>
|
||||
|
||||
<ImagePreviewDialog
|
||||
previewImage={previewImage}
|
||||
title={previewImageTitle}
|
||||
downloadFileName={previewImageTitle}
|
||||
canDownload={canDownloadAssets}
|
||||
onClose={() => {
|
||||
setPreviewImage(null);
|
||||
setPreviewImageTitle(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={selectedAsset?.kind === 'VIDEO'} onOpenChange={(open) => !open && setSelectedAsset(null)}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none rounded-none flex items-center justify-center"
|
||||
onClick={() => setSelectedAsset(null)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setSelectedAsset(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">{selectedAsset?.displayName || 'Video Preview'}</DialogTitle>
|
||||
|
||||
<div className="w-[min(96vw,1500px)] h-[min(94vh,1000px)] border border-border/60 bg-black/80 shadow-2xl flex flex-col overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="shrink-0 flex items-center gap-2 border-b border-border/60 bg-background/85 px-2 py-1.5 backdrop-blur-sm">
|
||||
<p className="flex-1 min-w-0 text-sm text-foreground truncate" title={selectedAsset?.displayName || undefined}>
|
||||
{selectedAsset?.displayName || 'Video Preview'}
|
||||
</p>
|
||||
{selectedAsset?.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
>
|
||||
<a
|
||||
href={`https://www.youtube.com/watch?v=${selectedAsset.providerVideoId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Open on YouTube
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedAsset?.provider === 'BUNNY' && canDownloadAssets ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title="Download Bunny video"
|
||||
aria-label="Download Bunny video"
|
||||
disabled={
|
||||
activeDownloadAssetId === selectedAsset.id
|
||||
|| !!bunnyProcessingByAssetId[selectedAsset.id]
|
||||
}
|
||||
>
|
||||
{activeDownloadAssetId === selectedAsset.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => void downloadAsset(selectedAsset, 'original')}>
|
||||
<Download className="h-3 w-3 mr-2" />
|
||||
Original
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => void downloadAsset(selectedAsset, 'compressed')}>
|
||||
<Download className="h-3 w-3 mr-2" />
|
||||
Compressed
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setSelectedAsset(null)}
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 w-full p-2 sm:p-4">
|
||||
{selectedAsset ? (
|
||||
selectedAsset.provider === 'YOUTUBE' && selectedAsset.providerVideoId ? (
|
||||
<div className="w-full h-full rounded-md border overflow-hidden bg-black">
|
||||
<iframe
|
||||
ref={youtubeIframeRef}
|
||||
className="w-full h-full"
|
||||
src={`https://www.youtube.com/embed/${selectedAsset.providerVideoId}?enablejsapi=1&rel=0&modestbranding=1&playsinline=1${typeof window !== 'undefined' ? `&origin=${encodeURIComponent(window.location.origin)}` : ''}`}
|
||||
title={selectedAsset.displayName}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<BunnyPreviewPlayer
|
||||
ref={bunnyPreviewPlayerRef}
|
||||
providerVideoId={selectedAsset.providerVideoId}
|
||||
isProcessing={!!bunnyProcessingByAssetId[selectedAsset.id]}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import Hls from 'hls.js';
|
||||
import { Loader2, Pause, Play, Volume2, VolumeX } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface BunnyPreviewPlayerProps {
|
||||
providerVideoId: string | null;
|
||||
isProcessing: boolean;
|
||||
}
|
||||
|
||||
export interface BunnyPreviewPlayerHandle {
|
||||
togglePlayPause: () => void;
|
||||
seekBy: (seconds: number) => void;
|
||||
toggleMute: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_BUNNY_PULL_ZONE_HOSTNAME = 'vz-965f4f4a-fc1.b-cdn.net';
|
||||
|
||||
function resolveBunnyCdnHostname(): string {
|
||||
const configured = process.env.NEXT_PUBLIC_BUNNY_CDN_URL;
|
||||
if (!configured) return DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
|
||||
try {
|
||||
const parsed = new URL(configured);
|
||||
return parsed.hostname || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
|
||||
} catch {
|
||||
return configured.replace(/^https?:\/\//, '').replace(/\/+$/, '') || DEFAULT_BUNNY_PULL_ZONE_HOSTNAME;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(value: number): string {
|
||||
if (!Number.isFinite(value) || value < 0) return '0:00';
|
||||
const total = Math.floor(value);
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export const BunnyPreviewPlayer = forwardRef<BunnyPreviewPlayerHandle, BunnyPreviewPlayerProps>(function BunnyPreviewPlayer({ providerVideoId, isProcessing }, ref) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const retryAttemptRef = useRef(0);
|
||||
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
|
||||
const playlistUrl = useMemo(() => {
|
||||
if (!providerVideoId) return null;
|
||||
return `https://${resolveBunnyCdnHostname()}/${providerVideoId}/playlist.m3u8`;
|
||||
}, [providerVideoId]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !playlistUrl) return;
|
||||
|
||||
let destroyed = false;
|
||||
let usingHlsJs = false;
|
||||
|
||||
const clearRetry = () => {
|
||||
if (retryTimerRef.current) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRetry = (retryFn: () => void) => {
|
||||
clearRetry();
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
if (!destroyed) retryFn();
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const getRetryUrl = () => {
|
||||
retryAttemptRef.current += 1;
|
||||
const separator = playlistUrl.includes('?') ? '&' : '?';
|
||||
return `${playlistUrl}${separator}retry=${Date.now()}-${retryAttemptRef.current}`;
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
if (destroyed) return;
|
||||
setIsReady(true);
|
||||
setLoadError(false);
|
||||
setDuration(Number.isFinite(video.duration) ? video.duration : 0);
|
||||
clearRetry();
|
||||
};
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onEnded = () => setIsPlaying(false);
|
||||
const onTimeUpdate = () => setCurrentTime(video.currentTime || 0);
|
||||
const onError = () => {
|
||||
if (destroyed) return;
|
||||
if (video.readyState >= HTMLMediaElement.HAVE_METADATA) {
|
||||
setLoadError(true);
|
||||
return;
|
||||
}
|
||||
setLoadError(false);
|
||||
scheduleRetry(() => {
|
||||
if (usingHlsJs && hlsRef.current) {
|
||||
hlsRef.current.loadSource(getRetryUrl());
|
||||
hlsRef.current.startLoad(-1);
|
||||
} else {
|
||||
video.src = getRetryUrl();
|
||||
video.load();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.addEventListener('play', onPlay);
|
||||
video.addEventListener('pause', onPause);
|
||||
video.addEventListener('ended', onEnded);
|
||||
video.addEventListener('timeupdate', onTimeUpdate);
|
||||
video.addEventListener('error', onError);
|
||||
|
||||
const canPlayNativeHls = video.canPlayType('application/vnd.apple.mpegurl');
|
||||
if (Hls.isSupported()) {
|
||||
const hls = new Hls();
|
||||
hlsRef.current = hls;
|
||||
usingHlsJs = true;
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
if (!destroyed) hls.loadSource(playlistUrl);
|
||||
});
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (destroyed) return;
|
||||
if (data.fatal && video.readyState < HTMLMediaElement.HAVE_METADATA) {
|
||||
scheduleRetry(() => hls.loadSource(getRetryUrl()));
|
||||
}
|
||||
});
|
||||
} else if (canPlayNativeHls) {
|
||||
video.src = playlistUrl;
|
||||
video.load();
|
||||
} else {
|
||||
// Defer state update to avoid sync setState directly in effect body.
|
||||
window.setTimeout(() => {
|
||||
if (!destroyed) setLoadError(true);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return () => {
|
||||
destroyed = true;
|
||||
clearRetry();
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.removeEventListener('play', onPlay);
|
||||
video.removeEventListener('pause', onPause);
|
||||
video.removeEventListener('ended', onEnded);
|
||||
video.removeEventListener('timeupdate', onTimeUpdate);
|
||||
video.removeEventListener('error', onError);
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
setIsReady(false);
|
||||
};
|
||||
}, [playlistUrl]);
|
||||
|
||||
const seekTo = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !duration) return;
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
||||
video.currentTime = ratio * duration;
|
||||
setCurrentTime(video.currentTime);
|
||||
};
|
||||
|
||||
const togglePlayPause = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !isReady || isProcessing) return;
|
||||
if (video.paused) void video.play();
|
||||
else video.pause();
|
||||
}, [isProcessing, isReady]);
|
||||
|
||||
const seekBy = useCallback((seconds: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !isReady || isProcessing || !duration) return;
|
||||
video.currentTime = Math.min(duration, Math.max(0, (video.currentTime || 0) + seconds));
|
||||
setCurrentTime(video.currentTime);
|
||||
}, [duration, isProcessing, isReady]);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
const nextMuted = !video.muted;
|
||||
video.muted = nextMuted;
|
||||
setIsMuted(nextMuted);
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
togglePlayPause,
|
||||
seekBy,
|
||||
toggleMute,
|
||||
}), [seekBy, toggleMute, togglePlayPause]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full rounded-md border overflow-hidden bg-black flex flex-col">
|
||||
<div className="relative flex-1 min-h-0 flex items-center justify-center bg-black" onClick={togglePlayPause}>
|
||||
<video ref={videoRef} className="w-full h-full object-contain bg-black" playsInline preload="metadata" />
|
||||
|
||||
{(isProcessing || (!isReady && !loadError)) && (
|
||||
<div className="absolute inset-0 bg-black/65 flex items-center justify-center">
|
||||
<div className="flex items-center gap-2 text-white text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadError && !isProcessing && (
|
||||
<div className="absolute inset-0 bg-black/65 flex items-center justify-center">
|
||||
<p className="text-xs text-white/85">Unable to load Bunny preview right now.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-white/10 bg-black/70 px-2 py-1.5">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-white hover:text-white"
|
||||
disabled={!isReady || isProcessing}
|
||||
onClick={togglePlayPause}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-white hover:text-white"
|
||||
disabled={!isReady}
|
||||
onClick={toggleMute}
|
||||
>
|
||||
{isMuted ? <VolumeX className="h-3.5 w-3.5" /> : <Volume2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<span className="text-[11px] text-white/80 tabular-nums ml-1">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-6 rounded bg-white/10 select-none',
|
||||
isReady && !isProcessing ? 'cursor-pointer' : 'cursor-not-allowed opacity-70'
|
||||
)}
|
||||
onClick={seekTo}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full rounded bg-cyan-500/40 pointer-events-none"
|
||||
style={{ width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%` }}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 h-full w-1 rounded bg-cyan-400 pointer-events-none"
|
||||
style={{ left: `calc(${duration > 0 ? (currentTime / duration) * 100 : 0}% - 2px)` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { memo, type RefObject } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Image as ImageIcon, Loader2, Mic, Pause, Pencil, Play, Send, Tag, Trash2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -13,7 +12,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { AnnotationStroke } from '@/components/annotation-canvas';
|
||||
import type { CommentTag } from '@/components/video-page/types';
|
||||
import { MentionTextarea } from '@/components/video-page/mention-textarea';
|
||||
import type { CommentTag, VideoAsset } from '@/components/video-page/types';
|
||||
|
||||
interface CommentComposerProps {
|
||||
isRecording: boolean;
|
||||
@@ -51,6 +51,7 @@ interface CommentComposerProps {
|
||||
canManageTags: boolean;
|
||||
projectId?: string;
|
||||
pauseVideoForAnnotation: () => void;
|
||||
assets: VideoAsset[];
|
||||
}
|
||||
|
||||
export const CommentComposer = memo(function CommentComposer({
|
||||
@@ -89,6 +90,7 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
canManageTags,
|
||||
projectId,
|
||||
pauseVideoForAnnotation,
|
||||
assets,
|
||||
}: CommentComposerProps) {
|
||||
return (
|
||||
<div className="shrink-0 p-4 border-t bg-background">
|
||||
@@ -168,10 +170,11 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
placeholder="Add a note to your voice comment (optional)..."
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
onChange={setCommentText}
|
||||
assets={assets}
|
||||
rows={1}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
@@ -222,21 +225,24 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
placeholder="Add a comment..."
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleAddComment();
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => handlePaste(e, false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2 items-stretch">
|
||||
<div className="flex-1 min-w-0">
|
||||
<MentionTextarea
|
||||
placeholder="Add a comment..."
|
||||
value={commentText}
|
||||
onChange={setCommentText}
|
||||
assets={assets}
|
||||
rows={6}
|
||||
className="resize-none text-sm min-h-[180px] w-full"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleAddComment();
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => handlePaste(e, false)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 self-end">
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleAddComment}
|
||||
@@ -329,7 +335,7 @@ export const CommentComposer = memo(function CommentComposer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
|
||||
<p className="text-xs text-muted-foreground mt-2">Cmd+Enter to submit</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Image as ImageIcon, Video } from 'lucide-react';
|
||||
import type { VideoAsset } from '@/components/video-page/types';
|
||||
|
||||
const URL_REGEX = /(https?:\/\/[^\s]+)/g;
|
||||
const ASSET_MENTION_REGEX = /@\[(.+?)\]\(asset:([a-z0-9]+)\)/gi;
|
||||
|
||||
interface CommentRichTextProps {
|
||||
text: string;
|
||||
onAssetMentionClick?: (assetId: string) => void;
|
||||
assets?: VideoAsset[];
|
||||
}
|
||||
|
||||
function renderUrls(text: string): React.ReactNode[] {
|
||||
const parts = text.split(URL_REGEX);
|
||||
return parts.map((part, index) => {
|
||||
if (/^https?:\/\/[^\s]+$/.test(part)) {
|
||||
return (
|
||||
<a
|
||||
key={`url-${index}`}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline break-all"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <React.Fragment key={`txt-${index}`}>{part}</React.Fragment>;
|
||||
});
|
||||
}
|
||||
|
||||
export function CommentRichText({ text, onAssetMentionClick, assets = [] }: CommentRichTextProps) {
|
||||
const nodes: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of text.matchAll(ASSET_MENTION_REGEX)) {
|
||||
const mentionIndex = match.index ?? -1;
|
||||
if (mentionIndex < 0) continue;
|
||||
|
||||
if (mentionIndex > lastIndex) {
|
||||
nodes.push(...renderUrls(text.slice(lastIndex, mentionIndex)));
|
||||
}
|
||||
|
||||
const fallbackLabel = match[1] || 'asset';
|
||||
const assetId = match[2] || '';
|
||||
const matchedAsset = assets.find((asset) => asset.id === assetId);
|
||||
const label = matchedAsset?.displayName || fallbackLabel;
|
||||
const isVideoAsset = matchedAsset?.kind === 'VIDEO';
|
||||
|
||||
nodes.push(
|
||||
<button
|
||||
key={`mention-${assetId}-${mentionIndex}`}
|
||||
type="button"
|
||||
className="inline-flex max-w-full items-center gap-1 rounded bg-primary/10 px-1.5 py-0.5 text-primary hover:bg-primary/20 transition-colors align-middle"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (assetId && onAssetMentionClick) onAssetMentionClick(assetId);
|
||||
}}
|
||||
title={label}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
isVideoAsset
|
||||
? 'inline-flex h-4 shrink-0 items-center gap-0.5 rounded bg-violet-500/25 px-1 text-[9px] font-semibold tracking-wide text-violet-200'
|
||||
: 'inline-flex h-4 shrink-0 items-center gap-0.5 rounded bg-emerald-500/25 px-1 text-[9px] font-semibold tracking-wide text-emerald-200'
|
||||
}
|
||||
>
|
||||
{isVideoAsset ? <Video className="h-2.5 w-2.5" /> : <ImageIcon className="h-2.5 w-2.5" />}
|
||||
{isVideoAsset ? 'VID' : 'SS'}
|
||||
</span>
|
||||
<span className="truncate max-w-[190px] sm:max-w-[240px]">
|
||||
@{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
lastIndex = mentionIndex + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
nodes.push(...renderUrls(text.slice(lastIndex)));
|
||||
}
|
||||
|
||||
return <>{nodes}</>;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { memo, type ReactNode, type RefObject } from 'react';
|
||||
import { ArrowUpRight, CheckCircle2, Circle, Clock, Download, FileText, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X } from 'lucide-react';
|
||||
import { ArrowUpRight, CheckCircle2, ChevronDown, Circle, Clock, Download, FileText, FolderOpen, Image as ImageIcon, Loader2, MessageSquare, Mic, MoreVertical, Pause, Pencil, Play, Reply, Tag, Trash2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -14,8 +13,9 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Linkify } from '@/components/linkify';
|
||||
import type { Comment, CommentTag, Version } from '@/components/video-page/types';
|
||||
import { MentionTextarea } from '@/components/video-page/mention-textarea';
|
||||
import { CommentRichText } from '@/components/video-page/comment-rich-text';
|
||||
import type { Comment, CommentTag, Version, VideoAsset } from '@/components/video-page/types';
|
||||
|
||||
interface CommentsPaneProps {
|
||||
isMobileCommentsOpen: boolean;
|
||||
@@ -79,6 +79,11 @@ interface CommentsPaneProps {
|
||||
isUploadingReplyAudio: boolean;
|
||||
isUploadingReplyImage: boolean;
|
||||
composer: ReactNode;
|
||||
assets: VideoAsset[];
|
||||
onAssetMentionClick: (assetId: string) => void;
|
||||
activePane: 'comments' | 'assets';
|
||||
setActivePane: (pane: 'comments' | 'assets') => void;
|
||||
assetsPane: ReactNode;
|
||||
}
|
||||
|
||||
export const CommentsPane = memo(function CommentsPane({
|
||||
@@ -143,6 +148,11 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
isUploadingReplyAudio,
|
||||
isUploadingReplyImage,
|
||||
composer,
|
||||
assets,
|
||||
onAssetMentionClick,
|
||||
activePane,
|
||||
setActivePane,
|
||||
assetsPane,
|
||||
}: CommentsPaneProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -161,52 +171,94 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
'lg:static lg:w-80 lg:shrink-0 lg:border-l lg:transition-none lg:translate-x-0 lg:shadow-none lg:z-auto',
|
||||
isFullscreenMode && !showComments ? 'hidden' : ''
|
||||
)}>
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between p-4 border-b lg:cursor-default"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<span className="font-medium">Comments</span>
|
||||
<Badge variant="secondary">{comments.length}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}>
|
||||
{showResolved ? 'Hide' : 'Show'} Resolved
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={!activeVersion || isGuest || isExportingCsv || isExportingPdf}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExportComments('csv');
|
||||
}}
|
||||
title={isGuest ? 'CSV export requires an authenticated account' : 'Download comments as CSV'}
|
||||
>
|
||||
{isExportingCsv ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
disabled={!activeVersion || isExportingCsv || isExportingPdf}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExportComments('pdf');
|
||||
}}
|
||||
title="Download comments as PDF"
|
||||
>
|
||||
{isExportingPdf ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileText className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden" onClick={() => setIsMobileCommentsOpen(false)}>
|
||||
<div className="shrink-0 p-4 border-b lg:cursor-default space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1 min-w-0 overflow-x-auto">
|
||||
<Button
|
||||
variant={activePane === 'comments' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => setActivePane('comments')}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 mr-1" />
|
||||
Comments
|
||||
<Badge variant="secondary" className="ml-2">{comments.length}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant={activePane === 'assets' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 shrink-0"
|
||||
onClick={() => setActivePane('assets')}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-1" />
|
||||
Assets
|
||||
<Badge variant="secondary" className="ml-2">{assets.length}</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 lg:hidden shrink-0" onClick={() => setIsMobileCommentsOpen(false)}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activePane === 'comments' && (
|
||||
<div className="flex w-full items-center justify-end gap-2 flex-wrap">
|
||||
<Button
|
||||
variant={showResolved ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={(e) => { e.stopPropagation(); handleToggleShowResolved(); }}
|
||||
>
|
||||
Resolved
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
disabled={!activeVersion || isExportingCsv || isExportingPdf}
|
||||
aria-label="Download comments"
|
||||
title="Download comments"
|
||||
>
|
||||
{isExportingCsv || isExportingPdf ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 ml-0.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
disabled={!activeVersion || isGuest || isExportingCsv || isExportingPdf}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExportComments('csv');
|
||||
}}
|
||||
title={isGuest ? 'CSV export requires an authenticated account' : 'Download comments as CSV'}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download CSV
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!activeVersion || isExportingCsv || isExportingPdf}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExportComments('pdf');
|
||||
}}
|
||||
title="Download comments as PDF"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
Download PDF
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{filteredComments.length === 0 ? (
|
||||
{activePane === 'assets' ? assetsPane : filteredComments.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>No comments yet</p>
|
||||
@@ -312,9 +364,10 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mb-2">
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onChange={setEditText}
|
||||
assets={assets}
|
||||
rows={2}
|
||||
className="resize-none text-sm mb-1"
|
||||
autoFocus
|
||||
@@ -402,7 +455,11 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-2">
|
||||
{comment.content && <p className="text-sm mb-2"><Linkify>{comment.content}</Linkify></p>}
|
||||
{comment.content && (
|
||||
<p className="text-sm mb-2">
|
||||
<CommentRichText text={comment.content} onAssetMentionClick={onAssetMentionClick} assets={assets} />
|
||||
</p>
|
||||
)}
|
||||
{comment.imageUrl && (
|
||||
<div
|
||||
className="rounded-md overflow-hidden bg-muted mb-2 max-h-60 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||
@@ -530,9 +587,10 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
</div>
|
||||
{isEditingReply ? (
|
||||
<div className="mb-1">
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onChange={setEditText}
|
||||
assets={assets}
|
||||
rows={2}
|
||||
className="resize-none text-sm mb-1"
|
||||
autoFocus
|
||||
@@ -567,7 +625,11 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-1">
|
||||
{reply.content && <p className="text-sm"><Linkify>{reply.content}</Linkify></p>}
|
||||
{reply.content && (
|
||||
<p className="text-sm">
|
||||
<CommentRichText text={reply.content} onAssetMentionClick={onAssetMentionClick} assets={assets} />
|
||||
</p>
|
||||
)}
|
||||
{reply.imageUrl && (
|
||||
<div
|
||||
className="rounded-md overflow-hidden bg-muted mt-2 max-h-40 flex items-center justify-center cursor-pointer hover:opacity-90 transition-opacity"
|
||||
@@ -689,9 +751,10 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onChange={setReplyText}
|
||||
assets={assets}
|
||||
placeholder="Add a note (optional)..."
|
||||
rows={1}
|
||||
className="resize-none text-sm"
|
||||
@@ -725,9 +788,10 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<Textarea
|
||||
<MentionTextarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onChange={setReplyText}
|
||||
assets={assets}
|
||||
placeholder="Write a reply..."
|
||||
rows={2}
|
||||
className="resize-none text-sm flex-1"
|
||||
@@ -807,7 +871,7 @@ export const CommentsPane = memo(function CommentsPane({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{composer}
|
||||
{activePane === 'comments' ? composer : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { toast } from 'sonner';
|
||||
import type { AnnotationCanvasHandle, AnnotationStroke } from '@/components/annotation-canvas';
|
||||
import type { Comment, CommentActionsConfig, CommentTag, Version, VideoData } from '@/components/video-page/types';
|
||||
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
|
||||
|
||||
interface UseCommentActionsParams extends CommentActionsConfig {
|
||||
setVideo: Dispatch<SetStateAction<VideoData | null>>;
|
||||
@@ -279,13 +280,9 @@ export function useCommentActions({
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('Image must be less than 10MB');
|
||||
const imageError = validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -297,27 +294,21 @@ export function useCommentActions({
|
||||
}, []);
|
||||
|
||||
const handlePaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>, isReply: boolean = false) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const file = extractPastedImageFile(e.clipboardData);
|
||||
if (!file) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('Image must be less than 10MB');
|
||||
return;
|
||||
}
|
||||
if (isReply) {
|
||||
setReplyImageBlob(file);
|
||||
} else {
|
||||
setImageBlob(file);
|
||||
}
|
||||
e.preventDefault();
|
||||
break;
|
||||
}
|
||||
}
|
||||
const imageError = validateImageFile(file);
|
||||
if (imageError) {
|
||||
toast.error(imageError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReply) {
|
||||
setReplyImageBlob(file);
|
||||
} else {
|
||||
setImageBlob(file);
|
||||
}
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { VideoAsset } from '@/components/video-page/types';
|
||||
|
||||
type BunnyDownloadPreference = 'original' | 'compressed';
|
||||
|
||||
type CreateAssetPayload = {
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
|
||||
displayName?: string;
|
||||
sourceUrl: string;
|
||||
providerVideoId?: string;
|
||||
thumbnailUrl?: string;
|
||||
uploadToken?: string;
|
||||
};
|
||||
|
||||
interface UseVideoAssetsParams {
|
||||
videoId: string;
|
||||
isAuthenticated: boolean;
|
||||
canUploadAssets: boolean;
|
||||
canDownloadAssets: boolean;
|
||||
guestName?: string;
|
||||
}
|
||||
|
||||
interface AssetsListResponse {
|
||||
data?: {
|
||||
assets?: VideoAsset[];
|
||||
pagination?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
hasMore?: boolean;
|
||||
nextOffset?: number | null;
|
||||
};
|
||||
canUploadAssets?: boolean;
|
||||
canDownloadAssets?: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface AssetCreateResponse {
|
||||
data?: VideoAsset;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ASSET_PAGE_SIZE = 40;
|
||||
|
||||
export function useVideoAssets({
|
||||
videoId,
|
||||
isAuthenticated,
|
||||
canUploadAssets,
|
||||
canDownloadAssets,
|
||||
guestName,
|
||||
}: UseVideoAssetsParams) {
|
||||
const [assets, setAssets] = useState<VideoAsset[]>([]);
|
||||
const [isLoadingAssets, setIsLoadingAssets] = useState(true);
|
||||
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
|
||||
const [activeDeleteAssetId, setActiveDeleteAssetId] = useState<string | null>(null);
|
||||
const [activeDownloadAssetId, setActiveDownloadAssetId] = useState<string | null>(null);
|
||||
const [hasMoreAssets, setHasMoreAssets] = useState(false);
|
||||
const [nextAssetsOffset, setNextAssetsOffset] = useState(0);
|
||||
const [isLoadingMoreAssets, setIsLoadingMoreAssets] = useState(false);
|
||||
|
||||
const fetchAssets = useCallback(async () => {
|
||||
setIsLoadingAssets(true);
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=0`, { cache: 'no-store' });
|
||||
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
|
||||
if (!res.ok) {
|
||||
toast.error(payload?.error || 'Failed to fetch assets');
|
||||
return;
|
||||
}
|
||||
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
|
||||
const pagination = payload?.data?.pagination;
|
||||
setAssets(list);
|
||||
setHasMoreAssets(!!pagination?.hasMore);
|
||||
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
|
||||
} catch {
|
||||
toast.error('Failed to fetch assets');
|
||||
} finally {
|
||||
setIsLoadingAssets(false);
|
||||
}
|
||||
}, [videoId]);
|
||||
|
||||
const loadMoreAssets = useCallback(async () => {
|
||||
if (isLoadingMoreAssets || !hasMoreAssets) return;
|
||||
setIsLoadingMoreAssets(true);
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${videoId}/assets?limit=${ASSET_PAGE_SIZE}&offset=${nextAssetsOffset}`, { cache: 'no-store' });
|
||||
const payload = (await res.json().catch(() => null)) as AssetsListResponse | null;
|
||||
if (!res.ok) {
|
||||
toast.error(payload?.error || 'Failed to load more assets');
|
||||
return;
|
||||
}
|
||||
const list = Array.isArray(payload?.data?.assets) ? payload.data.assets : [];
|
||||
const pagination = payload?.data?.pagination;
|
||||
setAssets((prev) => [...prev, ...list.filter((asset) => !prev.some((existing) => existing.id === asset.id))]);
|
||||
setHasMoreAssets(!!pagination?.hasMore);
|
||||
setNextAssetsOffset(typeof pagination?.nextOffset === 'number' ? pagination.nextOffset : 0);
|
||||
} catch {
|
||||
toast.error('Failed to load more assets');
|
||||
} finally {
|
||||
setIsLoadingMoreAssets(false);
|
||||
}
|
||||
}, [hasMoreAssets, isLoadingMoreAssets, nextAssetsOffset, videoId]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchAssets();
|
||||
}, [fetchAssets]);
|
||||
|
||||
const createAsset = useCallback(async (payload: CreateAssetPayload): Promise<VideoAsset | null> => {
|
||||
if (!canUploadAssets) {
|
||||
toast.error('You do not have permission to upload assets');
|
||||
return null;
|
||||
}
|
||||
|
||||
setIsCreatingAsset(true);
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${videoId}/assets`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
...(isAuthenticated ? {} : { guestName: guestName?.trim() || 'Guest' }),
|
||||
}),
|
||||
});
|
||||
const body = (await res.json().catch(() => null)) as AssetCreateResponse | null;
|
||||
if (!res.ok || !body?.data) {
|
||||
toast.error(body?.error || 'Failed to create asset');
|
||||
return null;
|
||||
}
|
||||
|
||||
setAssets((prev) => [body.data!, ...prev]);
|
||||
setNextAssetsOffset((prev) => prev + 1);
|
||||
return body.data;
|
||||
} catch {
|
||||
toast.error('Failed to create asset');
|
||||
return null;
|
||||
} finally {
|
||||
setIsCreatingAsset(false);
|
||||
}
|
||||
}, [canUploadAssets, videoId, isAuthenticated, guestName]);
|
||||
|
||||
const deleteAsset = useCallback(async (assetId: string) => {
|
||||
setActiveDeleteAssetId(assetId);
|
||||
try {
|
||||
const res = await fetch(`/api/videos/${videoId}/assets/${assetId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const payload = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
if (!res.ok) {
|
||||
toast.error(payload?.error || 'Failed to delete asset');
|
||||
return false;
|
||||
}
|
||||
|
||||
setAssets((prev) => prev.filter((asset) => asset.id !== assetId));
|
||||
setNextAssetsOffset((prev) => Math.max(0, prev - 1));
|
||||
return true;
|
||||
} catch {
|
||||
toast.error('Failed to delete asset');
|
||||
return false;
|
||||
} finally {
|
||||
setActiveDeleteAssetId(null);
|
||||
}
|
||||
}, [videoId]);
|
||||
|
||||
const downloadAsset = useCallback(async (asset: VideoAsset, preference: BunnyDownloadPreference = 'compressed') => {
|
||||
if (!canDownloadAssets) {
|
||||
toast.error('Asset downloads require an authenticated account');
|
||||
return;
|
||||
}
|
||||
if (asset.provider === 'YOUTUBE') {
|
||||
toast.error('YouTube assets cannot be downloaded');
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveDownloadAssetId(asset.id);
|
||||
try {
|
||||
let downloadUrl = `/api/videos/${videoId}/assets/${asset.id}/download`;
|
||||
|
||||
if (asset.provider === 'BUNNY') {
|
||||
const prepareRes = await fetch(`${downloadUrl}?source=${preference}&prepare=1`, { cache: 'no-store' });
|
||||
const prepareBody = (await prepareRes.json().catch(() => null)) as { error?: string } | null;
|
||||
if (!prepareRes.ok) {
|
||||
toast.error(prepareBody?.error || 'Download is not available');
|
||||
return;
|
||||
}
|
||||
downloadUrl = `${downloadUrl}?source=${preference}`;
|
||||
}
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
} catch {
|
||||
toast.error('Failed to start download');
|
||||
} finally {
|
||||
setActiveDownloadAssetId(null);
|
||||
}
|
||||
}, [canDownloadAssets, videoId]);
|
||||
|
||||
const getGuestUploadToken = useCallback(async (intent: 'image') => {
|
||||
if (isAuthenticated) return null;
|
||||
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ intent }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { data?: { token?: string }; error?: string }
|
||||
| null;
|
||||
const token = payload?.data?.token;
|
||||
if (!response.ok || !token) {
|
||||
throw new Error(payload?.error || 'Failed to prepare upload');
|
||||
}
|
||||
return token;
|
||||
}, [isAuthenticated, videoId]);
|
||||
|
||||
return {
|
||||
assets,
|
||||
isLoadingAssets,
|
||||
isCreatingAsset,
|
||||
activeDeleteAssetId,
|
||||
activeDownloadAssetId,
|
||||
hasMoreAssets,
|
||||
isLoadingMoreAssets,
|
||||
fetchAssets,
|
||||
loadMoreAssets,
|
||||
createAsset,
|
||||
deleteAsset,
|
||||
downloadAsset,
|
||||
getGuestUploadToken,
|
||||
};
|
||||
}
|
||||
@@ -521,6 +521,10 @@ export function useVideoPlayer({
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (document.querySelector('[data-slot="dialog-content"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
|
||||
@@ -9,71 +9,94 @@ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
interface ImagePreviewDialogProps {
|
||||
previewImage: string | null;
|
||||
onClose: () => void;
|
||||
title?: string | null;
|
||||
downloadFileName?: string | null;
|
||||
canDownload?: boolean;
|
||||
}
|
||||
|
||||
export const ImagePreviewDialog = memo(function ImagePreviewDialog({
|
||||
previewImage,
|
||||
onClose,
|
||||
title,
|
||||
downloadFileName,
|
||||
canDownload = true,
|
||||
}: ImagePreviewDialogProps) {
|
||||
const resolvedDownloadName = downloadFileName || (previewImage ? previewImage.split('/').pop() || 'attachment.png' : 'attachment.png');
|
||||
|
||||
return (
|
||||
<Dialog open={!!previewImage} onOpenChange={(open) => !open && onClose()}>
|
||||
<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 flex flex-col items-center justify-center rounded-none"
|
||||
className="max-w-none sm:max-w-none w-screen h-screen max-h-screen p-0 overflow-hidden bg-black/90 border-none shadow-none flex items-center justify-center rounded-none"
|
||||
onClick={onClose}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle className="sr-only">Image Preview</DialogTitle>
|
||||
<div className="absolute top-4 right-4 flex gap-3 z-50">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (!previewImage) return;
|
||||
const response = await fetch(previewImage);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = previewImage.split('/').pop() || 'attachment.png';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error);
|
||||
toast.error('Failed to download image');
|
||||
}
|
||||
}}
|
||||
<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="shrink-0 flex items-center gap-2 border-b border-border/60 bg-background/85 px-2 py-1.5 backdrop-blur-sm">
|
||||
<p className="flex-1 min-w-0 text-sm text-foreground truncate" title={title || undefined}>
|
||||
{title || 'Image Preview'}
|
||||
</p>
|
||||
{canDownload ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (!previewImage) return;
|
||||
const response = await fetch(previewImage);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = resolvedDownloadName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Failed to download image:', error);
|
||||
toast.error('Failed to download image');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="relative flex-1 min-h-0 w-full flex items-center justify-center p-2 sm:p-4 cursor-zoom-out"
|
||||
onClick={onClose}
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full bg-black/40 hover:bg-black/80 border-white/20 text-white h-10 w-10 backdrop-blur-md transition-all shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="relative w-full h-full flex items-center justify-center p-4 cursor-zoom-out"
|
||||
onClick={onClose}
|
||||
>
|
||||
{previewImage && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
className="max-w-[95vw] max-h-[90vh] object-contain rounded-md select-none cursor-default"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
{previewImage && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewImage}
|
||||
alt={title || 'Preview'}
|
||||
className="max-w-full max-h-full object-contain rounded-md select-none cursor-default"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
export const MAX_IMAGE_UPLOAD_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export function validateImageFile(file: File): string | null {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return 'Please select an image file';
|
||||
}
|
||||
|
||||
if (file.size > MAX_IMAGE_UPLOAD_BYTES) {
|
||||
return 'Image must be less than 10MB';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractPastedImageFile(data: DataTransfer | null | undefined): File | null {
|
||||
const items = data?.items;
|
||||
if (!items) return null;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (!item.type.startsWith('image/')) continue;
|
||||
const file = item.getAsFile();
|
||||
if (file) return file;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { VideoAsset } from '@/components/video-page/types';
|
||||
|
||||
type MentionRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
interface MentionTextareaProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
assets: VideoAsset[];
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
className?: string;
|
||||
onPaste?: React.ClipboardEventHandler<HTMLTextAreaElement>;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLTextAreaElement>;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function findMentionRange(text: string, caret: number): MentionRange | null {
|
||||
const before = text.slice(0, caret);
|
||||
const atIndex = before.lastIndexOf('@');
|
||||
if (atIndex < 0) return null;
|
||||
|
||||
const charBeforeAt = atIndex === 0 ? ' ' : before[atIndex - 1];
|
||||
if (charBeforeAt && !/\s/.test(charBeforeAt)) return null;
|
||||
|
||||
const query = before.slice(atIndex + 1);
|
||||
if (query.length === 0) {
|
||||
return { start: atIndex, end: caret, query: '' };
|
||||
}
|
||||
|
||||
if (/\s|\[|\]|\(|\)/.test(query)) return null;
|
||||
|
||||
return { start: atIndex, end: caret, query };
|
||||
}
|
||||
|
||||
export function MentionTextarea({
|
||||
value,
|
||||
onChange,
|
||||
assets,
|
||||
placeholder,
|
||||
rows = 2,
|
||||
className,
|
||||
onPaste,
|
||||
onKeyDown,
|
||||
autoFocus,
|
||||
disabled,
|
||||
}: MentionTextareaProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [mentionRange, setMentionRange] = useState<MentionRange | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const searchableAssets = useMemo(() => {
|
||||
return assets
|
||||
.map((asset) => ({
|
||||
asset,
|
||||
displayNameLower: asset.displayName.toLowerCase(),
|
||||
createdAtMs: Date.parse(asset.createdAt),
|
||||
}))
|
||||
.sort((a, b) => b.createdAtMs - a.createdAtMs);
|
||||
}, [assets]);
|
||||
|
||||
const filteredAssets = useMemo(() => {
|
||||
if (!mentionRange) return [];
|
||||
const query = mentionRange.query.trim().toLowerCase();
|
||||
if (!query) return searchableAssets.slice(0, 8).map((entry) => entry.asset);
|
||||
return searchableAssets
|
||||
.filter((entry) => entry.displayNameLower.includes(query))
|
||||
.map((entry) => entry.asset)
|
||||
.slice(0, 8);
|
||||
}, [mentionRange, searchableAssets]);
|
||||
|
||||
const closeMentions = () => {
|
||||
setMentionRange(null);
|
||||
setActiveIndex(0);
|
||||
};
|
||||
|
||||
const insertAssetMention = (asset: VideoAsset) => {
|
||||
if (!mentionRange || !textareaRef.current) return;
|
||||
const mentionToken = `@[${asset.displayName}](asset:${asset.id}) `;
|
||||
const nextValue = `${value.slice(0, mentionRange.start)}${mentionToken}${value.slice(mentionRange.end)}`;
|
||||
onChange(nextValue);
|
||||
closeMentions();
|
||||
|
||||
const nextCursor = mentionRange.start + mentionToken.length;
|
||||
requestAnimationFrame(() => {
|
||||
if (!textareaRef.current) return;
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.setSelectionRange(nextCursor, nextCursor);
|
||||
});
|
||||
};
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const nextValue = event.target.value;
|
||||
onChange(nextValue);
|
||||
|
||||
const caret = event.target.selectionStart ?? nextValue.length;
|
||||
const range = findMentionRange(nextValue, caret);
|
||||
setMentionRange(range);
|
||||
setActiveIndex(0);
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (event) => {
|
||||
if (mentionRange && filteredAssets.length > 0) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setActiveIndex((prev) => (prev + 1) % filteredAssets.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setActiveIndex((prev) => (prev - 1 + filteredAssets.length) % filteredAssets.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const selected = filteredAssets[Math.min(activeIndex, filteredAssets.length - 1)];
|
||||
if (selected) insertAssetMention(selected);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
closeMentions();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onKeyDown?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={className}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
onBlur={() => {
|
||||
window.setTimeout(closeMentions, 120);
|
||||
}}
|
||||
/>
|
||||
|
||||
{mentionRange && filteredAssets.length > 0 && (
|
||||
<div className="absolute left-0 right-0 bottom-full mb-1 z-30 rounded-md border bg-popover shadow-md overflow-hidden">
|
||||
{filteredAssets.map((asset, index) => (
|
||||
<button
|
||||
key={asset.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-2 py-1.5 text-xs hover:bg-accent transition-colors',
|
||||
index === activeIndex && 'bg-accent'
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
insertAssetMention(asset);
|
||||
}}
|
||||
>
|
||||
<span className="font-medium">@{asset.displayName}</span>
|
||||
<span className="ml-2 text-muted-foreground">{asset.provider}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,23 @@ export interface CommentTag {
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface VideoAsset {
|
||||
id: string;
|
||||
videoId: string;
|
||||
kind: 'IMAGE' | 'VIDEO';
|
||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
|
||||
displayName: string;
|
||||
sourceUrl: string | null;
|
||||
providerVideoId: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
uploadedByUserId: string | null;
|
||||
uploadedByGuestName: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
uploadedByUser: { id: string; name: string | null; image: string | null } | null;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalDecision {
|
||||
id: string;
|
||||
approverId: string;
|
||||
@@ -111,6 +128,9 @@ export interface VideoData {
|
||||
canManageTags?: boolean;
|
||||
canResolveComments?: boolean;
|
||||
canRequestApproval?: boolean;
|
||||
canShareVideo?: boolean;
|
||||
canUploadAssets?: boolean;
|
||||
canDownloadAssets?: boolean;
|
||||
}
|
||||
|
||||
export interface BunnyQualityOption {
|
||||
|
||||
@@ -61,6 +61,7 @@ interface VideoPageHeaderProps {
|
||||
onCreateVersion: () => void;
|
||||
onOpenCompare: () => void;
|
||||
canRequestApproval: boolean;
|
||||
canShareVideo: boolean;
|
||||
hasPendingApprovalRequest: boolean;
|
||||
onOpenApprovalRequest: () => void;
|
||||
onOpenApprovalsPanel: () => void;
|
||||
@@ -107,6 +108,7 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
||||
onCreateVersion,
|
||||
onOpenCompare,
|
||||
canRequestApproval,
|
||||
canShareVideo,
|
||||
hasPendingApprovalRequest,
|
||||
onOpenApprovalRequest,
|
||||
onOpenApprovalsPanel,
|
||||
@@ -231,17 +233,24 @@ export const VideoPageHeader = memo(function VideoPageHeader({
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
<Button variant="outline" size="sm" className="w-7 px-0 self-center">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
|
||||
{canShareVideo ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
Share Video
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem disabled>
|
||||
<Share2 className="h-4 w-4 mr-2" />
|
||||
Share Video
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={onOpenApprovalRequest}
|
||||
disabled={!canRequestApproval}
|
||||
|
||||
Reference in New Issue
Block a user