"use client"; import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent, type PointerEvent, } from "react"; import { createPortal } from "react-dom"; import { useAction } from "convex/react"; import { X, Search, Loader2, AlertCircle, Play, Pause } from "lucide-react"; import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import type { PexelsVideo, PexelsVideoFile } from "@/lib/pexels-types"; import { pickPreviewVideoFile, pickVideoFile } from "@/lib/pexels-types"; import { toast } from "@/lib/toast"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; type Orientation = "" | "landscape" | "portrait" | "square"; type DurationFilter = "all" | "short" | "medium" | "long"; function formatDuration(seconds: number): string { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${s.toString().padStart(2, "0")}`; } function pexelsVideoProxySrc(mp4Url: string): string { return `/api/pexels-video?u=${encodeURIComponent(mp4Url)}`; } export interface VideoBrowserSessionState { term: string; orientation: Orientation; durationFilter: DurationFilter; results: PexelsVideo[]; page: number; totalPages: number; } interface Props { nodeId: string; canvasId: string; onClose: () => void; initialState?: VideoBrowserSessionState; onStateChange?: (state: VideoBrowserSessionState) => void; } export function VideoBrowserPanel({ nodeId, canvasId, onClose, initialState, onStateChange, }: Props) { const [isMounted, setIsMounted] = useState(false); const [term, setTerm] = useState(initialState?.term ?? ""); const [debouncedTerm, setDebouncedTerm] = useState(initialState?.term ?? ""); const [orientation, setOrientation] = useState( initialState?.orientation ?? "", ); const [durationFilter, setDurationFilter] = useState( initialState?.durationFilter ?? "all", ); const [results, setResults] = useState( initialState?.results ?? [], ); const [page, setPage] = useState(initialState?.page ?? 1); const [totalPages, setTotalPages] = useState(initialState?.totalPages ?? 1); const [isLoading, setIsLoading] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [selectingVideoId, setSelectingVideoId] = useState( null, ); const [previewingVideoId, setPreviewingVideoId] = useState( null, ); const searchVideos = useAction(api.pexels.searchVideos); const popularVideos = useAction(api.pexels.popularVideos); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const shouldSkipInitialSearchRef = useRef( Boolean(initialState?.results?.length), ); const requestSequenceRef = useRef(0); const scrollAreaRef = useRef(null); const isSelecting = selectingVideoId !== null; useEffect(() => { setIsMounted(true); return () => setIsMounted(false); }, []); // Debounce useEffect(() => { const timeout = setTimeout(() => setDebouncedTerm(term), 400); return () => clearTimeout(timeout); }, [term]); useEffect(() => { setPreviewingVideoId(null); }, [debouncedTerm, orientation, durationFilter, page]); // Escape useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [onClose]); // Session state sync useEffect(() => { if (!onStateChange) return; onStateChange({ term, orientation, durationFilter, results, page, totalPages }); }, [durationFilter, onStateChange, orientation, page, results, term, totalPages]); const getDurationParams = useCallback( (filter: DurationFilter): { minDuration?: number; maxDuration?: number } => { switch (filter) { case "short": return { maxDuration: 30 }; case "medium": return { minDuration: 30, maxDuration: 60 }; case "long": return { minDuration: 60 }; default: return {}; } }, [], ); const runSearch = useCallback( async (searchTerm: string, requestedPage: number) => { const seq = ++requestSequenceRef.current; setIsLoading(true); setErrorMessage(null); try { const isSearch = searchTerm.trim().length > 0; const durationParams = getDurationParams(durationFilter); const response = isSearch ? await searchVideos({ query: searchTerm, orientation: orientation || undefined, page: requestedPage, perPage: 20, ...durationParams, }) : await popularVideos({ page: requestedPage, perPage: 20, }); if (seq !== requestSequenceRef.current) return; const videos = response.videos ?? []; setResults(videos); setPage(requestedPage); // Estimate total pages from next_page presence const estimatedTotal = response.total_results ?? videos.length * requestedPage; const perPage = response.per_page ?? 20; setTotalPages(Math.max(1, Math.ceil(estimatedTotal / perPage))); if (scrollAreaRef.current) scrollAreaRef.current.scrollTop = 0; } catch (error) { if (seq !== requestSequenceRef.current) return; console.error("Pexels video search error", error); setErrorMessage( error instanceof Error ? error.message : "Search failed", ); } finally { if (seq === requestSequenceRef.current) setIsLoading(false); } }, [searchVideos, popularVideos, orientation, durationFilter, getDurationParams], ); // Trigger search useEffect(() => { if (shouldSkipInitialSearchRef.current) { shouldSkipInitialSearchRef.current = false; return; } void runSearch(debouncedTerm, 1); }, [debouncedTerm, orientation, durationFilter, runSearch]); const handleSelect = useCallback( async (video: PexelsVideo) => { if (isSelecting) return; if (status.isOffline) { toast.warning( "Offline aktuell nicht unterstützt", "Video-Auswahl benötigt eine aktive Verbindung.", ); return; } setSelectingVideoId(video.id); let file: PexelsVideoFile; try { file = pickVideoFile(video.video_files); } catch (err) { toast.error( err instanceof Error ? err.message : "Videoformat nicht verfügbar", ); setSelectingVideoId(null); return; } try { await queueNodeDataUpdate({ nodeId: nodeId as Id<"nodes">, data: { pexelsId: video.id, mp4Url: file.link, thumbnailUrl: video.image, width: video.width, height: video.height, duration: video.duration, attribution: { userName: video.user.name, userUrl: video.user.url, videoUrl: video.url, }, canvasId, }, }); // Auto-resize to match aspect ratio const aspectRatio = video.width > 0 && video.height > 0 ? video.width / video.height : 16 / 9; const targetWidth = 320; const targetHeight = Math.round(targetWidth / aspectRatio); await queueNodeResize({ nodeId: nodeId as Id<"nodes">, width: targetWidth, height: targetHeight, }); onClose(); } catch (error) { console.error("Failed to select video", error); } finally { setSelectingVideoId(null); } }, [canvasId, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline], ); const handlePreviousPage = useCallback(() => { if (isLoading || page <= 1) return; void runSearch(debouncedTerm, page - 1); }, [debouncedTerm, isLoading, page, runSearch]); const handleNextPage = useCallback(() => { if (isLoading || page >= totalPages) return; void runSearch(debouncedTerm, page + 1); }, [debouncedTerm, isLoading, page, runSearch, totalPages]); const modal = useMemo( () => (
event.stopPropagation()} onPointerDownCapture={(event) => event.stopPropagation()} >
event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()} onPointerDownCapture={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label="Browse Pexels videos" > {/* Header */}

Browse Pexels Videos

{/* Search + Filters */}
setTerm(event.target.value)} className="pl-9" autoFocus />
Format: {(["", "landscape", "portrait", "square"] as const).map((o) => ( ))}
Dauer: {( [ ["all", "Alle"], ["short", "<30s"], ["medium", "30–60s"], ["long", ">60s"], ] as const ).map(([key, label]) => ( ))}
{/* Results */}
event.stopPropagation()} > {isLoading && results.length === 0 ? (
{Array.from({ length: 12 }).map((_, index) => (
))}
) : errorMessage ? (

Search failed

{errorMessage}

) : results.length === 0 ? (

{term.trim() ? "Keine Videos gefunden" : "Videos werden geladen..."}

) : (
{results.map((video) => { const isSelectingThis = selectingVideoId === video.id; const previewFile = pickPreviewVideoFile(video.video_files); const previewSrc = previewFile ? pexelsVideoProxySrc(previewFile.link) : null; const isPreview = previewingVideoId === video.id; const stopBubbling = (e: PointerEvent | MouseEvent) => { e.stopPropagation(); }; return (
{isPreview && previewSrc ? (
)}
{/* Footer */}
{results.length > 0 ? (
Seite {page} von {totalPages}
) : null}

Videos by{" "} Pexels . Free to use, attribution appreciated.

{results.length > 0 ? `${results.length} Videos` : ""}
), [ debouncedTerm, durationFilter, errorMessage, handleNextPage, handlePreviousPage, handleSelect, isLoading, isSelecting, onClose, orientation, page, results, runSearch, previewingVideoId, selectingVideoId, term, totalPages, ], ); if (!isMounted) return null; return createPortal(modal, document.body); }