Add archive preview and new storage categories
Some checks failed
Automated Container Build / build-and-push (push) Failing after 32s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 32s
This commit is contained in:
parent
a2b634517f
commit
6b7d0de208
6 changed files with 150 additions and 4 deletions
|
|
@ -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})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Reference in a new issue