feat: add toast notifications for file actions
All checks were successful
Automated Container Build / build-and-push (push) Successful in 39s

This commit is contained in:
Elijah 2026-05-23 10:47:36 -07:00
parent cf43dd0e5a
commit ee36e16dae

View file

@ -7,7 +7,8 @@ import {
ChevronRight, Grid3X3, List, Plus, Upload, LogOut, ChevronRight, Grid3X3, List, Plus, Upload, LogOut,
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
} from 'lucide-react'; } from 'lucide-react';
import api from '@/lib/api'; import api from '@/lib/api';
@ -105,6 +106,12 @@ interface UploadProgress {
error?: string; error?: string;
} }
interface Toast {
id: number;
message: string;
type?: 'success' | 'error' | 'info';
}
function getFileDisplayName(name: string, isDir: boolean) { function getFileDisplayName(name: string, isDir: boolean) {
if (isDir) return name; if (isDir) return name;
const dotIndex = name.lastIndexOf('.'); const dotIndex = name.lastIndexOf('.');
@ -236,6 +243,15 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
const [sortAsc, setSortAsc] = useState(true); const [sortAsc, setSortAsc] = useState(true);
const [draggedFile, setDraggedFile] = useState<string | null>(null); const [draggedFile, setDraggedFile] = useState<string | null>(null);
const [uploads, setUploads] = useState<UploadProgress[]>([]); const [uploads, setUploads] = useState<UploadProgress[]>([]);
const [toasts, setToasts] = useState<Toast[]>([]);
function showToast(message: string, type: 'success' | 'error' | 'info' = 'success') {
const id = Date.now() + Math.random();
setToasts(prev => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 3000);
}
const [showTrashModal, setShowTrashModal] = useState(false); const [showTrashModal, setShowTrashModal] = useState(false);
const [showMoveModal, setShowMoveModal] = useState(false); const [showMoveModal, setShowMoveModal] = useState(false);
const [movingFiles, setMovingFiles] = useState<string[]>([]); const [movingFiles, setMovingFiles] = useState<string[]>([]);
@ -599,17 +615,21 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
async function deleteSelected() { async function deleteSelected() {
if (selectedFiles.size === 0) return; if (selectedFiles.size === 0) return;
if (!confirm(`Move ${selectedFiles.size} items to trash?`)) return;
const count = selectedFiles.size;
for (const path of selectedFiles) { for (const path of selectedFiles) {
await api.deleteFile(path); await api.deleteFile(path);
} }
setSelectedFiles(new Set()); setSelectedFiles(new Set());
loadFiles(); loadFiles(undefined, true);
showToast(`Deleted ${count} item${count > 1 ? 's' : ''}`);
} }
async function handlePin(file: FileItem) { async function handlePin(file: FileItem) {
await api.togglePin(file.path); await api.togglePin(file.path);
loadFiles(undefined, true); loadFiles(undefined, true);
setPinnedRefreshCounter(c => c + 1); setPinnedRefreshCounter(c => c + 1);
showToast(file.is_pinned ? 'Unpinned item' : 'Pinned item');
} }
async function handleRename(path: string, originalName: string, isDir: boolean) { async function handleRename(path: string, originalName: string, isDir: boolean) {
@ -629,6 +649,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
setRenaming(null); setRenaming(null);
setNewName(''); setNewName('');
loadFiles(undefined, true); loadFiles(undefined, true);
showToast(`Renamed to ${finalName}`);
} }
async function handleCreateFolder() { async function handleCreateFolder() {
@ -643,11 +664,13 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
async function handleRestore(file: FileItem) { async function handleRestore(file: FileItem) {
await api.restoreFromTrash(file.path); await api.restoreFromTrash(file.path);
loadTrash(); loadTrash();
showToast(`Restored ${file.name}`);
} }
async function handleEmptyTrash() { async function handleEmptyTrash() {
await api.emptyTrash(); await api.emptyTrash();
loadTrash(); loadTrash();
showToast('Trash emptied');
} }
async function handleMoveExecute() { async function handleMoveExecute() {
@ -658,6 +681,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
setMovingFiles([]); setMovingFiles([]);
loadFiles(undefined, true); loadFiles(undefined, true);
setSelectedFiles(new Set()); setSelectedFiles(new Set());
showToast(`Moved ${movingFiles.length} item${movingFiles.length > 1 ? 's' : ''}`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
@ -706,6 +730,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
try { try {
await api.move(sourcePath, file.path); await api.move(sourcePath, file.path);
loadFiles(undefined, true); loadFiles(undefined, true);
showToast('Item moved');
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} }
@ -1553,15 +1578,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
<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" title="Delete Selected"
onClick={async () => { onClick={() => deleteSelected()}
if (confirm(`Move ${selectedFiles.size} items to trash?`)) {
for (const path of Array.from(selectedFiles)) {
await api.deleteFile(path).catch(() => {});
}
setSelectedFiles(new Set());
loadFiles(undefined, true);
}
}}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
@ -1676,8 +1693,13 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
label="Move to Trash" label="Move to Trash"
danger danger
onClick={() => { onClick={() => {
if (confirm(`Move ${contextMenu.file.name} to trash?`)) {
api.deleteFile(contextMenu.file.path).then(() => {
loadFiles(undefined, true);
showToast(`Deleted ${contextMenu.file.name}`);
});
}
setContextMenu(null); setContextMenu(null);
api.deleteFile(contextMenu.file.path).then(() => loadFiles(undefined, true));
}} }}
/> />
</> </>
@ -1716,6 +1738,30 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
)} )}
</AnimatePresence> </AnimatePresence>
{/* Toast Notifications */}
<div className="fixed bottom-6 right-6 z-[60] flex flex-col gap-2 pointer-events-none">
<AnimatePresence>
{toasts.map(toast => (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
className="px-4 py-3 rounded-xl shadow-xl flex items-center gap-3 backdrop-blur-md border border-white/10 pointer-events-auto"
style={{
backgroundColor: toast.type === 'error' ? 'rgba(239, 68, 68, 0.9)' : 'var(--color-bg-elevated)',
color: toast.type === 'error' ? 'white' : 'var(--color-text-primary)'
}}
>
{toast.type === 'success' && <CheckCircle className="w-5 h-5 text-green-500" />}
{toast.type === 'error' && <AlertCircle className="w-5 h-5 text-white" />}
{toast.type === 'info' && <Info className="w-5 h-5 text-blue-500" />}
<span className="font-medium text-sm">{toast.message}</span>
</motion.div>
))}
</AnimatePresence>
</div>
{/* Upload Progress Panel */} {/* Upload Progress Panel */}
<AnimatePresence> <AnimatePresence>
{uploads.length > 0 && ( {uploads.length > 0 && (