Misc fixes and additions
Some checks failed
Automated Container Build / build-and-push (push) Failing after 8s

This commit is contained in:
Elijah 2026-05-22 14:27:45 -07:00
parent 724d70e58b
commit 02eefdac0e
9 changed files with 311 additions and 65 deletions

View file

@ -90,7 +90,7 @@ func (h *TusHandler) Create(c *fiber.Ctx) error {
}
// Create temp file for the incomplete upload
tempDir := filepath.Join(h.Config.DataDir, "uploads")
tempDir := filepath.Join(h.Config.StorageDir, ".uploads")
os.MkdirAll(tempDir, 0755)
tempPath := filepath.Join(tempDir, uploadID)
@ -196,7 +196,7 @@ func (h *TusHandler) Patch(c *fiber.Ctx) error {
// Check if upload is complete
if upload.Offset >= upload.FileSize {
go h.finalizeUpload(upload, c.IP())
h.finalizeUpload(upload, c.IP())
}
return c.SendStatus(fiber.StatusNoContent)

View file

@ -88,11 +88,16 @@ func main() {
Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path}\n",
TimeFormat: "2006-01-02 15:04:05",
}))
app.Use(func(c *fiber.Ctx) error {
c.Set("Cache-Control", "no-store, no-cache, must-revalidate")
return c.Next()
})
app.Use(helmet.New())
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, PUT, DELETE, PATCH, OPTIONS",
AllowHeaders: "Origin, Content-Type, Accept, Authorization, Upload-Length, Upload-Offset, Upload-Metadata, Tus-Resumable",
AllowMethods: "GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD",
ExposeHeaders: "Location, Upload-Offset, Upload-Length, Tus-Resumable, Tus-Version, Tus-Extension, Tus-Max-Size",
}))
// === PUBLIC ROUTES (no auth) ===

View file

@ -3,8 +3,8 @@ FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY package.json package-lock.json ./
RUN npm ci
COPY . .

View file

@ -14,6 +14,21 @@ export default function RootLayout({
return (
<html lang="en" data-theme="dark" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var theme = localStorage.getItem('theme');
if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
document.documentElement.style.setProperty('color-scheme', 'light');
}
} catch (e) {}
})();
`,
}}
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link

View file

@ -25,6 +25,19 @@ export default function Home() {
if (api.isAuthenticated()) {
setState('app');
// Load and apply saved theme from API
try {
const data = await api.getSettings();
const s = data.settings || data;
const theme = s.theme || 'dark';
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.setProperty('color-scheme', theme);
if (theme === 'light') document.documentElement.classList.remove('dark');
else document.documentElement.classList.add('dark');
localStorage.setItem('theme', theme);
} catch {
// Theme sync is non-critical
}
} else {
setState('login');
}

View file

@ -34,6 +34,14 @@ interface FileExplorerProps {
type ViewMode = 'grid' | 'list';
type SidebarSection = 'files' | 'pinned' | 'trash' | 'storage' | 'settings';
interface UploadProgress {
id: string;
name: string;
progress: number;
status: 'uploading' | 'complete' | 'error';
error?: string;
}
export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [currentPath, setCurrentPath] = useState('.');
const [files, setFiles] = useState<FileItem[]>([]);
@ -52,13 +60,29 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [previewFile, setPreviewFile] = useState<FileItem | null>(null);
const [showInfo, setShowInfo] = useState(false);
const [sharingFile, setSharingFile] = useState<string | null>(null);
const [sortBy, setSortBy] = useState<'name' | 'size' | 'date' | 'type'>('name');
const [sortAsc, setSortAsc] = useState(true);
const [uploads, setUploads] = useState<UploadProgress[]>([]);
const searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
const fileInputRef = useRef<HTMLInputElement>(null);
// Load files on path change
const loadFiles = useCallback(async (path?: string) => {
const targetPath = path ?? currentPath;
setLoading(true);
try {
const data = await api.listDirectory(targetPath);
setFiles(data.files || []);
} catch {
setFiles([]);
} finally {
setLoading(false);
}
}, [currentPath]);
useEffect(() => {
if (activeSection === 'files') {
loadFiles();
loadFiles(currentPath);
}
}, [currentPath, activeSection]);
@ -96,18 +120,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
return () => window.removeEventListener('click', handleClick);
}, []);
async function loadFiles() {
setLoading(true);
try {
const data = await api.listDirectory(currentPath);
setFiles(data.files || []);
} catch {
setFiles([]);
} finally {
setLoading(false);
}
}
async function loadPinned() {
setLoading(true);
try {
@ -176,10 +188,10 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
}
return next;
});
} else if (file.is_dir) {
navigateToFolder(file);
} else {
setSelectedFiles(new Set([file.path]));
// Single click to open/navigate
if (file.is_dir) navigateToFolder(file);
else setPreviewFile(file);
}
}
@ -262,22 +274,36 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
setDragOver(false);
}
async function uploadFile(file: File) {
const id = `${Date.now()}-${file.name}`;
setUploads(prev => [...prev, { id, name: file.name, progress: 0, status: 'uploading' }]);
try {
await api.upload(file, currentPath, {
onProgress: (percent) => {
setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: percent } : u));
},
});
setUploads(prev => prev.map(u => u.id === id ? { ...u, progress: 100, status: 'complete' } : u));
setTimeout(() => {
setUploads(prev => prev.filter(u => u.id !== id));
}, 3000);
} catch (err: any) {
setUploads(prev => prev.map(u => u.id === id ? { ...u, status: 'error', error: err.message || 'Upload failed' } : u));
}
}
async function handleDrop(e: React.DragEvent) {
e.preventDefault();
setDragOver(false);
const droppedFiles = Array.from(e.dataTransfer.files);
for (const file of droppedFiles) {
await api.upload(file, currentPath);
}
await Promise.all(droppedFiles.map(file => uploadFile(file)));
loadFiles();
}
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const uploadFiles = e.target.files;
if (!uploadFiles) return;
for (const file of Array.from(uploadFiles)) {
await api.upload(file, currentPath);
}
await Promise.all(Array.from(uploadFiles).map(file => uploadFile(file)));
e.target.value = '';
loadFiles();
}
@ -298,8 +324,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function getFileIcon(file: FileItem) {
if (file.is_dir) return <Folder className="w-5 h-5" style={{ color: 'var(--color-accent)' }} />;
function getFileIcon(file: FileItem, large = false) {
const sizeClass = large ? "w-12 h-12" : "w-5 h-5";
if (file.is_dir) return <Folder className={sizeClass} style={{ color: 'var(--color-accent)' }} />;
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const isMedia = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'mp4', 'webm', 'ogg'].includes(ext) || file.mime_type.startsWith('image/') || file.mime_type.startsWith('video/');
@ -311,7 +338,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
else if (['mp3', 'wav', 'flac', 'ogg', 'm4a'].includes(ext)) color = '#22c55e';
else if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) color = '#8b5cf6';
else if (['js', 'ts', 'py', 'go', 'rs', 'md', 'txt'].includes(ext)) color = '#3b82f6';
return <File className="w-5 h-5 fallback-icon" style={{ color }} />;
return <File className={`${sizeClass} fallback-icon`} style={{ color }} />;
})();
if (isMedia && file.checksum && viewMode === 'grid') {
@ -335,7 +362,21 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
return fallbackIcon;
}
const displayFiles = searchResults !== null ? searchResults : files;
let displayFiles = searchResults !== null ? searchResults : files;
displayFiles = displayFiles.filter(f => f.name !== '.uploads').sort((a, b) => {
let cmp = 0;
if (sortBy === 'name') cmp = a.name.localeCompare(b.name);
else if (sortBy === 'size') cmp = a.size - b.size;
else if (sortBy === 'date') cmp = new Date(a.mod_time).getTime() - new Date(b.mod_time).getTime();
else if (sortBy === 'type') {
const typeA = a.is_dir ? '0' : (a.mime_type || a.name.split('.').pop() || '');
const typeB = b.is_dir ? '0' : (b.mime_type || b.name.split('.').pop() || '');
cmp = typeA.localeCompare(typeB);
}
// Directories always first if sorting by name
if (sortBy === 'name' && a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
return sortAsc ? cmp : -cmp;
});
return (
<div className="flex h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
@ -512,6 +553,20 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
<div className="w-px h-5 mx-1" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
<div className="relative group">
<button className="flex items-center gap-1.5 p-2 rounded-lg transition-colors text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>
Sort by: <span className="capitalize">{sortBy}</span>
{sortAsc ? <ArrowUp className="w-3 h-3" /> : <ArrowUp className="w-3 h-3 rotate-180 transition-transform" />}
</button>
<div className="absolute right-0 top-full mt-1 w-40 rounded-xl shadow-lg border opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all z-50 overflow-hidden" style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}>
{['name', 'size', 'date', 'type'].map(opt => (
<button key={opt} onClick={() => { if (sortBy === opt) setSortAsc(!sortAsc); else setSortBy(opt as any); }} className="w-full text-left px-4 py-2 text-sm transition-colors hover:bg-white/5 capitalize" style={{ color: sortBy === opt ? 'var(--color-accent)' : 'var(--color-text-primary)' }}>
{opt}
</button>
))}
</div>
</div>
<button
id="view-toggle"
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
@ -665,19 +720,35 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
transition={{ delay: idx * 0.02 }}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => handleContextMenu(e, file)}
onDoubleClick={() => file.is_dir ? navigateToFolder(file) : setPreviewFile(file)}
className="group relative p-4 rounded-xl cursor-pointer transition-all duration-150"
style={{
backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
border: `1px solid ${selectedFiles.has(file.path) ? 'var(--color-accent)' : 'var(--color-border-subtle)'}`,
}}
>
<div
className="absolute top-3 left-3 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: selectedFiles.has(file.path) ? 1 : undefined }}
>
<input
type="checkbox"
checked={selectedFiles.has(file.path)}
onChange={(e) => {
const newSet = new Set(selectedFiles);
if (e.target.checked) newSet.add(file.path);
else newSet.delete(file.path);
setSelectedFiles(newSet);
}}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 cursor-pointer"
/>
</div>
{file.is_pinned && (
<Pin className="absolute top-2 right-2 w-3 h-3" style={{ color: 'var(--color-warning)' }} />
)}
<div className="flex flex-col items-center gap-3">
<div className="w-16 h-16 flex items-center justify-center">
{getFileIcon(file)}
{getFileIcon(file, true)}
</div>
{renaming === file.path ? (
<input
@ -687,6 +758,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path)}
autoFocus
onFocus={(e) => e.target.select()}
className="w-full text-xs text-center bg-transparent outline-none"
style={{
color: 'var(--color-text-primary)',
@ -725,19 +797,35 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
transition={{ delay: idx * 0.02 }}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => handleContextMenu(e, file)}
onDoubleClick={() => file.is_dir ? navigateToFolder(file) : setPreviewFile(file)}
className="group relative p-4 rounded-xl cursor-pointer transition-all duration-150"
style={{
backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
border: `1px solid ${selectedFiles.has(file.path) ? 'var(--color-accent)' : 'var(--color-border-subtle)'}`,
}}
>
<div
className="absolute top-3 left-3 z-10 opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: selectedFiles.has(file.path) ? 1 : undefined }}
>
<input
type="checkbox"
checked={selectedFiles.has(file.path)}
onChange={(e) => {
const newSet = new Set(selectedFiles);
if (e.target.checked) newSet.add(file.path);
else newSet.delete(file.path);
setSelectedFiles(newSet);
}}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 cursor-pointer"
/>
</div>
{file.is_pinned && (
<Pin className="absolute top-2 right-2 w-3 h-3" style={{ color: 'var(--color-warning)' }} />
)}
<div className="flex flex-col items-center gap-3">
<div className="w-16 h-16 flex items-center justify-center">
{getFileIcon(file)}
{getFileIcon(file, true)}
</div>
{renaming === file.path ? (
<input
@ -747,6 +835,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path)}
autoFocus
onFocus={(e) => e.target.select()}
className="w-full text-xs text-center bg-transparent outline-none"
style={{
color: 'var(--color-text-primary)',
@ -786,11 +875,23 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
transition={{ delay: idx * 0.015 }}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => handleContextMenu(e, file)}
onDoubleClick={() => file.is_dir ? navigateToFolder(file) : setPreviewFile(file)}
className="grid grid-cols-[1fr_100px_140px_40px] gap-4 items-center px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-100"
className="grid grid-cols-[1fr_100px_140px_40px] gap-4 items-center px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-100 group"
style={{ backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'transparent' }}
>
<div className="flex items-center gap-3 min-w-0">
<input
type="checkbox"
checked={selectedFiles.has(file.path)}
onChange={(e) => {
const newSet = new Set(selectedFiles);
if (e.target.checked) newSet.add(file.path);
else newSet.delete(file.path);
setSelectedFiles(newSet);
}}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: selectedFiles.has(file.path) ? 1 : undefined }}
/>
{getFileIcon(file)}
{renaming === file.path ? (
<input
@ -800,6 +901,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path)}
autoFocus
onFocus={(e) => e.target.select()}
className="flex-1 text-sm bg-transparent outline-none"
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
/>
@ -835,11 +937,23 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
transition={{ delay: idx * 0.015 }}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => handleContextMenu(e, file)}
onDoubleClick={() => file.is_dir ? navigateToFolder(file) : setPreviewFile(file)}
className="grid grid-cols-[1fr_100px_140px_40px] gap-4 items-center px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-100"
className="grid grid-cols-[1fr_100px_140px_40px] gap-4 items-center px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-100 group"
style={{ backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'transparent' }}
>
<div className="flex items-center gap-3 min-w-0">
<input
type="checkbox"
checked={selectedFiles.has(file.path)}
onChange={(e) => {
const newSet = new Set(selectedFiles);
if (e.target.checked) newSet.add(file.path);
else newSet.delete(file.path);
setSelectedFiles(newSet);
}}
onClick={(e) => e.stopPropagation()}
className="w-4 h-4 rounded border-gray-300 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
style={{ opacity: selectedFiles.has(file.path) ? 1 : undefined }}
/>
{getFileIcon(file)}
{renaming === file.path ? (
<input
@ -849,6 +963,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
onBlur={() => handleRename(file.path)}
autoFocus
onFocus={(e) => e.target.select()}
className="flex-1 text-sm bg-transparent outline-none"
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
/>
@ -1038,6 +1153,87 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)}
</AnimatePresence>
{/* Upload Progress Panel */}
<AnimatePresence>
{uploads.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
className="fixed bottom-6 right-6 z-[110] w-80 rounded-2xl overflow-hidden"
style={{
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-lg)',
}}
>
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{
borderColor: 'var(--color-border-subtle)',
backgroundColor: 'var(--color-bg-secondary)',
}}
>
<div className="flex items-center gap-2">
<Upload className="w-4 h-4" style={{ color: 'var(--color-accent)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>
{uploads.filter(u => u.status === 'uploading').length > 0
? `Uploading ${uploads.filter(u => u.status === 'uploading').length} file${uploads.filter(u => u.status === 'uploading').length > 1 ? 's' : ''}`
: 'Uploads complete'}
</span>
</div>
<button
onClick={() => setUploads(prev => prev.filter(u => u.status === 'uploading'))}
className="p-1 rounded hover:bg-white/10"
>
<X className="w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
</button>
</div>
<div className="max-h-60 overflow-y-auto p-2 space-y-1" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
{uploads.map(upload => (
<div key={upload.id} className="p-3 rounded-xl" style={{ backgroundColor: 'var(--color-bg-elevated)' }}>
<div className="flex items-center justify-between mb-1">
<span
className="text-xs font-medium truncate max-w-[200px]"
style={{ color: 'var(--color-text-primary)' }}
title={upload.name}
>
{upload.name}
</span>
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
{upload.status === 'complete' ? '✓' : upload.status === 'error' ? '✗' : `${Math.round(upload.progress)}%`}
</span>
</div>
<div
className="h-1.5 w-full rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--color-bg-secondary)' }}
>
<motion.div
className="h-full rounded-full"
initial={{ width: 0 }}
animate={{ width: `${upload.progress}%` }}
style={{
backgroundColor:
upload.status === 'error'
? 'var(--color-danger)'
: upload.status === 'complete'
? 'var(--color-success)'
: 'var(--color-accent)',
}}
/>
</div>
{upload.status === 'error' && (
<p className="text-xs mt-1" style={{ color: 'var(--color-danger)' }}>
{upload.error}
</p>
)}
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<TaskManager />
<FilePreview file={previewFile} onClose={() => setPreviewFile(null)} />
<AnimatePresence>

View file

@ -137,6 +137,14 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
</div>
)}
{previewType === 'pdf' && (
<iframe
src={`${downloadUrl}#navpanes=0&view=FitH`}
title={file.name}
className="w-full h-full rounded-lg border-0 bg-white"
/>
)}
{(previewType === 'text' || previewType === 'code') && (
<div className="absolute inset-0 p-4">
{loading ? (
@ -200,11 +208,12 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
// --- Helpers ---
type PreviewType = 'image' | 'video' | 'audio' | 'text' | 'code' | 'unsupported';
type PreviewType = 'image' | 'video' | 'audio' | 'text' | 'code' | 'pdf' | 'unsupported';
function getPreviewType(file: { name: string; mime_type: string }): PreviewType {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
if (ext === 'pdf' || file.mime_type === 'application/pdf') return 'pdf';
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';

View file

@ -24,16 +24,33 @@ export default function SettingsPage() {
loadSettings();
}, []);
function applyTheme(theme: string) {
if (theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
document.documentElement.classList.remove('dark');
document.documentElement.style.setProperty('color-scheme', 'light');
} else {
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.classList.add('dark');
document.documentElement.style.setProperty('color-scheme', 'dark');
}
localStorage.setItem('theme', theme);
}
async function loadSettings() {
try {
const data = await api.getSettings();
setSettings({
theme: data.theme || 'dark',
thumbnail_images: data.thumbnail_images || 'true',
thumbnail_videos: data.thumbnail_videos || 'true',
max_versions: data.max_versions || '1',
trash_auto_purge_days: data.trash_auto_purge_days || '14',
});
const s = data.settings || data;
const loaded = {
theme: s.theme || 'dark',
thumbnail_images: s.thumbnail_images || 'true',
thumbnail_videos: s.thumbnail_videos || 'true',
max_versions: s.max_versions || '1',
trash_auto_purge_days: s.trash_auto_purge_days || '14',
};
setSettings(loaded);
// Apply the persisted theme on load
applyTheme(loaded.theme);
} catch (err: any) {
setError(err.message || 'Failed to load settings');
} finally {
@ -51,17 +68,7 @@ export default function SettingsPage() {
await api.updateSettings(settings);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
// Update local theme variable if changed
if (settings.theme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
document.documentElement.classList.remove('dark');
document.documentElement.style.setProperty('color-scheme', 'light');
} else {
document.documentElement.setAttribute('data-theme', 'dark');
document.documentElement.classList.add('dark');
document.documentElement.style.setProperty('color-scheme', 'dark');
}
applyTheme(settings.theme);
} catch (err: any) {
setError(err.message || 'Failed to save settings');
} finally {

View file

@ -139,7 +139,7 @@ class ApiClient {
// --- Files ---
async listDirectory(path: string = '.') {
const res = await this.request(`/api/files/${encodeURIComponent(path)}`);
const res = await this.request(`/api/files/?path=${encodeURIComponent(path)}`);
return res.json();
}
@ -182,10 +182,11 @@ class ApiClient {
return res.json();
}
upload(file: File, destPath: string): Promise<void> {
upload(file: File, destPath: string, callbacks?: { onProgress?: (percent: number) => void }): Promise<void> {
return new Promise((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: `${API_BASE}/api/tus/`,
chunkSize: 5 * 1024 * 1024, // 5MB chunks bypass Next.js 10MB proxy limit
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: this.getStreamHeaders(),
metadata: {
@ -197,8 +198,8 @@ class ApiClient {
reject(error);
},
onProgress: function (bytesUploaded, bytesTotal) {
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
console.log(bytesUploaded, bytesTotal, percentage + '%');
const percentage = (bytesUploaded / bytesTotal) * 100;
callbacks?.onProgress?.(percentage);
},
onSuccess: function () {
resolve();
@ -209,8 +210,8 @@ class ApiClient {
}
getDownloadUrl(path: string) {
const url = `${API_BASE}/api/files/download/${encodeURIComponent(path)}`;
return this.accessToken ? `${url}?token=${this.accessToken}` : url;
const url = `${API_BASE}/api/files/download/?path=${encodeURIComponent(path)}`;
return this.accessToken ? `${url}&token=${this.accessToken}` : url;
}
getThumbnailUrl(checksum: string) {