Initial Mobile UI
All checks were successful
Automated Container Build / build-and-push (push) Successful in 40s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 40s
This commit is contained in:
parent
322685e3a3
commit
95a2b3ad0e
2 changed files with 171 additions and 62 deletions
|
|
@ -8,10 +8,10 @@ import {
|
||||||
MoreVertical, Star, Download, Pencil, Copy, Move,
|
MoreVertical, Star, Download, Pencil, Copy, Move,
|
||||||
FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon, Image as ImageIcon, Film as FilmIcon,
|
FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon, Image as ImageIcon, Film as FilmIcon,
|
||||||
Sun, Moon, Music as MusicIcon, Code as CodeIcon, Archive as ArchiveIcon, CornerDownRight,
|
Sun, Moon, Music as MusicIcon, Code as CodeIcon, Archive as ArchiveIcon, CornerDownRight,
|
||||||
CheckCircle, AlertCircle
|
CheckCircle, AlertCircle, Menu
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
const Folder = ({ className, style }: { className?: string, style?: React.CSSProperties }) => (
|
const Folder = ({ className, style }: { className?: string, style?: React.CSSProperties }) => (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24" className={className} style={style}>
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="24" height="24" className={className} style={style}>
|
||||||
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
<path d="M10 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z" />
|
||||||
|
|
@ -205,6 +205,11 @@ function ThumbnailImage({ checksum, fallbackIcon, downloadToken }: { checksum: s
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
const [currentPath, setCurrentPath] = useState(() => {
|
const [currentPath, setCurrentPath] = useState(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
return new URLSearchParams(window.location.search).get('path') || '.';
|
return new URLSearchParams(window.location.search).get('path') || '.';
|
||||||
|
|
@ -644,7 +649,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleContextMenu(e: React.MouseEvent, file: FileItem) {
|
function handleContextMenu(e: React.MouseEvent | { preventDefault: () => void, clientX: number, clientY: number }, file: FileItem) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const estimatedHeight = activeSection === 'trash' ? 100 : 320;
|
const estimatedHeight = activeSection === 'trash' ? 100 : 320;
|
||||||
setContextMenu({
|
setContextMenu({
|
||||||
|
|
@ -654,6 +659,43 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const touchTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const getTouchHandlers = (file: FileItem) => ({
|
||||||
|
onTouchStart: (e: React.TouchEvent) => {
|
||||||
|
if (!isMobile) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const startX = touch.clientX;
|
||||||
|
const startY = touch.clientY;
|
||||||
|
|
||||||
|
touchTimerRef.current = setTimeout(() => {
|
||||||
|
handleContextMenu({ preventDefault: () => {}, clientX: startX, clientY: startY }, file);
|
||||||
|
// Provide haptic feedback if available
|
||||||
|
if (typeof window !== 'undefined' && window.navigator && window.navigator.vibrate) {
|
||||||
|
window.navigator.vibrate(50);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
onTouchMove: () => {
|
||||||
|
if (touchTimerRef.current) {
|
||||||
|
clearTimeout(touchTimerRef.current);
|
||||||
|
touchTimerRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTouchEnd: () => {
|
||||||
|
if (touchTimerRef.current) {
|
||||||
|
clearTimeout(touchTimerRef.current);
|
||||||
|
touchTimerRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTouchCancel: () => {
|
||||||
|
if (touchTimerRef.current) {
|
||||||
|
clearTimeout(touchTimerRef.current);
|
||||||
|
touchTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function handleSearch(query: string) {
|
async function handleSearch(query: string) {
|
||||||
setSearchQuery(query);
|
setSearchQuery(query);
|
||||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||||
|
|
@ -1236,9 +1278,21 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
}, [displayFiles, activeSection, trashAutoPurgeDays]);
|
}, [displayFiles, activeSection, trashAutoPurgeDays]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
<div className="flex h-screen w-full overflow-hidden" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||||
|
{isMobile && sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<aside
|
<aside
|
||||||
className="w-60 flex-shrink-0 flex flex-col border-r"
|
className={`w-60 flex-shrink-0 flex flex-col border-r ${
|
||||||
|
isMobile
|
||||||
|
? `fixed inset-y-0 left-0 z-50 transition-transform duration-300 ease-in-out ${
|
||||||
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
}`
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-sidebar-bg)',
|
backgroundColor: 'var(--color-sidebar-bg)',
|
||||||
borderColor: 'var(--color-border-subtle)',
|
borderColor: 'var(--color-border-subtle)',
|
||||||
|
|
@ -1397,7 +1451,17 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
borderColor: 'var(--color-border-subtle)',
|
borderColor: 'var(--color-border-subtle)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{(!isMobile || !mobileSearchOpen) && (
|
||||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="p-1.5 mr-1 rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
|
style={{ color: 'var(--color-text-secondary)' }}
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => { navigateTo('.'); setActiveSection('files'); }}
|
onClick={() => { navigateTo('.'); setActiveSection('files'); }}
|
||||||
className="text-sm font-medium hover:opacity-80 transition-opacity"
|
className="text-sm font-medium hover:opacity-80 transition-opacity"
|
||||||
|
|
@ -1410,7 +1474,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
<ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
|
<ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||||
<button
|
<button
|
||||||
onClick={() => navigateTo(pathSegments.slice(0, i + 1).join('/'))}
|
onClick={() => navigateTo(pathSegments.slice(0, i + 1).join('/'))}
|
||||||
className="text-sm font-medium hover:opacity-80 transition-opacity truncate max-w-[150px]"
|
className={`text-sm font-medium hover:opacity-80 transition-opacity truncate ${isMobile ? 'max-w-[60px]' : 'max-w-[150px]'}`}
|
||||||
style={{
|
style={{
|
||||||
color: i === pathSegments.length - 1 ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
|
color: i === pathSegments.length - 1 ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
|
||||||
}}
|
}}
|
||||||
|
|
@ -1420,9 +1484,12 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeSection !== 'settings' && (
|
{activeSection !== 'settings' && (
|
||||||
<div className="relative w-72">
|
<div className={`relative transition-all ${isMobile && mobileSearchOpen ? 'flex-1' : isMobile ? 'w-auto' : 'w-72'}`}>
|
||||||
|
{!isMobile || mobileSearchOpen ? (
|
||||||
|
<>
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||||
<input
|
<input
|
||||||
id="search-input"
|
id="search-input"
|
||||||
|
|
@ -1430,33 +1497,41 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
placeholder="Search files..."
|
placeholder="Search files..."
|
||||||
className="w-full pl-9 pr-4 py-2 rounded-lg text-sm outline-none transition-all"
|
className="w-full pl-9 pr-8 py-2 rounded-lg text-sm outline-none transition-all"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: 'var(--color-bg-tertiary)',
|
backgroundColor: 'var(--color-bg-tertiary)',
|
||||||
border: '1px solid var(--color-border)',
|
border: '1px solid var(--color-border)',
|
||||||
color: 'var(--color-text-primary)',
|
color: 'var(--color-text-primary)',
|
||||||
}}
|
}}
|
||||||
|
autoFocus={isMobile && mobileSearchOpen}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{(searchQuery || isMobile) && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSearchQuery(''); setSearchResults(null); }}
|
onClick={() => { setSearchQuery(''); setSearchResults(null); if (isMobile) setMobileSearchOpen(false); }}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 hover:bg-black/10 dark:hover:bg-white/10 rounded"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
|
<X className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setMobileSearchOpen(true)} className="p-2 rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5">
|
||||||
|
<Search className="w-4.5 h-4.5" style={{ color: 'var(--color-text-secondary)' }} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
{(!isMobile || !mobileSearchOpen) && (
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2">
|
||||||
{activeSection === 'trash' && (
|
{activeSection === 'trash' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowTrashModal(true)}
|
onClick={() => setShowTrashModal(true)}
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all hover:opacity-90"
|
className={`flex items-center gap-2 ${isMobile ? 'p-2' : 'px-4 py-2'} rounded-lg text-sm font-medium transition-all hover:opacity-90`}
|
||||||
style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: 'var(--color-danger)' }}
|
style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: 'var(--color-danger)' }}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
Empty Trash
|
{!isMobile && "Empty Trash"}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1474,9 +1549,15 @@ 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">
|
<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)' }}>
|
<button className="flex items-center gap-1.5 p-2 rounded-lg transition-colors text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }} title="Sort">
|
||||||
|
{isMobile ? (
|
||||||
|
<ArrowUp className={`w-4.5 h-4.5 ${sortAsc ? '' : 'rotate-180 transition-transform'}`} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
Sort by: <span className="capitalize">{sortBy}</span>
|
Sort by: <span className="capitalize">{sortBy}</span>
|
||||||
{sortAsc ? <ArrowUp className="w-3 h-3" /> : <ArrowUp className="w-3 h-3 rotate-180 transition-transform" />}
|
{sortAsc ? <ArrowUp className="w-3 h-3" /> : <ArrowUp className="w-3 h-3 rotate-180 transition-transform" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="absolute right-0 top-full pt-1 w-40 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all z-50">
|
<div className="absolute right-0 top-full pt-1 w-40 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all z-50">
|
||||||
<div className="w-full rounded-xl shadow-lg border overflow-hidden" style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}>
|
<div className="w-full rounded-xl shadow-lg border overflow-hidden" style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}>
|
||||||
|
|
@ -1516,13 +1597,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
<button
|
<button
|
||||||
id="upload-button"
|
id="upload-button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-all hover:opacity-90"
|
className={`flex items-center gap-2 ${isMobile ? 'p-2' : 'px-4 py-2'} rounded-lg text-sm font-medium text-white transition-all hover:opacity-90`}
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
|
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
|
||||||
}}
|
}}
|
||||||
|
title="Upload"
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
Upload
|
{!isMobile && "Upload"}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|
@ -1536,6 +1618,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{activeSection === 'settings' ? (
|
{activeSection === 'settings' ? (
|
||||||
|
|
@ -1672,6 +1755,7 @@ 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)}
|
||||||
|
{...getTouchHandlers(file)}
|
||||||
className={`group relative p-4 rounded-xl cursor-pointer transition-all duration-150 ${draggedFile === file.path ? 'opacity-50' : ''}`}
|
className={`group relative p-4 rounded-xl cursor-pointer transition-all duration-150 ${draggedFile === file.path ? 'opacity-50' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: selectedFiles.has(file.path) || dragOverFolder === file.path ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
|
backgroundColor: selectedFiles.has(file.path) || dragOverFolder === file.path ? 'var(--color-accent-subtle)' : 'var(--color-bg-elevated)',
|
||||||
|
|
@ -1766,6 +1850,7 @@ 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)}
|
||||||
|
{...getTouchHandlers(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 ${draggedFile === file.path ? 'opacity-50' : ''}`}
|
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 ${draggedFile === file.path ? 'opacity-50' : ''}`}
|
||||||
style={{ backgroundColor: selectedFiles.has(file.path) || dragOverFolder === file.path ? 'var(--color-accent-subtle)' : 'transparent' }}
|
style={{ backgroundColor: selectedFiles.has(file.path) || dragOverFolder === file.path ? 'var(--color-accent-subtle)' : 'transparent' }}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
24
frontend/src/hooks/useIsMobile.ts
Normal file
24
frontend/src/hooks/useIsMobile.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768;
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initial check
|
||||||
|
const checkIsMobile = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIsMobile();
|
||||||
|
|
||||||
|
// Add resize listener
|
||||||
|
window.addEventListener('resize', checkIsMobile);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => window.removeEventListener('resize', checkIsMobile);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
Reference in a new issue