diff --git a/backend/handlers/archives.go b/backend/handlers/archives.go index 3c69856..b00ad47 100644 --- a/backend/handlers/archives.go +++ b/backend/handlers/archives.go @@ -1,6 +1,7 @@ package handlers import ( + "archive/zip" "fmt" "os" "path/filepath" @@ -9,6 +10,7 @@ import ( "git.elijahkuntz.com/Elijah/drive/config" "git.elijahkuntz.com/Elijah/drive/database" "git.elijahkuntz.com/Elijah/drive/workers" + "github.com/bodgit/sevenzip" "github.com/gofiber/fiber/v2" ) @@ -130,4 +132,66 @@ func (h *ArchiveHandler) syncArchiveToDB(relPath string) { `, cleanPath, filepath.Base(cleanPath), info.Size(), info.IsDir()) } +type ArchiveContentItem struct { + Name string `json:"name"` + Size uint64 `json:"size"` + Path string `json:"path"` +} + +// GetArchiveContents lists files within a zip or 7z archive without extracting. +// GET /api/files/archive-contents +func (h *ArchiveHandler) GetArchiveContents(c *fiber.Ctx) error { + relPath := c.Query("path") + if relPath == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "path is required"}) + } + + fullPath, err := resolveSafe(h.Config.StorageDir, relPath) + if err != nil { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": err.Error()}) + } + + ext := strings.ToLower(filepath.Ext(fullPath)) + var items []ArchiveContentItem + + if ext == ".zip" { + r, err := zip.OpenReader(fullPath) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to open zip file"}) + } + defer r.Close() + + for _, f := range r.File { + if f.FileInfo().IsDir() { + continue + } + items = append(items, ArchiveContentItem{ + Name: f.FileInfo().Name(), + Size: f.UncompressedSize64, + Path: f.Name, + }) + } + } else if ext == ".7z" { + r, err := sevenzip.OpenReader(fullPath) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to open 7z file"}) + } + defer r.Close() + + for _, f := range r.File { + if f.FileInfo().IsDir() { + continue + } + items = append(items, ArchiveContentItem{ + Name: f.FileInfo().Name(), + Size: f.UncompressedSize, + Path: f.Name, + }) + } + } else { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "unsupported archive type"}) + } + + return c.JSON(fiber.Map{"items": items}) +} diff --git a/backend/handlers/files.go b/backend/handlers/files.go index 5098a8d..0b6d78c 100644 --- a/backend/handlers/files.go +++ b/backend/handlers/files.go @@ -1255,6 +1255,10 @@ func categorizeFile(ext string) string { return "documents" case ".zip", ".tar", ".gz", ".rar", ".7z", ".bz2": return "archives" + case ".js", ".ts", ".tsx", ".jsx", ".html", ".css", ".json", ".py", ".go", ".java", ".cpp", ".c", ".h", ".hpp", ".cs", ".php", ".rb", ".sh", ".yml", ".yaml", ".xml", ".rs", ".swift", ".kt": + return "code" + case ".stl", ".3mf", ".obj", ".step", ".stp", ".glb", ".gltf", ".blend": + return "3d_models" default: return "other" } diff --git a/backend/main.go b/backend/main.go index 76b6e43..0df2814 100644 --- a/backend/main.go +++ b/backend/main.go @@ -212,6 +212,7 @@ func main() { files.Get("/info/*", fsHandler.GetExtendedFileInfo) files.Get("/thumbnail/:checksum", fsHandler.ServeThumbnail) files.Get("/download-folder", fsHandler.DownloadFolder) + files.Get("/archive-contents", archiveHandler.GetArchiveContents) files.Post("/download-bulk", fsHandler.DownloadBulk) files.Get("/download", fsHandler.Download) files.Get("/download/*", fsHandler.Download) diff --git a/frontend/src/components/FilePreview.tsx b/frontend/src/components/FilePreview.tsx index 249ba7b..4721c11 100644 --- a/frontend/src/components/FilePreview.tsx +++ b/frontend/src/components/FilePreview.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { X, Download, File, Image as ImageIcon, Film, Music, FileText, Code, Loader2 } from 'lucide-react'; +import { X, Download, File, Image as ImageIcon, Film, Music, FileText, Code, Loader2, Archive } from 'lucide-react'; import api from '@/lib/api'; import dynamic from 'next/dynamic'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; @@ -28,6 +28,7 @@ interface FilePreviewProps { export default function FilePreview({ file, onClose, downloadToken }: FilePreviewProps) { const [content, setContent] = useState(null); + const [archiveItems, setArchiveItems] = useState<{name: string, size: number, path: string}[] | null>(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -62,8 +63,18 @@ export default function FilePreview({ file, onClose, downloadToken }: FilePrevie .then((text) => setContent(text)) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); + } else if (type === 'archive') { + setLoading(true); + setError(null); + api.getArchiveContents(file.path) + .then(data => { + if (data.error) throw new Error(data.error); + setArchiveItems(data.items || []); + }) + .catch(err => setError(err.message)) + .finally(() => setLoading(false)); } - }, [file]); + }, [file, downloadToken]); if (!file) return null; @@ -184,6 +195,63 @@ export default function FilePreview({ file, onClose, downloadToken }: FilePrevie ) )} + {previewType === 'archive' && ( +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+ +

{error}

+
+ ) : ( +
+
+ Archive Contents + + {archiveItems?.length || 0} items + +
+
+ + + + + + + + + {archiveItems?.map((item, idx) => { + const iconConfig = getIcon(getPreviewType({ name: item.name, mime_type: '' })); + return ( + + + + + ); + })} + {archiveItems?.length === 0 && ( + + + + )} + +
NameSize
+ {iconConfig} + {item.name} + + {formatSize(item.size)} +
+ This archive is empty. +
+
+
+ )} +
+ )} + {(previewType === 'text' || previewType === 'code') && (
{loading ? ( @@ -258,7 +326,7 @@ export default function FilePreview({ file, onClose, downloadToken }: FilePrevie // --- Helpers --- -type PreviewType = 'image' | 'video' | 'audio' | 'text' | 'code' | 'pdf' | 'stl' | '3mf' | 'unsupported'; +type PreviewType = 'image' | 'video' | 'audio' | 'text' | 'code' | 'pdf' | 'stl' | '3mf' | 'archive' | 'unsupported'; function getPreviewType(file: { name: string; mime_type: string }): PreviewType { const ext = file.name.split('.').pop()?.toLowerCase() || ''; @@ -266,6 +334,7 @@ function getPreviewType(file: { name: string; mime_type: string }): PreviewType if (ext === 'pdf' || file.mime_type === 'application/pdf') return 'pdf'; if (ext === 'stl' || file.mime_type === 'model/stl') return 'stl'; if (ext === '3mf' || file.mime_type === 'model/3mf') return '3mf'; + if (['zip', '7z'].includes(ext)) return 'archive'; if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image'; if (['mp4', 'webm', 'ogg'].includes(ext)) return 'video'; // Only browser-supported videos if (['mp3', 'wav', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio'; @@ -288,6 +357,7 @@ function getIcon(type: PreviewType) { case 'audio': return ; case 'text': return ; case 'code': return ; + case 'archive': return ; default: return ; } } diff --git a/frontend/src/components/StorageDashboard.tsx b/frontend/src/components/StorageDashboard.tsx index 07a6fe8..5d7ff1d 100644 --- a/frontend/src/components/StorageDashboard.tsx +++ b/frontend/src/components/StorageDashboard.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { motion } from 'framer-motion'; -import { HardDrive, Loader2, Image as ImageIcon, Film, Music, FileText, Archive, File } from 'lucide-react'; +import { HardDrive, Loader2, Image as ImageIcon, Film, Music, FileText, Archive, File, Code, Box } from 'lucide-react'; import api from '@/lib/api'; import { formatSize } from '@/lib/utils'; @@ -59,6 +59,8 @@ export default function StorageDashboard() { audio: { label: 'Audio', color: '#22c55e', icon: Music }, documents: { label: 'Documents', color: '#3b82f6', icon: FileText }, archives: { label: 'Archives', color: '#8b5cf6', icon: Archive }, + code: { label: 'Code', color: '#10b981', icon: Code }, + '3d_models': { label: '3D Models', color: '#ec4899', icon: Box }, other: { label: 'Other', color: '#9ca3af', icon: File }, }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4de9a85..3e9206b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -226,6 +226,11 @@ class ApiClient { return res.json(); } + async getArchiveContents(path: string) { + const res = await this.request(`/api/files/archive-contents?path=${encodeURIComponent(path)}`); + return res.json(); + } + async createFolder(path: string) { const res = await this.request('/api/files/mkdir', { method: 'POST',