Add archive preview and new storage categories
Some checks failed
Automated Container Build / build-and-push (push) Failing after 32s

This commit is contained in:
Elijah 2026-06-01 08:57:01 -07:00
parent a2b634517f
commit 6b7d0de208
6 changed files with 150 additions and 4 deletions

View file

@ -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})
}

View file

@ -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"
}

View file

@ -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)

View file

@ -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<string | null>(null);
const [archiveItems, setArchiveItems] = useState<{name: string, size: number, path: string}[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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' && (
<div className="absolute inset-0 p-4">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-accent)' }} />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-center">
<Archive className="w-12 h-12 mb-2" style={{ color: 'var(--color-danger)' }} />
<p style={{ color: 'var(--color-danger)' }}>{error}</p>
</div>
) : (
<div className="w-full h-full flex flex-col rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--color-bg-secondary)', border: '1px solid var(--color-border-subtle)' }}>
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-tertiary)' }}>
<span className="font-semibold" style={{ color: 'var(--color-text-primary)' }}>Archive Contents</span>
<span className="text-sm px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-bg-elevated)', color: 'var(--color-text-secondary)' }}>
{archiveItems?.length || 0} items
</span>
</div>
<div className="flex-1 overflow-auto">
<table className="w-full text-sm text-left whitespace-nowrap">
<thead className="sticky top-0 shadow-sm" style={{ backgroundColor: 'var(--color-bg-elevated)', color: 'var(--color-text-secondary)' }}>
<tr>
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium w-32 text-right">Size</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-neutral-800">
{archiveItems?.map((item, idx) => {
const iconConfig = getIcon(getPreviewType({ name: item.name, mime_type: '' }));
return (
<tr key={idx} className="hover:bg-white/5 transition-colors" style={{ color: 'var(--color-text-primary)' }}>
<td className="px-4 py-2 flex items-center gap-2">
{iconConfig}
<span className="truncate max-w-[60vw]" title={item.name}>{item.name}</span>
</td>
<td className="px-4 py-2 text-right" style={{ color: 'var(--color-text-secondary)' }}>
{formatSize(item.size)}
</td>
</tr>
);
})}
{archiveItems?.length === 0 && (
<tr>
<td colSpan={2} className="px-4 py-8 text-center" style={{ color: 'var(--color-text-tertiary)' }}>
This archive is empty.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
{(previewType === 'text' || previewType === 'code') && (
<div className="absolute inset-0 p-4">
{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 <Music {...props} />;
case 'text': return <FileText {...props} />;
case 'code': return <Code {...props} />;
case 'archive': return <Archive {...props} />;
default: return <File {...props} />;
}
}

View file

@ -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 },
};

View file

@ -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',