SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
4 h agolast push
JavaScript 56.7% TypeScript 42.6%
8.4 KB · 266 lines tsx
Raw Blame History
1"use client";23import { useState, useEffect, useMemo } from "react";4import { File, Folder, Download, Eye, ChevronRight, ChevronDown, Archive } from "lucide-react";5import type { PreviewPluginProps } from "./types";6import { formatFileSize } from "@/lib/utils";78interface ArchiveEntry {9  name: string;10  path: string;11  size: number;12  compressedSize: number;13  isDirectory: boolean;14  date: Date | null;15}1617interface TreeNode {18  name: string;19  path: string;20  isDirectory: boolean;21  size: number;22  date: Date | null;23  children: TreeNode[];24}2526export default function ArchivePreview({ file }: PreviewPluginProps) {27  const [entries, setEntries] = useState<ArchiveEntry[]>([]);28  const [loading, setLoading] = useState(true);29  const [error, setError] = useState<string | null>(null);30  const [previewContent, setPreviewContent] = useState<string | null>(null);31  const [previewName, setPreviewName] = useState("");32  // eslint-disable-next-line @typescript-eslint/no-explicit-any33  const [zipInstance, setZipInstance] = useState<any>(null);3435  useEffect(() => {36    const loadArchive = async () => {37      try {38        const JSZip = (await import("jszip")).default;39        const response = await fetch(`/api/files/${file.id}/preview`);40        const buffer = await response.arrayBuffer();41        const zip = await JSZip.loadAsync(buffer);42        setZipInstance(zip);4344        const items: ArchiveEntry[] = [];45        zip.forEach((relativePath, zipEntry) => {46          items.push({47            name: zipEntry.name.split("/").filter(Boolean).pop() || zipEntry.name,48            path: relativePath,49            // eslint-disable-next-line @typescript-eslint/no-explicit-any50            size: (zipEntry as any)._data?.uncompressedSize || 0,51            // eslint-disable-next-line @typescript-eslint/no-explicit-any52            compressedSize: (zipEntry as any)._data?.compressedSize || 0,53            isDirectory: zipEntry.dir,54            date: zipEntry.date,55          });56        });5758        setEntries(items);59        setLoading(false);60      } catch {61        setError("Impossible de lire l'archive");62        setLoading(false);63      }64    };6566    loadArchive();67  }, [file.id]);6869  const tree = useMemo(() => {70    const root: TreeNode = { name: "", path: "", isDirectory: true, size: 0, date: null, children: [] };7172    for (const entry of entries) {73      const parts = entry.path.split("/").filter(Boolean);74      let current = root;7576      for (let i = 0; i < parts.length; i++) {77        const isLast = i === parts.length - 1;78        let child = current.children.find((c) => c.name === parts[i]);7980        if (!child) {81          child = {82            name: parts[i],83            path: parts.slice(0, i + 1).join("/"),84            isDirectory: isLast ? entry.isDirectory : true,85            size: isLast ? entry.size : 0,86            date: isLast ? entry.date : null,87            children: [],88          };89          current.children.push(child);90        }9192        current = child;93      }94    }9596    // Sort: directories first, then by name97    const sortTree = (node: TreeNode) => {98      node.children.sort((a, b) => {99        if (a.isDirectory && !b.isDirectory) return -1;100        if (!a.isDirectory && b.isDirectory) return 1;101        return a.name.localeCompare(b.name);102      });103      node.children.forEach(sortTree);104    };105    sortTree(root);106107    return root;108  }, [entries]);109110  const handlePreviewFile = async (path: string, name: string) => {111    if (!zipInstance) return;112    try {113      const content = await zipInstance.file(path)?.async("string");114      setPreviewContent(content || "");115      setPreviewName(name);116    } catch {117      setPreviewContent("Impossible de lire ce fichier");118      setPreviewName(name);119    }120  };121122  const handleDownloadFile = async (path: string, name: string) => {123    if (!zipInstance) return;124    try {125      const blob = await zipInstance.file(path)?.async("blob");126      if (!blob) return;127      const url = URL.createObjectURL(blob);128      const a = document.createElement("a");129      a.href = url;130      a.download = name;131      a.click();132      URL.revokeObjectURL(url);133    } catch {134      // ignore135    }136  };137138  if (loading) {139    return (140      <div className="flex items-center justify-center h-full">141        <div className="w-6 h-6 border-2 border-white/20 border-t-white rounded-full animate-spin" />142      </div>143    );144  }145146  if (error) {147    return (148      <div className="flex flex-col items-center justify-center h-full text-gray-400">149        <Archive className="w-12 h-12 mb-4 text-gray-600" />150        <p>{error}</p>151      </div>152    );153  }154155  const fileCount = entries.filter((e) => !e.isDirectory).length;156  const totalSize = entries.reduce((sum, e) => sum + e.size, 0);157158  return (159    <div className="flex flex-col h-full">160      {/* Stats */}161      <div className="flex items-center gap-3 px-3 py-2 border-b border-white/10 bg-white/5 text-xs text-gray-400">162        <span>{fileCount} fichiers</span>163        <span>{formatFileSize(totalSize)} total</span>164      </div>165166      <div className="flex flex-1 overflow-hidden">167        {/* Tree */}168        <div className="flex-1 overflow-auto p-3">169          {tree.children.map((node) => (170            <TreeNodeView171              key={node.path}172              node={node}173              onPreview={handlePreviewFile}174              onDownload={handleDownloadFile}175            />176          ))}177        </div>178179        {/* Preview panel */}180        {previewContent !== null && (181          <div className="w-1/2 border-l border-white/10 flex flex-col">182            <div className="flex items-center justify-between px-3 py-2 border-b border-white/10 bg-white/5">183              <span className="text-xs text-gray-400 truncate">{previewName}</span>184              <button185                onClick={() => setPreviewContent(null)}186                className="text-xs text-gray-500 hover:text-white"187              >188                Fermer189              </button>190            </div>191            <pre className="flex-1 overflow-auto p-3 text-xs text-gray-300 font-mono whitespace-pre-wrap">192              {previewContent}193            </pre>194          </div>195        )}196      </div>197    </div>198  );199}200201function TreeNodeView({202  node,203  onPreview,204  onDownload,205}: {206  node: TreeNode;207  onPreview: (path: string, name: string) => void;208  onDownload: (path: string, name: string) => void;209}) {210  const [expanded, setExpanded] = useState(true);211212  const isTextFile = (name: string) => {213    const ext = name.split(".").pop()?.toLowerCase() || "";214    return ["txt", "md", "json", "xml", "csv", "js", "ts", "jsx", "tsx", "html", "css", "py", "rb", "go", "rs", "java", "c", "cpp", "h", "yml", "yaml", "toml", "sh", "env", "log", "sql", "conf", "cfg"].includes(ext);215  };216217  if (node.isDirectory) {218    return (219      <div>220        <button221          onClick={() => setExpanded(!expanded)}222          className="flex items-center gap-1.5 w-full px-1 py-0.5 text-sm text-gray-300 hover:text-white hover:bg-white/5 rounded transition-colors"223        >224          {expanded ? <ChevronDown className="w-3 h-3 text-gray-500" /> : <ChevronRight className="w-3 h-3 text-gray-500" />}225          <Folder className="w-4 h-4 text-yellow-500/70" />226          <span>{node.name}</span>227        </button>228        {expanded && (229          <div className="ml-4">230            {node.children.map((child) => (231              <TreeNodeView key={child.path} node={child} onPreview={onPreview} onDownload={onDownload} />232            ))}233          </div>234        )}235      </div>236    );237  }238239  return (240    <div className="flex items-center gap-1.5 group px-1 py-0.5 text-sm text-gray-400 hover:text-white hover:bg-white/5 rounded transition-colors">241      <div className="w-3" />242      <File className="w-4 h-4 text-gray-500" />243      <span className="flex-1 truncate">{node.name}</span>244      <span className="text-xs text-gray-600 mr-2">{formatFileSize(node.size)}</span>245      <div className="hidden group-hover:flex items-center gap-1">246        {isTextFile(node.name) && (247          <button248            onClick={() => onPreview(node.path, node.name)}249            className="p-1 text-gray-500 hover:text-white transition-colors"250            title="Aperçu"251          >252            <Eye className="w-3 h-3" />253          </button>254        )}255        <button256          onClick={() => onDownload(node.path, node.name)}257          className="p-1 text-gray-500 hover:text-white transition-colors"258          title="Télécharger"259        >260          <Download className="w-3 h-3" />261        </button>262      </div>263    </div>264  );265}266