Misc fixes and additions
Some checks failed
Automated Container Build / build-and-push (push) Failing after 8s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 8s
This commit is contained in:
parent
724d70e58b
commit
02eefdac0e
9 changed files with 311 additions and 65 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) ===
|
||||
|
|
|
|||
|
|
@ -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 . .
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Reference in a new issue