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
|
// 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)
|
os.MkdirAll(tempDir, 0755)
|
||||||
tempPath := filepath.Join(tempDir, uploadID)
|
tempPath := filepath.Join(tempDir, uploadID)
|
||||||
|
|
||||||
|
|
@ -196,7 +196,7 @@ func (h *TusHandler) Patch(c *fiber.Ctx) error {
|
||||||
|
|
||||||
// Check if upload is complete
|
// Check if upload is complete
|
||||||
if upload.Offset >= upload.FileSize {
|
if upload.Offset >= upload.FileSize {
|
||||||
go h.finalizeUpload(upload, c.IP())
|
h.finalizeUpload(upload, c.IP())
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.SendStatus(fiber.StatusNoContent)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
|
|
||||||
|
|
@ -88,11 +88,16 @@ func main() {
|
||||||
Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path}\n",
|
Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path}\n",
|
||||||
TimeFormat: "2006-01-02 15:04:05",
|
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(helmet.New())
|
||||||
app.Use(cors.New(cors.Config{
|
app.Use(cors.New(cors.Config{
|
||||||
AllowOrigins: "*",
|
AllowOrigins: "*",
|
||||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
AllowHeaders: "Origin, Content-Type, Accept, Authorization, Upload-Length, Upload-Offset, Upload-Metadata, Tus-Resumable",
|
||||||
AllowMethods: "GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
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) ===
|
// === PUBLIC ROUTES (no auth) ===
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ FROM node:22-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,21 @@ export default function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
||||||
<head>
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
<link
|
<link
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,19 @@ export default function Home() {
|
||||||
|
|
||||||
if (api.isAuthenticated()) {
|
if (api.isAuthenticated()) {
|
||||||
setState('app');
|
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 {
|
} else {
|
||||||
setState('login');
|
setState('login');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,14 @@ interface FileExplorerProps {
|
||||||
type ViewMode = 'grid' | 'list';
|
type ViewMode = 'grid' | 'list';
|
||||||
type SidebarSection = 'files' | 'pinned' | 'trash' | 'storage' | 'settings';
|
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) {
|
export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const [currentPath, setCurrentPath] = useState('.');
|
const [currentPath, setCurrentPath] = useState('.');
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
const [files, setFiles] = useState<FileItem[]>([]);
|
||||||
|
|
@ -52,13 +60,29 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const [previewFile, setPreviewFile] = useState<FileItem | null>(null);
|
const [previewFile, setPreviewFile] = useState<FileItem | null>(null);
|
||||||
const [showInfo, setShowInfo] = useState(false);
|
const [showInfo, setShowInfo] = useState(false);
|
||||||
const [sharingFile, setSharingFile] = useState<string | null>(null);
|
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 searchTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Load files on path change
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (activeSection === 'files') {
|
if (activeSection === 'files') {
|
||||||
loadFiles();
|
loadFiles(currentPath);
|
||||||
}
|
}
|
||||||
}, [currentPath, activeSection]);
|
}, [currentPath, activeSection]);
|
||||||
|
|
||||||
|
|
@ -96,18 +120,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
return () => window.removeEventListener('click', handleClick);
|
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() {
|
async function loadPinned() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -176,10 +188,10 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} else if (file.is_dir) {
|
|
||||||
navigateToFolder(file);
|
|
||||||
} else {
|
} 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);
|
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) {
|
async function handleDrop(e: React.DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOver(false);
|
setDragOver(false);
|
||||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||||
for (const file of droppedFiles) {
|
await Promise.all(droppedFiles.map(file => uploadFile(file)));
|
||||||
await api.upload(file, currentPath);
|
|
||||||
}
|
|
||||||
loadFiles();
|
loadFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const uploadFiles = e.target.files;
|
const uploadFiles = e.target.files;
|
||||||
if (!uploadFiles) return;
|
if (!uploadFiles) return;
|
||||||
for (const file of Array.from(uploadFiles)) {
|
await Promise.all(Array.from(uploadFiles).map(file => uploadFile(file)));
|
||||||
await api.upload(file, currentPath);
|
|
||||||
}
|
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
loadFiles();
|
loadFiles();
|
||||||
}
|
}
|
||||||
|
|
@ -298,8 +324,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFileIcon(file: FileItem) {
|
function getFileIcon(file: FileItem, large = false) {
|
||||||
if (file.is_dir) return <Folder className="w-5 h-5" style={{ color: 'var(--color-accent)' }} />;
|
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 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/');
|
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 (['mp3', 'wav', 'flac', 'ogg', 'm4a'].includes(ext)) color = '#22c55e';
|
||||||
else if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) color = '#8b5cf6';
|
else if (['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) color = '#8b5cf6';
|
||||||
else if (['js', 'ts', 'py', 'go', 'rs', 'md', 'txt'].includes(ext)) color = '#3b82f6';
|
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') {
|
if (isMedia && file.checksum && viewMode === 'grid') {
|
||||||
|
|
@ -335,7 +362,21 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
return fallbackIcon;
|
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 (
|
return (
|
||||||
<div className="flex h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
<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="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
|
<button
|
||||||
id="view-toggle"
|
id="view-toggle"
|
||||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||||
|
|
@ -665,19 +720,35 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
transition={{ delay: idx * 0.02 }}
|
transition={{ delay: idx * 0.02 }}
|
||||||
onClick={(e) => handleFileClick(file, e)}
|
onClick={(e) => handleFileClick(file, e)}
|
||||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
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"
|
className="group relative p-4 rounded-xl cursor-pointer transition-all duration-150"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
|
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)'}`,
|
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 && (
|
{file.is_pinned && (
|
||||||
<Pin className="absolute top-2 right-2 w-3 h-3" style={{ color: 'var(--color-warning)' }} />
|
<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="flex flex-col items-center gap-3">
|
||||||
<div className="w-16 h-16 flex items-center justify-center">
|
<div className="w-16 h-16 flex items-center justify-center">
|
||||||
{getFileIcon(file)}
|
{getFileIcon(file, true)}
|
||||||
</div>
|
</div>
|
||||||
{renaming === file.path ? (
|
{renaming === file.path ? (
|
||||||
<input
|
<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); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
|
||||||
onBlur={() => handleRename(file.path)}
|
onBlur={() => handleRename(file.path)}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
className="w-full text-xs text-center bg-transparent outline-none"
|
className="w-full text-xs text-center bg-transparent outline-none"
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--color-text-primary)',
|
color: 'var(--color-text-primary)',
|
||||||
|
|
@ -725,19 +797,35 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
transition={{ delay: idx * 0.02 }}
|
transition={{ delay: idx * 0.02 }}
|
||||||
onClick={(e) => handleFileClick(file, e)}
|
onClick={(e) => handleFileClick(file, e)}
|
||||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
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"
|
className="group relative p-4 rounded-xl cursor-pointer transition-all duration-150"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
|
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)'}`,
|
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 && (
|
{file.is_pinned && (
|
||||||
<Pin className="absolute top-2 right-2 w-3 h-3" style={{ color: 'var(--color-warning)' }} />
|
<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="flex flex-col items-center gap-3">
|
||||||
<div className="w-16 h-16 flex items-center justify-center">
|
<div className="w-16 h-16 flex items-center justify-center">
|
||||||
{getFileIcon(file)}
|
{getFileIcon(file, true)}
|
||||||
</div>
|
</div>
|
||||||
{renaming === file.path ? (
|
{renaming === file.path ? (
|
||||||
<input
|
<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); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
|
||||||
onBlur={() => handleRename(file.path)}
|
onBlur={() => handleRename(file.path)}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
className="w-full text-xs text-center bg-transparent outline-none"
|
className="w-full text-xs text-center bg-transparent outline-none"
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--color-text-primary)',
|
color: 'var(--color-text-primary)',
|
||||||
|
|
@ -786,11 +875,23 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
transition={{ delay: idx * 0.015 }}
|
transition={{ delay: idx * 0.015 }}
|
||||||
onClick={(e) => handleFileClick(file, e)}
|
onClick={(e) => handleFileClick(file, e)}
|
||||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
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 group"
|
||||||
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"
|
|
||||||
style={{ backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'transparent' }}
|
style={{ backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'transparent' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<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)}
|
{getFileIcon(file)}
|
||||||
{renaming === file.path ? (
|
{renaming === file.path ? (
|
||||||
<input
|
<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); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
|
||||||
onBlur={() => handleRename(file.path)}
|
onBlur={() => handleRename(file.path)}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
className="flex-1 text-sm bg-transparent outline-none"
|
className="flex-1 text-sm bg-transparent outline-none"
|
||||||
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
|
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 }}
|
transition={{ delay: idx * 0.015 }}
|
||||||
onClick={(e) => handleFileClick(file, e)}
|
onClick={(e) => handleFileClick(file, e)}
|
||||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
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 group"
|
||||||
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"
|
|
||||||
style={{ backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'transparent' }}
|
style={{ backgroundColor: selectedFiles.has(file.path) ? 'var(--color-accent-subtle)' : 'transparent' }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<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)}
|
{getFileIcon(file)}
|
||||||
{renaming === file.path ? (
|
{renaming === file.path ? (
|
||||||
<input
|
<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); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(file.path); if (e.key === 'Escape') setRenaming(null); }}
|
||||||
onBlur={() => handleRename(file.path)}
|
onBlur={() => handleRename(file.path)}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
className="flex-1 text-sm bg-transparent outline-none"
|
className="flex-1 text-sm bg-transparent outline-none"
|
||||||
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
|
style={{ color: 'var(--color-text-primary)', borderBottom: '1px solid var(--color-accent)' }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1038,6 +1153,87 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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 />
|
<TaskManager />
|
||||||
<FilePreview file={previewFile} onClose={() => setPreviewFile(null)} />
|
<FilePreview file={previewFile} onClose={() => setPreviewFile(null)} />
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,14 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
|
||||||
</div>
|
</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') && (
|
{(previewType === 'text' || previewType === 'code') && (
|
||||||
<div className="absolute inset-0 p-4">
|
<div className="absolute inset-0 p-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
@ -200,11 +208,12 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- 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 {
|
function getPreviewType(file: { name: string; mime_type: string }): PreviewType {
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
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 (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image';
|
||||||
if (['mp4', 'webm', 'ogg'].includes(ext)) return 'video'; // Only browser-supported videos
|
if (['mp4', 'webm', 'ogg'].includes(ext)) return 'video'; // Only browser-supported videos
|
||||||
if (['mp3', 'wav', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio';
|
if (['mp3', 'wav', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio';
|
||||||
|
|
|
||||||
|
|
@ -24,16 +24,33 @@ export default function SettingsPage() {
|
||||||
loadSettings();
|
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() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
const data = await api.getSettings();
|
const data = await api.getSettings();
|
||||||
setSettings({
|
const s = data.settings || data;
|
||||||
theme: data.theme || 'dark',
|
const loaded = {
|
||||||
thumbnail_images: data.thumbnail_images || 'true',
|
theme: s.theme || 'dark',
|
||||||
thumbnail_videos: data.thumbnail_videos || 'true',
|
thumbnail_images: s.thumbnail_images || 'true',
|
||||||
max_versions: data.max_versions || '1',
|
thumbnail_videos: s.thumbnail_videos || 'true',
|
||||||
trash_auto_purge_days: data.trash_auto_purge_days || '14',
|
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) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to load settings');
|
setError(err.message || 'Failed to load settings');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -51,17 +68,7 @@ export default function SettingsPage() {
|
||||||
await api.updateSettings(settings);
|
await api.updateSettings(settings);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setTimeout(() => setSuccess(false), 3000);
|
setTimeout(() => setSuccess(false), 3000);
|
||||||
|
applyTheme(settings.theme);
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to save settings');
|
setError(err.message || 'Failed to save settings');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -139,7 +139,7 @@ class ApiClient {
|
||||||
|
|
||||||
// --- Files ---
|
// --- Files ---
|
||||||
async listDirectory(path: string = '.') {
|
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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,10 +182,11 @@ class ApiClient {
|
||||||
return res.json();
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const upload = new tus.Upload(file, {
|
const upload = new tus.Upload(file, {
|
||||||
endpoint: `${API_BASE}/api/tus/`,
|
endpoint: `${API_BASE}/api/tus/`,
|
||||||
|
chunkSize: 5 * 1024 * 1024, // 5MB chunks bypass Next.js 10MB proxy limit
|
||||||
retryDelays: [0, 3000, 5000, 10000, 20000],
|
retryDelays: [0, 3000, 5000, 10000, 20000],
|
||||||
headers: this.getStreamHeaders(),
|
headers: this.getStreamHeaders(),
|
||||||
metadata: {
|
metadata: {
|
||||||
|
|
@ -197,8 +198,8 @@ class ApiClient {
|
||||||
reject(error);
|
reject(error);
|
||||||
},
|
},
|
||||||
onProgress: function (bytesUploaded, bytesTotal) {
|
onProgress: function (bytesUploaded, bytesTotal) {
|
||||||
const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2);
|
const percentage = (bytesUploaded / bytesTotal) * 100;
|
||||||
console.log(bytesUploaded, bytesTotal, percentage + '%');
|
callbacks?.onProgress?.(percentage);
|
||||||
},
|
},
|
||||||
onSuccess: function () {
|
onSuccess: function () {
|
||||||
resolve();
|
resolve();
|
||||||
|
|
@ -209,8 +210,8 @@ class ApiClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
getDownloadUrl(path: string) {
|
getDownloadUrl(path: string) {
|
||||||
const url = `${API_BASE}/api/files/download/${encodeURIComponent(path)}`;
|
const url = `${API_BASE}/api/files/download/?path=${encodeURIComponent(path)}`;
|
||||||
return this.accessToken ? `${url}?token=${this.accessToken}` : url;
|
return this.accessToken ? `${url}&token=${this.accessToken}` : url;
|
||||||
}
|
}
|
||||||
|
|
||||||
getThumbnailUrl(checksum: string) {
|
getThumbnailUrl(checksum: string) {
|
||||||
|
|
|
||||||
Reference in a new issue