Add password toggle, hover tooltips, and silent refresh for actions
All checks were successful
Automated Container Build / build-and-push (push) Successful in 37s

This commit is contained in:
Elijah 2026-05-23 09:11:37 -07:00
parent 0ca098e713
commit 807775a176
2 changed files with 29 additions and 73 deletions

View file

@ -297,17 +297,16 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
} }
} }
// Load files on path change const loadFiles = useCallback(async (path?: string, silent: boolean = false) => {
const loadFiles = useCallback(async (path?: string) => {
const targetPath = path ?? currentPath; const targetPath = path ?? currentPath;
setLoading(true); if (!silent) setLoading(true);
try { try {
const data = await api.listDirectory(targetPath); const data = await api.listDirectory(targetPath);
setFiles(data.files || []); setFiles(data.files || []);
} catch { } catch {
setFiles([]); if (!silent) setFiles([]);
} finally { } finally {
setLoading(false); if (!silent) setLoading(false);
} }
}, [currentPath]); }, [currentPath]);
@ -332,10 +331,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
} }
}, [showMoveModal, loadFolderTree]); }, [showMoveModal, loadFolderTree]);
// Keyboard shortcuts
useEffect(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
// Don't trigger shortcuts when typing in inputs
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'Backspace') { if (e.key === 'Backspace') {
@ -357,7 +354,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [files, selectedFiles]); }, [files, selectedFiles]);
// Close context menu on click outside
useEffect(() => { useEffect(() => {
const handleClickOutside = () => { const handleClickOutside = () => {
setContextMenu(null); setContextMenu(null);
@ -367,18 +363,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
return () => window.removeEventListener('click', handleClickOutside); return () => window.removeEventListener('click', handleClickOutside);
}, []); }, []);
async function loadPinned() {
setLoading(true);
try {
const data = await api.listPinned();
setFiles(data.items || []);
} catch {
setFiles([]);
} finally {
setLoading(false);
}
}
async function loadTrash() { async function loadTrash() {
setLoading(true); setLoading(true);
try { try {
@ -409,12 +393,10 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
} }
} }
// Effect to keep pinned list loaded for the sidebar
const [pinnedFiles, setPinnedFiles] = useState<FileItem[]>([]); const [pinnedFiles, setPinnedFiles] = useState<FileItem[]>([]);
useEffect(() => { useEffect(() => {
if (pinnedExpanded) { if (pinnedExpanded) {
api.listPinned().then(data => { api.listPinned().then(data => {
// Sort by pinnedOrder
const items = data.items || []; const items = data.items || [];
items.sort((a: FileItem, b: FileItem) => { items.sort((a: FileItem, b: FileItem) => {
const idxA = pinnedOrder.indexOf(a.path); const idxA = pinnedOrder.indexOf(a.path);
@ -431,7 +413,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
async function handlePinnedDragEnd(sourcePath: string, destPath: string) { async function handlePinnedDragEnd(sourcePath: string, destPath: string) {
const newOrder = [...pinnedOrder]; const newOrder = [...pinnedOrder];
// Ensure all current pinned files are in order array
const currentPaths = pinnedFiles.map(f => f.path); const currentPaths = pinnedFiles.map(f => f.path);
for (const p of currentPaths) { for (const p of currentPaths) {
if (!newOrder.includes(p)) newOrder.push(p); if (!newOrder.includes(p)) newOrder.push(p);
@ -469,7 +450,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
function handleFileClick(file: FileItem, e: React.MouseEvent) { function handleFileClick(file: FileItem, e: React.MouseEvent) {
if (renaming === file.path) return; if (renaming === file.path) return;
if (e.shiftKey) { if (e.shiftKey) {
// Multi-select with shift
setSelectedFiles(prev => { setSelectedFiles(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(file.path)) { if (next.has(file.path)) {
@ -480,7 +460,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
return next; return next;
}); });
} else { } else {
// Single click to open/navigate
if (file.is_dir) navigateToFolder(file); if (file.is_dir) navigateToFolder(file);
else setPreviewFile(file); else setPreviewFile(file);
} }
@ -600,21 +579,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
async function handlePin(file: FileItem) { async function handlePin(file: FileItem) {
await api.togglePin(file.path); await api.togglePin(file.path);
loadFiles(); loadFiles(undefined, true);
if (pinnedExpanded) {
api.listPinned().then(data => {
const items = data.items || [];
items.sort((a: FileItem, b: FileItem) => {
const idxA = pinnedOrder.indexOf(a.path);
const idxB = pinnedOrder.indexOf(b.path);
if (idxA === -1 && idxB === -1) return a.name.localeCompare(b.name);
if (idxA === -1) return 1;
if (idxB === -1) return -1;
return idxA - idxB;
});
setPinnedFiles(items);
}).catch(console.error);
}
} }
async function handleRename(path: string, originalName: string, isDir: boolean) { async function handleRename(path: string, originalName: string, isDir: boolean) {
@ -633,7 +598,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
await api.rename(path, finalName); await api.rename(path, finalName);
setRenaming(null); setRenaming(null);
setNewName(''); setNewName('');
loadFiles(); loadFiles(undefined, true);
} }
async function handleCreateFolder() { async function handleCreateFolder() {
@ -642,7 +607,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
await api.createFolder(folderPath); await api.createFolder(folderPath);
setShowNewFolder(false); setShowNewFolder(false);
setNewFolderName(''); setNewFolderName('');
loadFiles(); loadFiles(undefined, true);
} }
async function handleRestore(file: FileItem) { async function handleRestore(file: FileItem) {
@ -661,14 +626,13 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
await Promise.all(movingFiles.map(src => api.move(src, moveModalPath === '.' ? '.' : moveModalPath))); await Promise.all(movingFiles.map(src => api.move(src, moveModalPath === '.' ? '.' : moveModalPath)));
setShowMoveModal(false); setShowMoveModal(false);
setMovingFiles([]); setMovingFiles([]);
loadFiles(); loadFiles(undefined, true);
setSelectedFiles(new Set()); setSelectedFiles(new Set());
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
} }
// Drag and drop upload
function handleDragOver(e: React.DragEvent) { function handleDragOver(e: React.DragEvent) {
if (draggedFile) return; if (draggedFile) return;
if (!e.dataTransfer.types.includes('Files')) return; if (!e.dataTransfer.types.includes('Files')) return;
@ -711,7 +675,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
setDraggedFile(null); setDraggedFile(null);
try { try {
await api.move(sourcePath, file.path); await api.move(sourcePath, file.path);
loadFiles(); loadFiles(undefined, true);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
@ -741,7 +705,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
if (draggedFile) return; if (draggedFile) return;
const droppedFiles = Array.from(e.dataTransfer.files); const droppedFiles = Array.from(e.dataTransfer.files);
await Promise.all(droppedFiles.map(file => uploadFile(file))); await Promise.all(droppedFiles.map(file => uploadFile(file)));
loadFiles(); loadFiles(undefined, true);
} }
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) { async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
@ -749,10 +713,9 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
if (!uploadFiles) return; if (!uploadFiles) return;
await Promise.all(Array.from(uploadFiles).map(file => uploadFile(file))); await Promise.all(Array.from(uploadFiles).map(file => uploadFile(file)));
e.target.value = ''; e.target.value = '';
loadFiles(); loadFiles(undefined, true);
} }
// Breadcrumb path segments
const pathSegments = currentPath === '.' ? [] : currentPath.split('/'); const pathSegments = currentPath === '.' ? [] : currentPath.split('/');
function formatSize(bytes: number): string { function formatSize(bytes: number): string {
@ -851,7 +814,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
let displayFiles = searchResults !== null ? searchResults : files; let displayFiles = searchResults !== null ? searchResults : files;
displayFiles = displayFiles.filter(f => f.name !== '.uploads').sort((a, b) => { displayFiles = displayFiles.filter(f => f.name !== '.uploads').sort((a, b) => {
// Directories always first regardless of sort type
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
let cmp = 0; let cmp = 0;
@ -921,7 +883,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
return ( return (
<div className="flex h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}> <div className="flex h-screen" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
{/* === SIDEBAR === */}
<aside <aside
className="w-60 flex-shrink-0 flex flex-col border-r" className="w-60 flex-shrink-0 flex flex-col border-r"
style={{ style={{
@ -929,7 +890,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
borderColor: 'var(--color-border-subtle)', borderColor: 'var(--color-border-subtle)',
}} }}
> >
{/* Logo */}
<div className="flex items-center gap-3 px-5 py-5"> <div className="flex items-center gap-3 px-5 py-5">
<div <div
className="w-9 h-9 rounded-xl flex items-center justify-center" className="w-9 h-9 rounded-xl flex items-center justify-center"
@ -944,7 +904,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</span> </span>
</div> </div>
{/* Navigation */}
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto"> <nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
{[ {[
{ id: 'files' as SidebarSection, icon: Folder, label: 'All Files' }, { id: 'files' as SidebarSection, icon: Folder, label: 'All Files' },
@ -964,7 +923,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</button> </button>
))} ))}
{/* Pinned Collapsible Section */}
<div className="py-1"> <div className="py-1">
<button <button
onClick={() => { onClick={() => {
@ -1055,7 +1013,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
))} ))}
</nav> </nav>
{/* Settings Footer */}
<div className="px-3 py-4 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}> <div className="px-3 py-4 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
<button <button
id="nav-settings" id="nav-settings"
@ -1072,14 +1029,12 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</div> </div>
</aside> </aside>
{/* === MAIN CONTENT === */}
<main <main
className="flex-1 flex flex-col overflow-hidden" className="flex-1 flex flex-col overflow-hidden"
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
> >
{/* Top Bar */}
<header <header
className="flex items-center gap-4 px-6 py-3.5 border-b" className="flex items-center gap-4 px-6 py-3.5 border-b"
style={{ style={{
@ -1087,7 +1042,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
borderColor: 'var(--color-border-subtle)', borderColor: 'var(--color-border-subtle)',
}} }}
> >
{/* Breadcrumbs */}
<div className="flex items-center gap-1 flex-1 min-w-0"> <div className="flex items-center gap-1 flex-1 min-w-0">
<button <button
onClick={() => { navigateTo('.'); setActiveSection('files'); }} onClick={() => { navigateTo('.'); setActiveSection('files'); }}
@ -1112,7 +1066,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
))} ))}
</div> </div>
{/* Search Bar */}
{activeSection !== 'settings' && ( {activeSection !== 'settings' && (
<div className="relative w-72"> <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)' }} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
@ -1140,7 +1093,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</div> </div>
)} )}
{/* Action Buttons */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{activeSection === 'trash' && ( {activeSection === 'trash' && (
<button <button
@ -1231,7 +1183,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</div> </div>
</header> </header>
{/* Dynamic Content Area */}
{activeSection === 'settings' ? ( {activeSection === 'settings' ? (
<SettingsPage onLogout={onLogout} /> <SettingsPage onLogout={onLogout} />
) : activeSection === 'storage' ? ( ) : activeSection === 'storage' ? (
@ -1240,7 +1191,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
<SharedFilesPage /> <SharedFilesPage />
) : ( ) : (
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* File Area */}
<div <div
className="flex-1 overflow-y-auto p-6 relative" className="flex-1 overflow-y-auto p-6 relative"
onContextMenu={activeSection === 'files' ? handleBgContextMenu : undefined} onContextMenu={activeSection === 'files' ? handleBgContextMenu : undefined}
@ -1250,7 +1200,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp} onPointerLeave={handlePointerUp}
> >
{/* Lasso selection box */}
{selectionBox && ( {selectionBox && (
<div <div
className="fixed z-50 pointer-events-none" className="fixed z-50 pointer-events-none"
@ -1265,7 +1214,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
/> />
)} )}
{/* Drag and drop overlay */}
<AnimatePresence> <AnimatePresence>
{dragOver && ( {dragOver && (
<motion.div <motion.div
@ -1291,7 +1239,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)} )}
</AnimatePresence> </AnimatePresence>
{/* New Folder Input */}
<AnimatePresence> <AnimatePresence>
{showNewFolder && ( {showNewFolder && (
<motion.div <motion.div
@ -1346,7 +1293,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)} )}
</div> </div>
) : viewMode === 'grid' ? ( ) : viewMode === 'grid' ? (
/* Grid View */
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
{groupedFiles.map((group, groupIdx) => ( {groupedFiles.map((group, groupIdx) => (
@ -1435,7 +1381,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</div> </div>
</div> </div>
) : ( ) : (
/* List View */
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
{groupedFiles.map((group, groupIdx) => ( {groupedFiles.map((group, groupIdx) => (
@ -1518,7 +1463,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)} )}
</div> </div>
{/* Bulk Actions Bar */}
<AnimatePresence> <AnimatePresence>
{selectedFiles.size > 0 && ( {selectedFiles.size > 0 && (
<motion.div <motion.div
@ -1544,6 +1488,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
<button <button
className="p-2 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 text-sm" className="p-2 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-primary)' }} style={{ color: 'var(--color-text-primary)' }}
title="Download Selected"
onClick={() => { onClick={() => {
const form = document.createElement('form'); const form = document.createElement('form');
form.method = 'POST'; form.method = 'POST';
@ -1569,6 +1514,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
<button <button
className="p-2 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 text-sm" className="p-2 hover:bg-white/5 rounded-lg transition-colors flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-primary)' }} style={{ color: 'var(--color-text-primary)' }}
title="Move Selected"
onClick={() => { onClick={() => {
setMovingFiles(Array.from(selectedFiles)); setMovingFiles(Array.from(selectedFiles));
setMoveModalPath('.'); setMoveModalPath('.');
@ -1579,13 +1525,14 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
</button> </button>
<button <button
className="p-2 hover:bg-red-500/10 rounded-lg transition-colors flex items-center gap-2 text-sm text-red-500" className="p-2 hover:bg-red-500/10 rounded-lg transition-colors flex items-center gap-2 text-sm text-red-500"
title="Delete Selected"
onClick={async () => { onClick={async () => {
if (confirm(`Move ${selectedFiles.size} items to trash?`)) { if (confirm(`Move ${selectedFiles.size} items to trash?`)) {
for (const path of Array.from(selectedFiles)) { for (const path of Array.from(selectedFiles)) {
await api.deleteFile(path).catch(() => {}); await api.deleteFile(path).catch(() => {});
} }
setSelectedFiles(new Set()); setSelectedFiles(new Set());
loadFiles(); loadFiles(undefined, true);
} }
}} }}
> >
@ -1597,6 +1544,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
onClick={() => setSelectedFiles(new Set())} onClick={() => setSelectedFiles(new Set())}
className="p-2 hover:bg-white/5 rounded-lg transition-colors" className="p-2 hover:bg-white/5 rounded-lg transition-colors"
style={{ color: 'var(--color-text-tertiary)' }} style={{ color: 'var(--color-text-tertiary)' }}
title="Clear Selection"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@ -1609,7 +1557,6 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)} )}
</main> </main>
{/* === CONTEXT MENU === */}
<AnimatePresence> <AnimatePresence>
{contextMenu && ( {contextMenu && (
<motion.div <motion.div
@ -1692,8 +1639,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
label="Move to Trash" label="Move to Trash"
danger danger
onClick={() => { onClick={() => {
api.deleteFile(contextMenu.file.path).then(() => loadFiles());
setContextMenu(null); setContextMenu(null);
api.deleteFile(contextMenu.file.path).then(() => loadFiles(undefined, true));
}} }}
/> />
</> </>

View file

@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { HardDrive, Lock, User, LogIn, Shield } from 'lucide-react'; import { HardDrive, Lock, User, LogIn, Shield, Eye, EyeOff } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
interface LoginPageProps { interface LoginPageProps {
@ -12,6 +12,7 @@ interface LoginPageProps {
export default function LoginPage({ onSuccess }: LoginPageProps) { export default function LoginPage({ onSuccess }: LoginPageProps) {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [totpCode, setTotpCode] = useState(''); const [totpCode, setTotpCode] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -128,17 +129,25 @@ export default function LoginPage({ onSuccess }: LoginPageProps) {
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4" style={{ color: 'var(--color-text-tertiary)' }} />
<input <input
id="login-password" id="login-password"
type="password" type={showPassword ? "text" : "password"}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Your password" placeholder="Your password"
className="w-full pl-10 pr-4 py-3 rounded-xl text-sm outline-none transition-all" className="w-full pl-10 pr-10 py-3 rounded-xl 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)',
}} }}
/> />
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-lg hover:bg-white/5 transition-colors"
style={{ color: 'var(--color-text-tertiary)' }}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div> </div>
</label> </label>
</> </>