Fix StorageDashboard 500 error and implement interactive folder tree for archive previews
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m10s

This commit is contained in:
Elijah 2026-06-01 10:35:52 -07:00
parent dbd3a868ed
commit e79f2fbbd5
2 changed files with 69 additions and 9 deletions

View file

@ -1152,6 +1152,8 @@ func (h *FSHandler) StorageDashboard(c *fiber.Ctx) error {
"audio": {Category: "audio"},
"documents": {Category: "documents"},
"archives": {Category: "archives"},
"code": {Category: "code"},
"3d_models": {Category: "3d_models"},
"other": {Category: "other"},
}

View file

@ -1,8 +1,8 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Download, File, Image as ImageIcon, Film, Music, FileText, Code, Loader2, Archive } from 'lucide-react';
import { X, Download, File, Image as ImageIcon, Film, Music, FileText, Code, Loader2, Archive, Folder, ChevronLeft } from 'lucide-react';
import api from '@/lib/api';
import dynamic from 'next/dynamic';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
@ -29,9 +29,44 @@ 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 [archivePath, setArchivePath] = useState<string>('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const visibleItems = useMemo(() => {
if (!archiveItems) return [];
const itemsMap = new Map<string, {name: string, isDir: boolean, size: number, path: string}>();
archiveItems.forEach(item => {
// Normalize path (remove leading slash if any)
const normalizedItemPath = item.path.replace(/\\/g, '/').replace(/^\/+/, '');
if (normalizedItemPath.startsWith(archivePath)) {
const relativePath = normalizedItemPath.substring(archivePath.length);
if (relativePath === '') return; // Skip the directory itself if present
const parts = relativePath.split('/');
if (parts.length === 1) {
itemsMap.set(parts[0], { name: parts[0], isDir: false, size: item.size, path: normalizedItemPath });
} else {
const folderName = parts[0];
if (!itemsMap.has(folderName)) {
itemsMap.set(folderName, { name: folderName, isDir: true, size: 0, path: archivePath + folderName + '/' });
}
const folder = itemsMap.get(folderName)!;
folder.size += item.size;
}
}
});
return Array.from(itemsMap.values()).sort((a, b) => {
if (a.isDir && !b.isDir) return -1;
if (!a.isDir && b.isDir) return 1;
return a.name.localeCompare(b.name);
});
}, [archiveItems, archivePath]);
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
@ -209,9 +244,25 @@ export default function FilePreview({ file, onClose, downloadToken }: FilePrevie
) : (
<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>
<div className="flex items-center gap-2">
{archivePath !== '' && (
<button
onClick={() => {
const parts = archivePath.split('/').filter(Boolean);
parts.pop();
setArchivePath(parts.length ? parts.join('/') + '/' : '');
}}
className="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
<ChevronLeft className="w-5 h-5" style={{ color: 'var(--color-text-secondary)' }} />
</button>
)}
<span className="font-semibold" style={{ color: 'var(--color-text-primary)' }}>
{archivePath === '' ? 'Archive Contents' : archivePath}
</span>
</div>
<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
{visibleItems.length} items
</span>
</div>
<div className="flex-1 overflow-auto">
@ -223,10 +274,17 @@ export default function FilePreview({ file, onClose, downloadToken }: FilePrevie
</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: '' }));
{visibleItems.map((item, idx) => {
const iconConfig = item.isDir ? <Folder className="w-5 h-5" style={{ color: '#3b82f6' }} /> : getIcon(getPreviewType({ name: item.name, mime_type: '' }));
return (
<tr key={idx} className="hover:bg-white/5 transition-colors" style={{ color: 'var(--color-text-primary)' }}>
<tr
key={idx}
className="hover:bg-white/5 transition-colors cursor-pointer"
style={{ color: 'var(--color-text-primary)' }}
onClick={() => {
if (item.isDir) setArchivePath(item.path);
}}
>
<td className="px-4 py-2 flex items-center gap-2">
{iconConfig}
<span className="truncate max-w-[60vw]" title={item.name}>{item.name}</span>
@ -237,10 +295,10 @@ export default function FilePreview({ file, onClose, downloadToken }: FilePrevie
</tr>
);
})}
{archiveItems?.length === 0 && (
{visibleItems.length === 0 && (
<tr>
<td colSpan={2} className="px-4 py-8 text-center" style={{ color: 'var(--color-text-tertiary)' }}>
This archive is empty.
This folder is empty.
</td>
</tr>
)}