"use client"; import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { toast } from "sonner"; import { FileText, Folder, FolderOpen, Loader2, Search, ChevronDown, ChevronRight, Upload, ImagePlus, Images, } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import type { PackageRow } from "./package-columns"; import { SendToTelegramButton } from "./send-to-telegram-button"; import { uploadPackagePreview } from "../actions"; import { ArchivePreviewPicker } from "./archive-preview-picker"; interface FileItem { id: string; path: string; fileName: string; extension: string | null; compressedSize: string; uncompressedSize: string; crc32: string | null; } interface TreeNode { name: string; isFolder: boolean; children: Map; file?: FileItem; } interface PackageFilesDrawerProps { pkg: PackageRow | null; open: boolean; onOpenChange: (open: boolean) => void; } function formatBytes(bytesStr: string): string { const bytes = Number(bytesStr); if (bytes === 0) return "0 B"; const k = 1024; const sizes = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } const EXTENSION_COLORS: Record = { stl: "bg-blue-500/15 text-blue-400 border-blue-500/30", obj: "bg-violet-500/15 text-violet-400 border-violet-500/30", "3mf": "bg-cyan-500/15 text-cyan-400 border-cyan-500/30", gcode: "bg-amber-500/15 text-amber-400 border-amber-500/30", png: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", jpg: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", jpeg: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30", pdf: "bg-red-500/15 text-red-400 border-red-500/30", txt: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30", lys: "bg-pink-500/15 text-pink-400 border-pink-500/30", }; function getExtBadgeClass(ext: string | null): string { if (!ext) return "bg-zinc-500/15 text-zinc-400 border-zinc-500/30"; return EXTENSION_COLORS[ext.toLowerCase()] ?? "bg-zinc-500/15 text-zinc-400 border-zinc-500/30"; } /** * Build a tree structure from flat file paths. */ function buildFileTree(files: FileItem[]): TreeNode { const root: TreeNode = { name: "", isFolder: true, children: new Map() }; for (const file of files) { // Normalize path separators (Windows RAR archives may use backslashes) const parts = file.path.replace(/\\/g, "/").split("/").filter(Boolean); let current = root; for (let i = 0; i < parts.length; i++) { const part = parts[i]; const isLast = i === parts.length - 1; if (!current.children.has(part)) { current.children.set(part, { name: part, isFolder: !isLast, children: new Map(), file: isLast ? file : undefined, }); } current = current.children.get(part)!; } } return root; } /** * Recursively renders a file tree node with indentation. */ function TreeNodeView({ node, depth, search, defaultOpen, }: { node: TreeNode; depth: number; search: string; defaultOpen: boolean; }) { const [open, setOpen] = useState(defaultOpen); // Sort children: folders first, then files, alphabetical within each group const sortedChildren = useMemo(() => { const arr = Array.from(node.children.values()); return arr.sort((a, b) => { if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1; return a.name.localeCompare(b.name); }); }, [node.children]); // If searching, force all open useEffect(() => { if (search) setOpen(true); }, [search]); if (node.isFolder && node.children.size > 0) { return (
{/* Don't render a row for the root node */} {depth >= 0 && ( )} {open && sortedChildren.map((child) => ( ))}
); } // File node if (node.file) { return (
{node.name} {node.file.extension && ( .{node.file.extension} )} {formatBytes(node.file.uncompressedSize)}
); } return null; } function countFiles(node: TreeNode): number { if (!node.isFolder) return 1; let count = 0; for (const child of node.children.values()) { count += countFiles(child); } return count; } const PAGE_SIZE = 100; export function PackageFilesDrawer({ pkg, open, onOpenChange }: PackageFilesDrawerProps) { const [files, setFiles] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [search, setSearch] = useState(""); const [page, setPage] = useState(1); const [uploading, setUploading] = useState(false); const [localPreviewUrl, setLocalPreviewUrl] = useState(null); const [showPreviewPicker, setShowPreviewPicker] = useState(false); const fileInputRef = useRef(null); const handlePreviewUpload = useCallback( async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file || !pkg) return; // Reset file input so the same file can be re-selected e.target.value = ""; setUploading(true); try { const formData = new FormData(); formData.append("file", file); const result = await uploadPackagePreview(pkg.id, formData); if (result.success) { toast.success("Preview image uploaded"); // Show uploaded image immediately via local object URL setLocalPreviewUrl(URL.createObjectURL(file)); } else { toast.error(result.error); } } catch { toast.error("Failed to upload preview image"); } finally { setUploading(false); } }, [pkg] ); // Clean up local preview URL when drawer closes or package changes useEffect(() => { return () => { if (localPreviewUrl) URL.revokeObjectURL(localPreviewUrl); }; }, [localPreviewUrl]); const fetchFiles = useCallback( async (pageNum: number, append: boolean) => { if (!pkg) return; if (pageNum === 1) setLoading(true); else setLoadingMore(true); try { const params = new URLSearchParams({ page: String(pageNum), limit: String(PAGE_SIZE), }); const res = await fetch(`/api/zips/${pkg.id}/files?${params}`); if (!res.ok) throw new Error("fetch failed"); const data = await res.json(); setFiles((prev) => (append ? [...prev, ...data.items] : data.items)); setTotal(data.pagination.total); } catch { // Silently handle } finally { setLoading(false); setLoadingMore(false); } }, [pkg] ); // Reset and fetch when package changes useEffect(() => { if (open && pkg) { setFiles([]); setTotal(0); setSearch(""); setPage(1); setLocalPreviewUrl(null); fetchFiles(1, false); } }, [open, pkg, fetchFiles]); const loadMore = () => { const nextPage = page + 1; setPage(nextPage); fetchFiles(nextPage, true); }; const hasMore = files.length < total; // Client-side search filter (over loaded files) const filtered = search ? files.filter( (f) => f.fileName.toLowerCase().includes(search.toLowerCase()) || f.path.toLowerCase().includes(search.toLowerCase()) ) : files; // Build tree from filtered files const tree = useMemo(() => buildFileTree(filtered), [filtered]); // If all files are in root (no folders), skip the tree and show flat list const hasNesting = useMemo(() => { return filtered.some((f) => f.path.replace(/\\/g, "/").includes("/")); }, [filtered]); return ( <> {/* Preview image + title row */}
{/* Preview image area with upload capability */} {(pkg?.hasPreview || localPreviewUrl) ? ( ) : ( )}
{pkg?.fileName ?? "Package Files"} {total.toLocaleString()} file{total !== 1 ? "s" : ""} in archive {pkg && (
{pkg.archiveType !== "DOCUMENT" && !pkg.isMultipart && ( )}
)}
{/* Search within file list */} {files.length > 0 && (
setSearch(e.target.value)} className="pl-9 h-9" />
)}
{loading ? (
Loading files...
) : filtered.length === 0 ? (
{search ? "No matching files" : "No files indexed"}
) : hasNesting ? ( <> {/* Render as folder tree */} {Array.from(tree.children.values()) .sort((a, b) => { if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1; return a.name.localeCompare(b.name); }) .map((child) => ( ))} ) : ( <> {/* Flat list for archives without folders */} {filtered.map((file) => (

{file.fileName}

{file.extension && ( .{file.extension} )} {formatBytes(file.uncompressedSize)}
))} )} {/* Load more button */} {hasMore && !search && (
)}
{/* Archive preview picker modal — rendered as sibling to avoid nested Dialog issues */} {pkg && pkg.archiveType !== "DOCUMENT" && !pkg.isMultipart && ( { // Refresh the preview by setting a cache-busting URL setLocalPreviewUrl(`/api/zips/${pkg.id}/preview?t=${Date.now()}`); }} /> )} ); }