- {getFileIcon(file)}
+ {getFileIcon(file, true)}
{renaming === file.path ? (
{ 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' }}
>
+
{
+ 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 ? (
{ 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' }}
>
+
{
+ 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 ? (
{ 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) {
)}
+ {/* Upload Progress Panel */}
+
+ {uploads.length > 0 && (
+
+
+
+
+
+ {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'}
+
+
+
+
+
+ {uploads.map(upload => (
+
+
+
+ {upload.name}
+
+
+ {upload.status === 'complete' ? '✓' : upload.status === 'error' ? '✗' : `${Math.round(upload.progress)}%`}
+
+
+
+
+
+ {upload.status === 'error' && (
+
+ {upload.error}
+
+ )}
+
+ ))}
+
+
+ )}
+
+
setPreviewFile(null)} />
diff --git a/frontend/src/components/FilePreview.tsx b/frontend/src/components/FilePreview.tsx
index d8820d7..0d63090 100644
--- a/frontend/src/components/FilePreview.tsx
+++ b/frontend/src/components/FilePreview.tsx
@@ -137,6 +137,14 @@ export default function FilePreview({ file, onClose }: FilePreviewProps) {
)}
+ {previewType === 'pdf' && (
+
+ )}
+
{(previewType === 'text' || previewType === 'code') && (
{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';
diff --git a/frontend/src/components/SettingsPage.tsx b/frontend/src/components/SettingsPage.tsx
index 626d775..cc9ede1 100644
--- a/frontend/src/components/SettingsPage.tsx
+++ b/frontend/src/components/SettingsPage.tsx
@@ -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 {
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 966826d..b1b8594 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -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 {
+ upload(file: File, destPath: string, callbacks?: { onProgress?: (percent: number) => void }): Promise {
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) {