"use client"; import { Loader2, Star } from "@/components/icons/platform-icons"; import { cn } from "@/lib/utils"; import { buildAssetUrl } from "@/modules/files/hooks/files-api"; import { getFileIcon, getLargeFileIcon, type FileEntry, } from "@/modules/files/components/file-manager-presenters"; import React, { useEffect, useRef, useState } from "react"; import type { MouseEvent } from "react"; const IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp"]); const VIRTUAL_BATCH = 100; function useVirtualLimit(total: number): [number, React.RefObject] { const [limit, setLimit] = useState(VIRTUAL_BATCH); const sentinelRef = useRef(null); useEffect(() => { setLimit(VIRTUAL_BATCH); }, [total]); useEffect(() => { if (limit >= total) return; const el = sentinelRef.current; if (!el) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) { setLimit((prev) => Math.min(prev + VIRTUAL_BATCH, total)); } }, { rootMargin: "200px" }, ); observer.observe(el); return () => observer.disconnect(); }, [limit, total]); return [limit, sentinelRef]; } export function LoadMoreBanner({ shown, total, onLoadMore, }: { shown: number; total: number; onLoadMore: () => void; }) { return (
Showing {shown} of {total} results
); } function GridThumbnail({ entry }: { entry: FileEntry }) { const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading"); const src = buildAssetUrl(entry.path); return (
{getLargeFileIcon(entry)}
{/* eslint-disable-next-line @next/next/no-img-element */} {entry.name} setStatus("loaded")} onError={() => setStatus("error")} className={cn( "size-14 rounded-lg object-cover transition-opacity duration-150", status === "loaded" ? "opacity-100" : "opacity-0", )} loading="lazy" />
); } export function FileGrid({ entries, pendingEntryPath, selectedFiles, titleFromPath, onEntryClick, onEntryContextMenu, onOpenEntry, }: { entries: FileEntry[]; pendingEntryPath: string | null; selectedFiles: Set; titleFromPath: boolean; onEntryClick: (event: MouseEvent, entry: FileEntry) => void; onEntryContextMenu: (event: MouseEvent, entry: FileEntry) => void; onOpenEntry: (entry: FileEntry) => void; }) { const [limit, sentinelRef] = useVirtualLimit(entries.length); const visible = entries.slice(0, limit); return ( <>
{visible.map((entry) => { const isImage = entry.type === "file" && IMAGE_EXTS.has((entry.ext ?? "").toLowerCase()); const isSelected = selectedFiles.has(entry.name); return ( ); })}
{limit < entries.length && (
)} ); } export function FileList({ entries, isGlobalSearchActive, isTrashView, pendingEntryPath, selectedFiles, onEntryClick, onEntryContextMenu, onOpenEntry, }: { entries: FileEntry[]; isGlobalSearchActive: boolean; isTrashView: boolean; pendingEntryPath: string | null; selectedFiles: Set; onEntryClick: (event: MouseEvent, entry: FileEntry) => void; onEntryContextMenu: (event: MouseEvent, entry: FileEntry) => void; onOpenEntry: (entry: FileEntry) => void; }) { const [limit, sentinelRef] = useVirtualLimit(entries.length); const visible = entries.slice(0, limit); return (
Name {isTrashView && Original Location} {isGlobalSearchActive && Location} Size {isTrashView ? "Deleted" : "Modified"}
{visible.map((entry) => ( ))} {limit < entries.length && (
)}
); }