"use client"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { useAction } from "convex/react"; import { X, Search, Loader2, AlertCircle } 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 { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; import { computeMediaNodeSize } from "@/lib/canvas-utils"; import { useCanvasSync } from "@/components/canvas/canvas-sync-context"; import { toast } from "@/lib/toast"; type AssetType = "photo" | "vector" | "icon"; interface FreepikResult { id: number; title: string; assetType: AssetType; previewUrl: string; intrinsicWidth?: number; intrinsicHeight?: number; sourceUrl: string; license: "freemium" | "premium"; authorName: string; orientation?: string; } export interface AssetBrowserSessionState { term: string; assetType: AssetType; results: FreepikResult[]; page: number; totalPages: number; } /** Canvas-weit: bleibt beim Wechsel optimistic_… → echte Node-ID erhalten (sonst remount = Panel zu). */ export type AssetBrowserTargetApi = { targetNodeId: string | null; openForNode: (nodeId: string) => void; close: () => void; }; export const AssetBrowserTargetContext = createContext( null, ); export function useAssetBrowserTarget(): AssetBrowserTargetApi { const v = useContext(AssetBrowserTargetContext); if (!v) { throw new Error("useAssetBrowserTarget must be used within AssetBrowserTargetContext.Provider"); } return v; } interface Props { nodeId: string; canvasId: string; onClose: () => void; initialState?: AssetBrowserSessionState; onStateChange?: (state: AssetBrowserSessionState) => void; } export function AssetBrowserPanel({ nodeId, canvasId, onClose, initialState, onStateChange, }: Props) { const [term, setTerm] = useState(initialState?.term ?? ""); const [debouncedTerm, setDebouncedTerm] = useState(initialState?.term ?? ""); const [assetType, setAssetType] = useState(initialState?.assetType ?? "photo"); 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 [selectingAssetKey, setSelectingAssetKey] = useState(null); const searchFreepik = useAction(api.freepik.search); const { queueNodeDataUpdate, queueNodeResize, status } = useCanvasSync(); const shouldSkipInitialSearchRef = useRef(Boolean(initialState?.results?.length)); const requestSequenceRef = useRef(0); const scrollAreaRef = useRef(null); const isSelecting = selectingAssetKey !== null; useEffect(() => { const timeout = setTimeout(() => { setDebouncedTerm(term); }, 500); return () => clearTimeout(timeout); }, [term]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") { onClose(); } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [onClose]); useEffect(() => { if (!onStateChange) return; onStateChange({ term, assetType, results, page, totalPages, }); }, [assetType, onStateChange, page, results, term, totalPages]); const runSearch = useCallback( async (searchTerm: string, type: AssetType, requestedPage: number) => { const cleanedTerm = searchTerm.trim(); const requestSequence = ++requestSequenceRef.current; if (!cleanedTerm) { setResults([]); setErrorMessage(null); setTotalPages(1); setPage(1); return; } setIsLoading(true); setErrorMessage(null); try { const response = await searchFreepik({ term: cleanedTerm, assetType: type, page: requestedPage, limit: 20, }); if (requestSequence !== requestSequenceRef.current) { return; } setResults(response.results); setTotalPages(response.totalPages); setPage(response.currentPage); if (scrollAreaRef.current) { scrollAreaRef.current.scrollTop = 0; } } catch (error) { if (requestSequence !== requestSequenceRef.current) { return; } console.error("Freepik search error", error); setErrorMessage( error instanceof Error ? error.message : "Freepik search failed", ); } finally { if (requestSequence === requestSequenceRef.current) { setIsLoading(false); } } }, [searchFreepik], ); useEffect(() => { if (shouldSkipInitialSearchRef.current) { shouldSkipInitialSearchRef.current = false; return; } setPage(1); void runSearch(debouncedTerm, assetType, 1); }, [assetType, debouncedTerm, runSearch]); const handleSelect = useCallback( async (asset: FreepikResult) => { if (isSelecting) return; if (status.isOffline) { toast.warning( "Offline aktuell nicht unterstützt", "Asset-Auswahl benötigt eine aktive Verbindung.", ); return; } const assetKey = `${asset.assetType}-${asset.id}`; setSelectingAssetKey(assetKey); try { await queueNodeDataUpdate({ nodeId: nodeId as Id<"nodes">, data: { assetId: asset.id, assetType: asset.assetType, title: asset.title, previewUrl: asset.previewUrl, intrinsicWidth: asset.intrinsicWidth, intrinsicHeight: asset.intrinsicHeight, url: asset.previewUrl, sourceUrl: asset.sourceUrl, license: asset.license, authorName: asset.authorName, orientation: asset.orientation, canvasId, }, }); const targetSize = computeMediaNodeSize("asset", { intrinsicWidth: asset.intrinsicWidth, intrinsicHeight: asset.intrinsicHeight, orientation: asset.orientation, }); await queueNodeResize({ nodeId: nodeId as Id<"nodes">, width: targetSize.width, height: targetSize.height, }); onClose(); } catch (error) { console.error("Failed to select asset", error); } finally { setSelectingAssetKey(null); } }, [canvasId, isSelecting, nodeId, onClose, queueNodeDataUpdate, queueNodeResize, status.isOffline], ); const handlePreviousPage = useCallback(() => { if (isLoading || page <= 1) return; void runSearch(debouncedTerm, assetType, page - 1); }, [assetType, debouncedTerm, isLoading, page, runSearch]); const handleNextPage = useCallback(() => { if (isLoading || page >= totalPages) return; void runSearch(debouncedTerm, assetType, page + 1); }, [assetType, 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 Freepik assets" >

Browse Freepik Assets

setTerm(event.target.value)} className="pl-9" autoFocus />
setAssetType(value as AssetType)}> Photos Vectors Icons
event.stopPropagation()} > {isLoading ? (
{Array.from({ length: 16 }).map((_, index) => (
))}
) : errorMessage ? (

Search failed

{errorMessage}

) : results.length === 0 ? (

{term.trim() ? "No results found" : "Type to search Freepik assets"}

) : ( <>
{results.map((asset) => { const assetKey = `${asset.assetType}-${asset.id}`; const isSelectingThisAsset = selectingAssetKey === assetKey; return ( ); })}
)}
{results.length > 0 ? (
Page {page} of {totalPages}
) : null}

Assets by{" "} Freepik . Freemium assets require attribution.

{results.length > 0 ? `${results.length} results on this page` : ""}
), [ assetType, errorMessage, handleNextPage, handlePreviousPage, handleSelect, debouncedTerm, isLoading, isSelecting, onClose, page, results, runSearch, selectingAssetKey, term, totalPages, ], ); return createPortal(modal, document.body); }