Initial Mobile UI
All checks were successful
Automated Container Build / build-and-push (push) Successful in 40s

This commit is contained in:
Elijah 2026-05-24 08:48:22 -07:00
parent 322685e3a3
commit 95a2b3ad0e
2 changed files with 171 additions and 62 deletions

View file

@ -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' }}
> >

View 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;
}