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,
|
||||
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,
|
||||
CheckCircle, AlertCircle
|
||||
CheckCircle, AlertCircle, Menu
|
||||
} from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
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}>
|
||||
<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) {
|
||||
const isMobile = useIsMobile();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
||||
|
||||
|
||||
const [currentPath, setCurrentPath] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
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();
|
||||
const estimatedHeight = activeSection === 'trash' ? 100 : 320;
|
||||
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) {
|
||||
setSearchQuery(query);
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current);
|
||||
|
|
@ -1236,9 +1278,21 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
}, [displayFiles, activeSection, trashAutoPurgeDays]);
|
||||
|
||||
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
|
||||
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={{
|
||||
backgroundColor: 'var(--color-sidebar-bg)',
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
|
|
@ -1397,68 +1451,89 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
borderColor: 'var(--color-border-subtle)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<button
|
||||
onClick={() => { navigateTo('.'); setActiveSection('files'); }}
|
||||
className="text-sm font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
Home
|
||||
</button>
|
||||
{pathSegments.map((seg, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
{(!isMobile || !mobileSearchOpen) && (
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={() => navigateTo(pathSegments.slice(0, i + 1).join('/'))}
|
||||
className="text-sm font-medium hover:opacity-80 transition-opacity truncate max-w-[150px]"
|
||||
style={{
|
||||
color: i === pathSegments.length - 1 ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
|
||||
}}
|
||||
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)' }}
|
||||
>
|
||||
{seg}
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { navigateTo('.'); setActiveSection('files'); }}
|
||||
className="text-sm font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
>
|
||||
Home
|
||||
</button>
|
||||
{pathSegments.map((seg, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<button
|
||||
onClick={() => navigateTo(pathSegments.slice(0, i + 1).join('/'))}
|
||||
className={`text-sm font-medium hover:opacity-80 transition-opacity truncate ${isMobile ? 'max-w-[60px]' : 'max-w-[150px]'}`}
|
||||
style={{
|
||||
color: i === pathSegments.length - 1 ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{seg}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection !== 'settings' && (
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<input
|
||||
id="search-input"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search files..."
|
||||
className="w-full pl-9 pr-4 py-2 rounded-lg text-sm outline-none transition-all"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => { setSearchQuery(''); setSearchResults(null); }}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<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)' }} />
|
||||
<input
|
||||
id="search-input"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder="Search files..."
|
||||
className="w-full pl-9 pr-8 py-2 rounded-lg text-sm outline-none transition-all"
|
||||
style={{
|
||||
backgroundColor: 'var(--color-bg-tertiary)',
|
||||
border: '1px solid var(--color-border)',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
autoFocus={isMobile && mobileSearchOpen}
|
||||
/>
|
||||
{(searchQuery || isMobile) && (
|
||||
<button
|
||||
onClick={() => { setSearchQuery(''); setSearchResults(null); if (isMobile) setMobileSearchOpen(false); }}
|
||||
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)' }} />
|
||||
</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 className="flex items-center gap-2">
|
||||
{activeSection === 'trash' && (
|
||||
<button
|
||||
onClick={() => setShowTrashModal(true)}
|
||||
className="flex items-center gap-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)' }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Empty Trash
|
||||
</button>
|
||||
)}
|
||||
{(!isMobile || !mobileSearchOpen) && (
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
{activeSection === 'trash' && (
|
||||
<button
|
||||
onClick={() => setShowTrashModal(true)}
|
||||
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)' }}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
{!isMobile && "Empty Trash"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(activeSection === 'files' || activeSection === 'trash' || activeSection === 'shared') && (
|
||||
<>
|
||||
|
|
@ -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="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 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>
|
||||
{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 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)' }}>
|
||||
|
|
@ -1516,13 +1597,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
<button
|
||||
id="upload-button"
|
||||
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={{
|
||||
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
|
||||
}}
|
||||
title="Upload"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload
|
||||
{!isMobile && "Upload"}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
|
@ -1536,6 +1618,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{activeSection === 'settings' ? (
|
||||
|
|
@ -1672,6 +1755,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
transition={{ delay: idx * 0.02 }}
|
||||
onClick={(e) => handleFileClick(file, e)}
|
||||
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' : ''}`}
|
||||
style={{
|
||||
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 }}
|
||||
onClick={(e) => handleFileClick(file, e)}
|
||||
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' : ''}`}
|
||||
style={{ backgroundColor: selectedFiles.has(file.path) || dragOverFolder === file.path ? 'var(--color-accent-subtle)' : 'transparent' }}
|
||||
>
|
||||
|
|
|
|||
Reference in a new issue