From fb50b448eb5a2ec01d7432c2f8010c7b4f7261c7 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Wed, 24 Sep 2025 08:39:24 +0530 Subject: [PATCH 01/22] notification popup added --- src/components/custom/notification-popup.tsx | 200 +++++++++++++++++++ src/contexts/NotificationContext.tsx | 10 +- src/contexts/NotificationUIContext.tsx | 61 ++++++ src/contexts/fileContext.tsx | 9 - src/providers/AppProviders.tsx | 2 + 5 files changed, 267 insertions(+), 15 deletions(-) create mode 100644 src/components/custom/notification-popup.tsx create mode 100644 src/contexts/NotificationUIContext.tsx diff --git a/src/components/custom/notification-popup.tsx b/src/components/custom/notification-popup.tsx new file mode 100644 index 0000000..9ab1190 --- /dev/null +++ b/src/components/custom/notification-popup.tsx @@ -0,0 +1,200 @@ +"use client" + +import { useEffect, useState } from "react" +import { useNavigate } from "react-router-dom" +import { X, Bell } from "lucide-react" +import { cn } from "@/lib/utils" +import type { NotificationAttributes } from "@/contexts/NotificationContext" + +interface NotificationPopupProps { + notification: NotificationAttributes + onClose?: () => void + duration?: number +} + +export function NotificationPopup({ + notification, + onClose, + duration = 5000 +}: NotificationPopupProps) { + const [isVisible, setIsVisible] = useState(true) + const [isExiting, setIsExiting] = useState(false) + const navigate = useNavigate() + + useEffect(() => { + const timer = setTimeout(() => { + handleClose() + }, duration) + + return () => clearTimeout(timer) + }, [duration]) + + const handleClose = () => { + setIsExiting(true) + setTimeout(() => { + setIsVisible(false) + onClose?.() + }, 300) + } + + const handleClick = () => { + navigate("/notifications") + handleClose() + } + + const getNotificationIcon = (type: string) => { + switch (type) { + case "file_shared": + return "📁" + case "file_updated": + return "📝" + case "file_upload_completed": + return "✅" + case "file_upload_failed": + return "❌" + case "file_deleted": + return "🗑️" + case "storage_quota_warning": + case "storage_quota_exceeded": + return "⚠️" + case "file_commented": + return "💬" + default: + return "🔔" + } + } + + const getNotificationColor = (type: string) => { + switch (type) { + case "file_upload_failed": + case "storage_quota_exceeded": + return "bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800" + case "storage_quota_warning": + return "bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800" + case "file_upload_completed": + case "file_shared": + return "bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800" + default: + return "bg-blue-50 border-blue-200 dark:bg-blue-900/20 dark:border-blue-800" + } + } + + if (!isVisible) return null + + return ( +
+
+ {/* Close button */} + + + {/* Notification content */} +
+ {/* Icon */} +
+
+ {getNotificationIcon(notification.type)} +
+
+ + {/* Content */} +
+
+

+ {notification.title} +

+ {!notification.is_read && ( +
+ )} +
+ +

+ {notification.message} +

+ +
+ + {new Date(notification.created_at).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + })} + + +
+ + Click to view all +
+
+
+
+ + {/* Progress bar */} +
+
+
+
+
+ ) +} + +// Container component to manage multiple notifications +interface NotificationContainerProps { + notifications: NotificationAttributes[] + onRemove: (id: string) => void + maxVisible?: number +} + +export function NotificationContainer({ + notifications, + onRemove, + maxVisible = 3 +}: NotificationContainerProps) { + const visibleNotifications = notifications.slice(0, maxVisible) + + return ( +
+
+ {visibleNotifications.map((notification, index) => ( +
+ onRemove(notification.id)} + duration={5000 + (index * 1000)} // Stagger durations + /> +
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/src/contexts/NotificationContext.tsx b/src/contexts/NotificationContext.tsx index 8f13028..4f7df8f 100644 --- a/src/contexts/NotificationContext.tsx +++ b/src/contexts/NotificationContext.tsx @@ -4,6 +4,7 @@ import notificationApi from '@/api/notification.api'; import { toast } from 'sonner'; import { useAuth } from './useAuth'; import { useSocket } from "@/contexts/SocketContext"; +import { useNotificationUI } from "./NotificationUIContext"; // 🔹 Notification Types export const NotificationType = { @@ -162,6 +163,7 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr const queryClient = useQueryClient(); const { user } = useAuth(); const { socket, initializeSocket } = useSocket(); + const { showNotification } = useNotificationUI(); // Memoized query key const notificationsQueryKey = useMemo(() => ['notifications', { limit: 20, offset: 0 }], []); @@ -177,14 +179,11 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr if (!socket) return; const handleNewNotification = (notification: NotificationAttributes) => { - toast.success(`🔔 ${notification.title}`, { - duration: 4000, - position: 'top-right' - }); + // Show custom notification UI instead of toast + showNotification(notification); // Optimistic UI update first dispatch({ type: "ADD_NOTIFICATION", notification }); - // Show toast // Update query cache efficiently queryClient.setQueryData(notificationsQueryKey, (oldData: any) => { @@ -388,7 +387,6 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr }; }); } catch (error: any) { - console.error('Failed to load more notifications:', error); toast.error('Failed to load more notifications'); } finally { // Ensure spinner hides only after state/cache are updated diff --git a/src/contexts/NotificationUIContext.tsx b/src/contexts/NotificationUIContext.tsx new file mode 100644 index 0000000..0a352c0 --- /dev/null +++ b/src/contexts/NotificationUIContext.tsx @@ -0,0 +1,61 @@ +"use client" + +import { createContext, useContext, useState, useCallback, type ReactNode } from "react" +import { NotificationContainer } from "@/components/custom/notification-popup" +import type { NotificationAttributes } from "./NotificationContext" + +interface NotificationUIContextType { + showNotification: (notification: NotificationAttributes) => void + removeNotification: (id: string) => void + clearAllNotifications: () => void +} + +const NotificationUIContext = createContext(undefined) + +export function NotificationUIProvider({ children }: { children: ReactNode }) { + const [activeNotifications, setActiveNotifications] = useState([]) + + const showNotification = useCallback((notification: NotificationAttributes) => { + setActiveNotifications(prev => { + // Check if notification already exists + const exists = prev.some(n => n.id === notification.id) + if (exists) return prev + + // Add new notification to the beginning + return [notification, ...prev] + }) + }, []) + + const removeNotification = useCallback((id: string) => { + setActiveNotifications(prev => prev.filter(n => n.id !== id)) + }, []) + + const clearAllNotifications = useCallback(() => { + setActiveNotifications([]) + }, []) + + const value: NotificationUIContextType = { + showNotification, + removeNotification, + clearAllNotifications + } + + return ( + + {children} + + + ) +} + +export function useNotificationUI() { + const context = useContext(NotificationUIContext) + if (!context) { + throw new Error("useNotificationUI must be used within NotificationUIProvider") + } + return context +} \ No newline at end of file diff --git a/src/contexts/fileContext.tsx b/src/contexts/fileContext.tsx index 6cbffb8..69f0be2 100644 --- a/src/contexts/fileContext.tsx +++ b/src/contexts/fileContext.tsx @@ -1,7 +1,6 @@ import React, { useReducer, useContext, createContext, type ReactNode } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import fileApi from '@/api/file.api'; -import { toast } from 'sonner'; import { useAuth } from './useAuth'; import type { CreateFolderInput, @@ -131,7 +130,6 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => retry: false, onSuccess: () => { dispatch({ type: 'SET_LOADING', loading: false }); - toast.success('Folder created successfully!'); // Invalidate and refetch file system tree queryClient.invalidateQueries({ queryKey: ['fileSystemTree'] }); }, @@ -148,7 +146,6 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => retry: false, onSuccess: () => { dispatch({ type: 'SET_LOADING', loading: false }); - toast.success('Folder renamed successfully!'); // Invalidate and refetch file system tree queryClient.invalidateQueries({ queryKey: ['fileSystemTree'] }); }, @@ -165,7 +162,6 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => retry: false, onSuccess: () => { dispatch({ type: 'SET_LOADING', loading: false }); - toast.success('File or folder moved successfully!'); // Invalidate and refetch file system tree queryClient.invalidateQueries({ queryKey: ['fileSystemTree'] }); }, @@ -182,7 +178,6 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => retry: false, onSuccess: () => { dispatch({ type: 'SET_LOADING', loading: false }); - toast.success('File created successfully!'); // Invalidate and refetch file system tree queryClient.invalidateQueries({ queryKey: ['fileSystemTree'] }); }, @@ -199,7 +194,6 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => retry: false, onSuccess: () => { dispatch({ type: 'SET_LOADING', loading: false }); - toast.success('File or folder shared successfully!'); }, onError: () => { dispatch({ type: 'SET_LOADING', loading: false }); @@ -216,7 +210,6 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => queryClient.invalidateQueries({ queryKey: ['fileSystemTree'] }); queryClient.invalidateQueries({ queryKey: ['trash'] }); dispatch({ type: 'SET_LOADING', loading: false }); - toast.success('File or folder deleted successfully!'); }, onError: () => { dispatch({ type: 'SET_LOADING', loading: false }); @@ -233,7 +226,6 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => queryClient.invalidateQueries({ queryKey: ['fileSystemTree'] }); queryClient.invalidateQueries({ queryKey: ['trash'] }); dispatch({ type: 'SET_LOADING', loading: false }); - toast.success('File or folder restored successfully!'); }, onError: () => { dispatch({ type: 'SET_LOADING', loading: false }); @@ -249,7 +241,6 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['trash'] }); dispatch({ type: 'SET_LOADING', loading: false }); - toast.success('Trash emptied successfully!'); }, onError: () => { dispatch({ type: 'SET_LOADING', loading: false }); diff --git a/src/providers/AppProviders.tsx b/src/providers/AppProviders.tsx index 593f600..5312c6a 100644 --- a/src/providers/AppProviders.tsx +++ b/src/providers/AppProviders.tsx @@ -9,6 +9,7 @@ import { AuthProvider } from "@/contexts/useAuth"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { FileProvider } from "@/contexts/fileContext"; import { NotificationProvider } from "@/contexts/NotificationContext"; +import { NotificationUIProvider } from "@/contexts/NotificationUIContext"; import { SocketProvider } from "@/contexts/SocketContext"; const queryClient = new QueryClient(); @@ -28,6 +29,7 @@ const AppProviders = ({ children }: { children: ReactNode }) => { ), (children: ReactNode) => {children}, (children: ReactNode) => {children}, + (children: ReactNode) => {children}, (children: ReactNode) => {children}, ]; From e55574a27bcd2851555f6e772c1006c452d0fd4e Mon Sep 17 00:00:00 2001 From: venu123143 Date: Fri, 26 Sep 2025 10:51:22 +0530 Subject: [PATCH 02/22] idle state issue fixes --- src/components/upload/FIleUploader.tsx | 21 ++++++++++++++++++--- src/store/connect-socket.ts | 1 - 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/upload/FIleUploader.tsx b/src/components/upload/FIleUploader.tsx index 925a44e..5cb2983 100644 --- a/src/components/upload/FIleUploader.tsx +++ b/src/components/upload/FIleUploader.tsx @@ -52,6 +52,7 @@ const FileUploader: React.FC = ({ const [selectedFiles, setSelectedFiles] = useState([]); const [isDragOver, setIsDragOver] = useState(false); const [showWarning, setShowWarning] = useState(false); + const [completedFiles, setCompletedFiles] = useState>(new Set()); const { state, @@ -187,6 +188,7 @@ const FileUploader: React.FC = ({ progress: 100, lastUploadedChunk: Math.ceil(file.size / (5 * 1024 * 1024)) }); + setCompletedFiles(prev => new Set([...prev, file.name])); } } else { // Use direct upload for non-video files (images, excel, pdf, text, etc.) @@ -243,6 +245,7 @@ const FileUploader: React.FC = ({ lastUploadedChunk: 1, totalChunks: 1 // Set to 1 for direct uploads }); + setCompletedFiles(prev => new Set([...prev, file.name])); } else { throw new Error('Upload failed - no file returned'); } @@ -264,6 +267,11 @@ const FileUploader: React.FC = ({ removeFile(file.name); } setSelectedFiles(prev => prev.filter(f => f.name !== file.name)); + setCompletedFiles(prev => { + const newSet = new Set(prev); + newSet.delete(file.name); + return newSet; + }); }; const formatFileSize = (bytes: number): string => { @@ -314,7 +322,7 @@ const FileUploader: React.FC = ({ const timer = setTimeout(() => { autoClearCompleted(); }, 3000); // Clear after 3 seconds - + return () => clearTimeout(timer); } }, [allFilesCompleted, autoClearCompleted]); @@ -418,13 +426,20 @@ const FileUploader: React.FC = ({
{selectedFiles.map((file) => { - const fileState = fileStates[file.name] || { + const fileState = fileStates[file.name] || (completedFiles.has(file.name) ? { + progress: 100, + status: 'completed', + error: null, + totalChunks: Math.ceil(file.size / (5 * 1024 * 1024)), + lastUploadedChunk: Math.ceil(file.size / (5 * 1024 * 1024)), + url: null + } : { progress: 0, status: 'idle', error: null, totalChunks: Math.ceil(file.size / (5 * 1024 * 1024)), lastUploadedChunk: 0 - }; + }); return (
=> { socket.on('connect', () => { const timeTaken = performance.now() - startTime; console.log(`Connected in ${timeTaken.toFixed(2)} ms, socket id: ${socket.id}`); - toast.success(`Connected to server (${socket.id})`); resolve(socket); }); From dbc4fcea8443ee278ffb767f938aa60bbd090508 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sat, 27 Sep 2025 19:23:02 +0530 Subject: [PATCH 03/22] recent files get api added --- src/api/file.api.ts | 8 +++ src/components/custom/ImageViewer.tsx | 8 +-- src/components/file-manager/RecentFiles.tsx | 71 +++++++++++++++++++++ src/contexts/fileContext.tsx | 29 +++++++++ src/pages/home-dashboard.tsx | 48 ++------------ 5 files changed, 118 insertions(+), 46 deletions(-) create mode 100644 src/components/file-manager/RecentFiles.tsx diff --git a/src/api/file.api.ts b/src/api/file.api.ts index fb521b2..1cc9516 100644 --- a/src/api/file.api.ts +++ b/src/api/file.api.ts @@ -65,6 +65,13 @@ const emptyTrash = async () => { return response.data; }; +const getRecents = async (page: number = 1, limit: number = 20) => { + const response = await apiClient.get(`/file-flow/file/recents`, { + params: { page, limit } + }); + return response.data; +}; + export default { createFolder, renameFolder, @@ -79,4 +86,5 @@ export default { deleteFileOrFolder, restoreFileOrFolder, emptyTrash, + getRecents, }; diff --git a/src/components/custom/ImageViewer.tsx b/src/components/custom/ImageViewer.tsx index a64f913..616e4b9 100644 --- a/src/components/custom/ImageViewer.tsx +++ b/src/components/custom/ImageViewer.tsx @@ -323,10 +323,10 @@ export const ImageViewer: React.FC = ({ onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} - style={{ - cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default', - touchAction: 'none' // Prevent default touch behaviors - }} + // style={{ + // cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default', + // touchAction: 'none' // Prevent default touch behaviors + // }} > {!imageLoaded && !imageError && (
diff --git a/src/components/file-manager/RecentFiles.tsx b/src/components/file-manager/RecentFiles.tsx new file mode 100644 index 0000000..91a1ae8 --- /dev/null +++ b/src/components/file-manager/RecentFiles.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import { motion } from 'framer-motion' +import { FileText, ImageIcon, Video, Music } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { cn } from '@/lib/utils' +import { useFile } from '@/contexts/fileContext' + +type RecentFilesProps = { + page?: number + limit?: number + onItemClick?: (id: string) => void +} + +function getIconMeta(fileType?: string | null) { + const type = (fileType || '').toLowerCase() + if (type.startsWith('image/')) return { Icon: ImageIcon, color: 'text-purple-500', bgColor: 'bg-purple-50', label: 'image' } + if (type.startsWith('video/')) return { Icon: Video, color: 'text-blue-500', bgColor: 'bg-blue-50', label: 'video' } + if (type.startsWith('audio/')) return { Icon: Music, color: 'text-orange-500', bgColor: 'bg-orange-50', label: 'audio' } + return { Icon: FileText, color: 'text-slate-600', bgColor: 'bg-slate-100', label: type.split('/')[1] || 'file' } +} + +const RecentFiles: React.FC = ({ page = 1, limit = 5, onItemClick }) => { + const { getRecents, recents } = useFile() + + React.useEffect(() => { + void getRecents(page, limit) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, limit]) + + if (!recents || recents.files.length === 0) { + return ( +
No recent files
+ ) + } + + return ( +
+ {recents.files.map((file, index) => { + const meta = getIconMeta(file.file_info?.file_type) + return ( + onItemClick?.(file.id)} + > +
+ +
+
+

{file.name}

+

+ {new Date(file.last_accessed_at || file.created_at).toLocaleString()} +

+
+ + {meta.label} + +
+ ) + })} +
+ ) +} + +export default RecentFiles + + diff --git a/src/contexts/fileContext.tsx b/src/contexts/fileContext.tsx index 69f0be2..6859635 100644 --- a/src/contexts/fileContext.tsx +++ b/src/contexts/fileContext.tsx @@ -18,6 +18,7 @@ interface FileState { sharedFiles: SharedFileSystemNode[]; sharedFilesByMe: SharedFileSystemNode[]; sharedFilesWithMe: SharedFileSystemNode[]; + recents: { files: Array>; metadata: { total: number; page: number; limit: number; totalPages: number } } | null; loading: boolean; } @@ -27,6 +28,7 @@ type FileAction = | { type: 'SET_SHARED_FILES'; sharedFiles: SharedFileSystemNode[] } | { type: 'SET_SHARED_FILES_BY_ME'; sharedFilesByMe: SharedFileSystemNode[] } | { type: 'SET_SHARED_FILES_WITH_ME'; sharedFilesWithMe: SharedFileSystemNode[] } + | { type: 'SET_RECENTS'; recents: FileState['recents'] } | { type: 'SET_LOADING'; loading: boolean }; const initialState: FileState = { @@ -35,6 +37,7 @@ const initialState: FileState = { sharedFiles: [], sharedFilesByMe: [], sharedFilesWithMe: [], + recents: null, loading: false, }; @@ -50,6 +53,8 @@ function fileReducer(state: FileState, action: FileAction): FileState { return { ...state, sharedFilesByMe: action.sharedFilesByMe }; case 'SET_SHARED_FILES_WITH_ME': return { ...state, sharedFilesWithMe: action.sharedFilesWithMe }; + case 'SET_RECENTS': + return { ...state, recents: action.recents }; case 'SET_LOADING': return { ...state, loading: action.loading }; default: @@ -70,6 +75,7 @@ interface FileContextType extends FileState { getAllSharedFilesWithMe: () => Promise; getFileSystemTree: () => Promise; getTrash: () => Promise; + getRecents: (page?: number, limit?: number) => Promise; deleteFileOrFolder: (id: string) => MutationResult; restoreFileOrFolder: (id: string) => MutationResult; emptyTrash: () => MutationResult; @@ -418,6 +424,28 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => } }; + const getRecents = async (page: number = 1, limit: number = 20) => { + try { + dispatch({ type: 'SET_LOADING', loading: true }); + const data = await queryClient.fetchQuery({ + queryKey: ['recents', page, limit], + queryFn: async () => { + const result = await fileApi.getRecents(page, limit); + return result.data as FileState['recents']; + }, + staleTime: 2 * 60 * 1000, // 2 minutes + gcTime: 5 * 60 * 1000, + }); + dispatch({ type: 'SET_RECENTS', recents: data }); + dispatch({ type: 'SET_LOADING', loading: false }); + return data; + } catch (error: any) { + dispatch({ type: 'SET_LOADING', loading: false }); + const errorMessage = error?.response?.data?.message || error?.message || 'Failed to get recents.'; + throw new Error(errorMessage); + } + }; + const deleteFileOrFolder = async (id: string) => { try { dispatch({ type: 'SET_LOADING', loading: true }); @@ -469,6 +497,7 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => getAllSharedFilesWithMe, getFileSystemTree, getTrash, + getRecents, deleteFileOrFolder, restoreFileOrFolder, emptyTrash, diff --git a/src/pages/home-dashboard.tsx b/src/pages/home-dashboard.tsx index 328f6af..6e998e1 100644 --- a/src/pages/home-dashboard.tsx +++ b/src/pages/home-dashboard.tsx @@ -18,18 +18,12 @@ import { import { useNavigate } from "react-router-dom" import { Button } from "@/components/ui/button" +import RecentFiles from "@/components/file-manager/RecentFiles" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Progress } from "@/components/ui/progress" -import { Badge } from "@/components/ui/badge" import { cn } from "@/lib/utils" - -const recentFiles = [ - { name: "Project Proposal.pdf", type: "pdf", size: "2.4 MB", modified: "2 hours ago", icon: FileText, color: "text-red-500", bgColor: "bg-red-50" }, - { name: "Dashboard Mockup.fig", type: "figma", size: "15.2 MB", modified: "4 hours ago", icon: ImageIcon, color: "text-purple-500", bgColor: "bg-purple-50" }, - { name: "Team Meeting.mp4", type: "video", size: "124 MB", modified: "1 day ago", icon: Video, color: "text-blue-500", bgColor: "bg-blue-50" }, - { name: "Brand Assets.zip", type: "archive", size: "45.8 MB", modified: "2 days ago", icon: Archive, color: "text-orange-500", bgColor: "bg-orange-50" }, - { name: "Presentation.pptx", type: "presentation", size: "8.1 MB", modified: "3 days ago", icon: FileText, color: "text-pink-500", bgColor: "bg-pink-50" }, -] +import { useAuth } from "@/contexts/useAuth" +// recent files are now loaded via RecentFiles component const quickStats = [ { label: "Total Files", value: "1,247", icon: FileText, change: "+12%", color: "from-blue-500 to-blue-600" }, @@ -55,7 +49,7 @@ const quickActions = [ export function HomeDashboard() { const navigate = useNavigate() - + const { user } = useAuth() return (
@@ -69,7 +63,7 @@ export function HomeDashboard() { >

- Good morning, Sophie ✨ + Good morning, {user?.display_name} ✨

Here's what's happening with your files today. @@ -196,37 +190,7 @@ export function HomeDashboard() { -

- {recentFiles.map((file, index) => ( - -
- -
-
-

- {file.name} -

-

- {file.size} • {file.modified} -

-
- - {file.type} - -
- ))} -
+ From 032fb78fbfc82ee127e0427c81aaf935f0346f33 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sun, 9 Nov 2025 22:30:34 +0530 Subject: [PATCH 04/22] sessions added --- src/api/auth.api.ts | 29 +- src/api/axios.ts | 3 +- src/api/file.api.ts | 2 +- src/components/session/VerifyPin.tsx | 186 ++++++++++++ src/components/settings/settings-content.tsx | 298 ++++++++++++++++++- src/components/settings/settings-tabs.tsx | 1 + src/contexts/useAuth.tsx | 92 +++++- src/pages/private-files-page.tsx | 269 ++++++++++------- src/types/user.types.ts | 1 + 9 files changed, 767 insertions(+), 114 deletions(-) create mode 100644 src/components/session/VerifyPin.tsx diff --git a/src/api/auth.api.ts b/src/api/auth.api.ts index 69dae1a..ee5328a 100644 --- a/src/api/auth.api.ts +++ b/src/api/auth.api.ts @@ -46,5 +46,32 @@ const getAllUsers = async (attributes: GetAllUsersAttributes) => { } }; +const setPin = async (pin: string) => { + try { + const response = await apiClient.post('/auth/user/set-pin', { pin }); + return response.data; + } catch (error) { + throw error; + } +}; + +const verifyPin = async (pin: string) => { + try { + const response = await apiClient.post('/auth/user/verify-pin', { pin }, { withCredentials: true }); + return response.data; + } catch (error) { + throw error; + } +}; + +const changePin = async (oldPin: string, newPin: string) => { + try { + const response = await apiClient.put('/auth/user/change-pin', { old_pin: oldPin, new_pin: newPin }); + return response.data; + } catch (error) { + throw error; + } +}; + -export default { login, register, verifyEmail, logout, getAllUsers }; \ No newline at end of file +export default { login, register, verifyEmail, logout, getAllUsers, setPin, verifyPin, changePin }; \ No newline at end of file diff --git a/src/api/axios.ts b/src/api/axios.ts index 655ee93..10ff28b 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -7,6 +7,7 @@ const apiClient = axios.create({ baseURL: API_BASE_URL, // Replace with your API base URL headers: { 'Content-Type': 'application/json' }, timeout: 100000, + withCredentials: true, }); apiClient.interceptors.request.use( @@ -29,7 +30,7 @@ apiClient.interceptors.response.use( }, async (error) => { if (error.response?.status === 401) { - await logout(); + // await logout(); } return Promise.reject(error); // Ensure error propagates } diff --git a/src/api/file.api.ts b/src/api/file.api.ts index 1cc9516..125e2df 100644 --- a/src/api/file.api.ts +++ b/src/api/file.api.ts @@ -67,7 +67,7 @@ const emptyTrash = async () => { const getRecents = async (page: number = 1, limit: number = 20) => { const response = await apiClient.get(`/file-flow/file/recents`, { - params: { page, limit } + params: { page, limit }, withCredentials: true }); return response.data; }; diff --git a/src/components/session/VerifyPin.tsx b/src/components/session/VerifyPin.tsx new file mode 100644 index 0000000..20de617 --- /dev/null +++ b/src/components/session/VerifyPin.tsx @@ -0,0 +1,186 @@ +"use client" + +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Button } from "@/components/ui/button" +import { useAuth } from "@/contexts/useAuth" +import { Key, Lock } from "lucide-react" +import { toast } from "sonner" + +interface VerifyPinModalProps { + isOpen: boolean + onVerified: () => void + title?: string + description?: string + required?: boolean // If true, modal cannot be closed until verified +} + +export function VerifyPinModal({ + isOpen, + onVerified, + title = "Verify PIN", + description = "Please enter your 4-digit PIN to continue", + required = true, +}: VerifyPinModalProps) { + const { verifyPin, verifyPinLoading, user } = useAuth() + const [pin, setPin] = useState("") + const [error, setError] = useState("") + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + setPin("") + setError("") + } + }, [isOpen]) + + // Check if user has PIN set + useEffect(() => { + if (isOpen && user && !user.pin_hash) { + toast.error("PIN not set. Please set your PIN in settings first.") + // If PIN is not set and not required, allow closing + if (!required) { + onVerified() + } + } + }, [isOpen, user, required, onVerified]) + + const handleVerify = async () => { + setError("") + + // Validation + if (!pin || pin.length !== 4) { + setError("PIN must be exactly 4 digits") + return + } + + if (!/^\d+$/.test(pin)) { + setError("PIN must contain only numbers") + return + } + + const result = await verifyPin(pin) + if (result.success) { + setPin("") + setError("") + toast.success("PIN verified successfully") + onVerified() + } else { + setError(result.error || "Invalid PIN. Please try again.") + } + } + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && pin.length === 4) { + handleVerify() + } + } + + // Prevent closing if required and not verified + const handleOpenChange = (open: boolean) => { + if (!open && required) { + // Prevent closing - do nothing + return + } + } + + const hasPin = user?.pin_hash + + return ( + + { + if (required) { + e.preventDefault() + } + }} + onEscapeKeyDown={(e) => { + if (required) { + e.preventDefault() + } + }} + onPointerDownOutside={(e) => { + if (required) { + e.preventDefault() + } + }} + > + +
+
+ +
+ {title} +
+ {description} +
+ + {hasPin ? ( +
+
+ + { + const value = e.target.value.replace(/\D/g, "") + if (value.length <= 4) { + setPin(value) + setError("") + } + }} + onKeyDown={handleKeyPress} + className={error ? "border-red-500" : ""} + autoFocus + /> + {error && ( +

{error}

+ )} +

+ Enter your PIN to verify your identity +

+
+ + + + {required && ( +

+ PIN verification is required to continue +

+ )} +
+ ) : ( +
+
+ +
+

+ PIN Not Set +

+

+ Please set your PIN in settings before accessing this page. +

+
+
+
+ )} +
+
+ ) +} + +export default VerifyPinModal diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index d25e744..1f368f0 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -1,5 +1,6 @@ "use client" +import { useState } from "react" import { motion } from "framer-motion" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -7,6 +8,7 @@ import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" +import { useAuth } from "@/contexts/useAuth" import { User, Shield, @@ -19,7 +21,9 @@ import { Download, Upload, Settings, - Palette + Palette, + Key, + CheckCircle2 } from "lucide-react" interface SettingsContentProps { @@ -27,6 +31,89 @@ interface SettingsContentProps { } export function SettingsContent({ activeTab }: SettingsContentProps) { + const { user, setPin, changePin, setPinLoading, changePinLoading, saveUser } = useAuth() + const [pin, setPinValue] = useState("") + const [confirmPin, setConfirmPin] = useState("") + const [oldPin, setOldPin] = useState("") + const [newPin, setNewPin] = useState("") + const [confirmNewPin, setConfirmNewPin] = useState("") + const [error, setError] = useState("") + const [isChangingPin, setIsChangingPin] = useState(false) + + const handleSetPin = async () => { + setError("") + + // Validation + if (!pin || pin.length !== 4) { + setError("PIN must be exactly 4 digits") + return + } + + if (!/^\d+$/.test(pin)) { + setError("PIN must contain only numbers") + return + } + + if (pin !== confirmPin) { + setError("PINs do not match") + return + } + + const result = await setPin(pin) + if (result.success) { + setPinValue("") + setConfirmPin("") + setError("") + // Update user in context and localStorage to reflect pin_hash + if (user) { + const updatedUser = { ...user, pin_hash: "set" } + saveUser(updatedUser) + } + } else { + setError(result.error || "Failed to set PIN") + } + } + + const handleChangePin = async () => { + setError("") + + // Validation + if (!oldPin || oldPin.length !== 4) { + setError("Old PIN must be exactly 4 digits") + return + } + + if (!newPin || newPin.length !== 4) { + setError("New PIN must be exactly 4 digits") + return + } + + if (!/^\d+$/.test(oldPin) || !/^\d+$/.test(newPin)) { + setError("PINs must contain only numbers") + return + } + + if (newPin !== confirmNewPin) { + setError("New PINs do not match") + return + } + + if (oldPin === newPin) { + setError("New PIN must be different from old PIN") + return + } + + const result = await changePin(oldPin, newPin) + if (result.success) { + setOldPin("") + setNewPin("") + setConfirmNewPin("") + setError("") + setIsChangingPin(false) + } else { + setError(result.error || "Failed to change PIN") + } + } const renderGeneralTab = () => ( ) + const renderPinTab = () => { + const hasPin = user?.pin_hash + + return ( + + + + + + PIN Settings + + + Set a 4-digit PIN for quick access and additional security + + + + {hasPin ? ( +
+ {!isChangingPin ? ( + <> +
+ +
+

PIN Already Set

+

+ You have already set a PIN for your account. Your session will be created when you verify your PIN. +

+
+
+
+ +
+ + ) : ( +
+
+ + { + const value = e.target.value.replace(/\D/g, "") + if (value.length <= 4) { + setOldPin(value) + setError("") + } + }} + className={error ? "border-red-500" : ""} + /> +
+
+ + { + const value = e.target.value.replace(/\D/g, "") + if (value.length <= 4) { + setNewPin(value) + setError("") + } + }} + className={error ? "border-red-500" : ""} + /> +

+ Enter a 4-digit numeric PIN +

+
+
+ + { + const value = e.target.value.replace(/\D/g, "") + if (value.length <= 4) { + setConfirmNewPin(value) + setError("") + } + }} + className={error ? "border-red-500" : ""} + /> +
+ {error && ( +
+

{error}

+
+ )} +
+ + +
+

+ Your new PIN will be securely hashed and stored. Make sure to remember it! +

+
+ )} +
+ ) : ( +
+
+ + { + const value = e.target.value.replace(/\D/g, "") + if (value.length <= 4) { + setPinValue(value) + setError("") + } + }} + className={error ? "border-red-500" : ""} + /> +

+ Enter a 4-digit numeric PIN +

+
+
+ + { + const value = e.target.value.replace(/\D/g, "") + if (value.length <= 4) { + setConfirmPin(value) + setError("") + } + }} + className={error ? "border-red-500" : ""} + /> +
+ {error && ( +
+

{error}

+
+ )} + +

+ Your PIN will be securely hashed and stored. Make sure to remember it! +

+
+ )} +
+
+
+ ) + } + const renderAppsTab = () => ( Promise; getAllUsers: (attributes: GetAllUsersAttributes) => Promise; + setPin: (pin: string) => Promise<{ success: boolean; error?: string }>; + verifyPin: (pin: string) => Promise<{ success: boolean; error?: string }>; + changePin: (oldPin: string, newPin: string) => Promise<{ success: boolean; error?: string }>; + setPinLoading: boolean; + verifyPinLoading: boolean; + changePinLoading: boolean; } const AuthContext = createContext(undefined); @@ -162,6 +168,48 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => }, }); + const { mutateAsync: setPinMutationFn, isPending: setPinLoading } = useMutation({ + mutationFn: async (pin: string) => { + const result = await authApi.setPin(pin); + return result.data; + }, + onSuccess: () => { + dispatch({ type: 'SET_LOADING', loading: false }); + toast.success("PIN set successfully"); + }, + onError: () => { + dispatch({ type: 'SET_LOADING', loading: false }); + }, + }); + + const { mutateAsync: verifyPinMutationFn, isPending: verifyPinLoading } = useMutation({ + mutationFn: async (pin: string) => { + const result = await authApi.verifyPin(pin); + return result.data; + }, + onSuccess: () => { + dispatch({ type: 'SET_LOADING', loading: false }); + toast.success("PIN verified successfully. Session created."); + }, + onError: () => { + dispatch({ type: 'SET_LOADING', loading: false }); + }, + }); + + const { mutateAsync: changePinMutationFn, isPending: changePinLoading } = useMutation({ + mutationFn: async ({ oldPin, newPin }: { oldPin: string; newPin: string }) => { + const result = await authApi.changePin(oldPin, newPin); + return result.data; + }, + onSuccess: () => { + dispatch({ type: 'SET_LOADING', loading: false }); + toast.success("PIN changed successfully"); + }, + onError: () => { + dispatch({ type: 'SET_LOADING', loading: false }); + }, + }); + const login = async (email: string, password: string) => { try { @@ -217,6 +265,42 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => } }; + const setPin = async (pin: string) => { + try { + dispatch({ type: 'SET_LOADING', loading: true }); + await setPinMutationFn(pin); + return { success: true }; + } catch (error: any) { + dispatch({ type: 'SET_LOADING', loading: false }); + const errorMessage = error?.response?.data?.message || error?.message || "Failed to set PIN. Please try again."; + return { success: false, error: errorMessage }; + } + }; + + const verifyPin = async (pin: string) => { + try { + dispatch({ type: 'SET_LOADING', loading: true }); + await verifyPinMutationFn(pin); + return { success: true }; + } catch (error: any) { + dispatch({ type: 'SET_LOADING', loading: false }); + const errorMessage = error?.response?.data?.message || error?.message || "Invalid PIN. Please try again."; + return { success: false, error: errorMessage }; + } + }; + + const changePin = async (oldPin: string, newPin: string) => { + try { + dispatch({ type: 'SET_LOADING', loading: true }); + await changePinMutationFn({ oldPin, newPin }); + return { success: true }; + } catch (error: any) { + dispatch({ type: 'SET_LOADING', loading: false }); + const errorMessage = error?.response?.data?.message || error?.message || "Failed to change PIN. Please try again."; + return { success: false, error: errorMessage }; + } + }; + const value: AuthContextType = { ...state, login, @@ -226,9 +310,15 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => logoutLoading, getAllUsers, VerifyEmail, + setPin, + verifyPin, + changePin, + setPinLoading, + verifyPinLoading, + changePinLoading, }; - return {children},; + return {children}; }; export const useAuth = () => { diff --git a/src/pages/private-files-page.tsx b/src/pages/private-files-page.tsx index 8c52c91..21409d2 100644 --- a/src/pages/private-files-page.tsx +++ b/src/pages/private-files-page.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useState, useEffect } from "react" import { motion } from "framer-motion" import { Grid3X3, @@ -30,6 +30,8 @@ import { privatePageConfig, defaultViewConfig } from "@/config/page-configs" import { useFile } from "@/contexts/fileContext" import { toast } from "sonner" import type { PrivateFileItem, FileActionHandlers, FileItem } from "@/types/file-manager" +import { VerifyPinModal } from "@/components/session/VerifyPin" +import { useAuth } from "@/contexts/useAuth" const mockPrivateFiles: PrivateFileItem[] = [ { @@ -135,8 +137,27 @@ export function PrivateFilesPage() { const [searchQuery, setSearchQuery] = useState("") const [selectedFiles, setSelectedFiles] = useState([]) const [showSensitiveOnly, setShowSensitiveOnly] = useState(false) - + const [isPinVerified, setIsPinVerified] = useState(false) + const [showPinModal, setShowPinModal] = useState(true) + const { deleteFileOrFolder } = useFile() + const { user } = useAuth() + + // Show PIN modal when component mounts if user has PIN set + useEffect(() => { + if (user?.pin_hash && !isPinVerified) { + setShowPinModal(true) + } else if (!user?.pin_hash) { + // If no PIN is set, allow access but show warning + setIsPinVerified(true) + setShowPinModal(false) + } + }, [user, isPinVerified]) + + const handlePinVerified = () => { + setIsPinVerified(true) + setShowPinModal(false) + } const filteredFiles = mockPrivateFiles.filter((file) => { const matchesSearch = file.name.toLowerCase().includes(searchQuery.toLowerCase()) @@ -181,129 +202,159 @@ export function PrivateFilesPage() { onDecrypt: (file) => console.log("Decrypt file:", file.name), } - return ( -
- {/* Header */} - -
-
-
- -

Private Files

-
-

- {filteredFiles.length} private items • {encryptedCount} encrypted • {sensitiveCount} sensitive -

-
-
- - + // Don't render content until PIN is verified (if PIN is set) + if (user?.pin_hash && !isPinVerified) { + return ( + <> + +
+
+ +

Please verify your PIN to access private files

- - - + + ) + } - {/* Toolbar */} - -
-
- - setSearchQuery(e.target.value)} - className="pl-10" - /> + return ( + <> + +
+ {/* Header */} + +
+
+
+ +

Private Files

+
+

+ {filteredFiles.length} private items • {encryptedCount} encrypted • {sensitiveCount} sensitive +

+
+
+ + +
- - - -
+ -
- {selectedFiles.length > 0 && {selectedFiles.length} selected} -
- - -
-
- - {/* Bulk Actions */} - {selectedFiles.length > 0 && ( + + {/* Toolbar */} - - {selectedFiles.length} private items selected -
-
+ +
+ {selectedFiles.length > 0 && {selectedFiles.length} selected} +
+ + +
+
- )} - {/* Unified File Manager */} - -
+ {/* Bulk Actions */} + {selectedFiles.length > 0 && ( + + + {selectedFiles.length} private items selected +
+ + + +
+
+ )} + + {/* Unified File Manager */} + +
+ ) } diff --git a/src/types/user.types.ts b/src/types/user.types.ts index 38a660d..8e131a5 100644 --- a/src/types/user.types.ts +++ b/src/types/user.types.ts @@ -15,6 +15,7 @@ export interface IUser { email: string; role: UserRole; password_hash: string; + pin_hash?: string; display_name?: string; avatar_url?: string; storage_quota: number; From 059d099e040eb8ce05d10fc4b6c49804d9c191bd Mon Sep 17 00:00:00 2001 From: venu123143 Date: Thu, 13 Nov 2025 00:39:19 +0530 Subject: [PATCH 05/22] auth api session changes --- src/api/auth.api.ts | 11 ++++- src/api/axios.ts | 2 +- src/contexts/useAuth.tsx | 50 ++++++++++++++++++-- src/pages/private-files-page.tsx | 80 +++++++++++++++++++++++++++----- 4 files changed, 126 insertions(+), 17 deletions(-) diff --git a/src/api/auth.api.ts b/src/api/auth.api.ts index ee5328a..1930862 100644 --- a/src/api/auth.api.ts +++ b/src/api/auth.api.ts @@ -73,5 +73,14 @@ const changePin = async (oldPin: string, newPin: string) => { } }; +const getSession = async () => { + try { + const response = await apiClient.get('/auth/user/get-session', { withCredentials: true }); + return response.data; + } catch (error) { + throw error; + } +}; + -export default { login, register, verifyEmail, logout, getAllUsers, setPin, verifyPin, changePin }; \ No newline at end of file +export default { login, register, verifyEmail, logout, getAllUsers, setPin, verifyPin, changePin, getSession }; \ No newline at end of file diff --git a/src/api/axios.ts b/src/api/axios.ts index 10ff28b..ced3c43 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -30,7 +30,7 @@ apiClient.interceptors.response.use( }, async (error) => { if (error.response?.status === 401) { - // await logout(); + await logout(); } return Promise.reject(error); // Ensure error propagates } diff --git a/src/contexts/useAuth.tsx b/src/contexts/useAuth.tsx index 563d615..1a33d9c 100644 --- a/src/contexts/useAuth.tsx +++ b/src/contexts/useAuth.tsx @@ -1,4 +1,4 @@ -import React, { useReducer, useContext, createContext, type ReactNode } from 'react'; +import React, { useReducer, useContext, createContext, useCallback, type ReactNode } from 'react'; import { CONSTANTS } from '@/constants/constants'; import { useAuthStore } from '@/store/auth.store'; import { type IUser, type SignupDto, type GetAllUsersAttributes, type IUserListItem } from '@/types/user.types'; @@ -6,7 +6,12 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import authApi from '@/api/auth.api'; import { toast } from 'sonner'; import { useSocket } from "@/contexts/SocketContext"; - +export interface PinSessionData { + user_id: string; + email: string; + pin_verified: boolean; + verified_at: string; +} const userStr = localStorage.getItem(CONSTANTS.STORAGE_KEYS.USER_DATA); const user = userStr ? JSON.parse(userStr) as IUser : null; @@ -14,6 +19,7 @@ const user = userStr ? JSON.parse(userStr) as IUser : null; interface AuthState { user: IUser | null; loading: boolean; + pinSession: PinSessionData | null; } type AuthAction = @@ -21,11 +27,13 @@ type AuthAction = | { type: 'LOGIN_SUCCESS'; user: IUser } | { type: 'LOGOUT' } | { type: 'REGISTER_START' } - | { type: 'SET_LOADING'; loading: boolean }; + | { type: 'SET_LOADING'; loading: boolean } + | { type: 'SET_PIN_SESSION'; pinSession: PinSessionData }; const initialState: AuthState = { user: user, loading: false, + pinSession: null, }; function authReducer(state: AuthState, action: AuthAction): AuthState { @@ -39,6 +47,8 @@ function authReducer(state: AuthState, action: AuthAction): AuthState { return { ...state, user: null, loading: false }; case 'SET_LOADING': return { ...state, loading: action.loading }; + case 'SET_PIN_SESSION': + return { ...state, pinSession: action.pinSession }; default: return state; } @@ -58,6 +68,8 @@ interface AuthContextType extends AuthState { setPinLoading: boolean; verifyPinLoading: boolean; changePinLoading: boolean; + getPinSession: () => Promise<{ success: boolean; user: IUser; session: PinSessionData } | { success: false; error: string }>; + isPinSessionValid: () => boolean; } const AuthContext = createContext(undefined); @@ -187,8 +199,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => const result = await authApi.verifyPin(pin); return result.data; }, - onSuccess: () => { + onSuccess: (data) => { dispatch({ type: 'SET_LOADING', loading: false }); + // Update session state if session data is returned + if (data?.session) { + dispatch({ type: 'SET_PIN_SESSION', pinSession: data.session }); + } toast.success("PIN verified successfully. Session created."); }, onError: () => { @@ -300,6 +316,30 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => return { success: false, error: errorMessage }; } }; + const getPinSession = useCallback(async (): Promise<{ success: true; user: IUser; session: PinSessionData } | { success: false; error: string }> => { + try { + dispatch({ type: 'SET_LOADING', loading: true }); + const result = await authApi.getSession(); + dispatch({ type: 'SET_PIN_SESSION', pinSession: result.data.session }); + return { success: true as const, user: result.data.user, session: result.data.session }; + } catch (error: any) { + dispatch({ type: 'SET_LOADING', loading: false }); + const errorMessage = error?.response?.data?.message || error?.message || "Failed to get PIN session. Please try again."; + return { success: false as const, error: errorMessage }; + } + }, []); + + const isPinSessionValid = (): boolean => { + if (!state.pinSession || !state.pinSession.pin_verified) { + return false; + } + + const verifiedAt = new Date(state.pinSession.verified_at); + const now = new Date(); + const diffInMinutes = (now.getTime() - verifiedAt.getTime()) / (1000 * 60); + + return diffInMinutes <= 20; + }; const value: AuthContextType = { ...state, @@ -316,6 +356,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => setPinLoading, verifyPinLoading, changePinLoading, + getPinSession, + isPinSessionValid, }; return {children}; diff --git a/src/pages/private-files-page.tsx b/src/pages/private-files-page.tsx index 21409d2..21f4ab4 100644 --- a/src/pages/private-files-page.tsx +++ b/src/pages/private-files-page.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect } from "react" +import { useState, useEffect, useRef } from "react" import { motion } from "framer-motion" import { Grid3X3, @@ -138,21 +138,67 @@ export function PrivateFilesPage() { const [selectedFiles, setSelectedFiles] = useState([]) const [showSensitiveOnly, setShowSensitiveOnly] = useState(false) const [isPinVerified, setIsPinVerified] = useState(false) - const [showPinModal, setShowPinModal] = useState(true) + const [showPinModal, setShowPinModal] = useState(false) + const [isCheckingSession, setIsCheckingSession] = useState(true) + const hasCheckedSession = useRef(false) const { deleteFileOrFolder } = useFile() - const { user } = useAuth() + const { user, getPinSession } = useAuth() - // Show PIN modal when component mounts if user has PIN set + // Check for active PIN session on mount (only once) useEffect(() => { - if (user?.pin_hash && !isPinVerified) { - setShowPinModal(true) - } else if (!user?.pin_hash) { - // If no PIN is set, allow access but show warning - setIsPinVerified(true) - setShowPinModal(false) + // Prevent multiple calls + if (hasCheckedSession.current) { + return } - }, [user, isPinVerified]) + + const checkSession = async () => { + hasCheckedSession.current = true + + if (!user?.pin_hash) { + // If no PIN is set, allow access + setIsPinVerified(true) + setShowPinModal(false) + setIsCheckingSession(false) + return + } + + try { + // Try to get the current session + const sessionResult = await getPinSession() + + if (sessionResult.success && sessionResult.session) { + // Check if session is valid (within 20 minutes) + const verifiedAt = new Date(sessionResult.session.verified_at) + const now = new Date() + const diffInMinutes = (now.getTime() - verifiedAt.getTime()) / (1000 * 60) + const isValid = sessionResult.session.pin_verified && diffInMinutes <= 20 + + if (isValid) { + // Session is valid (within 20 minutes), auto-verify + setIsPinVerified(true) + setShowPinModal(false) + } else { + // Session expired, show PIN modal + setIsPinVerified(false) + setShowPinModal(true) + } + } else { + // No valid session, show PIN modal + setIsPinVerified(false) + setShowPinModal(true) + } + } catch (error) { + // If session check fails, show PIN modal + setIsPinVerified(false) + setShowPinModal(true) + } finally { + setIsCheckingSession(false) + } + } + + checkSession() + }, [user, getPinSession]) const handlePinVerified = () => { setIsPinVerified(true) @@ -202,6 +248,18 @@ export function PrivateFilesPage() { onDecrypt: (file) => console.log("Decrypt file:", file.name), } + // Show loading state while checking session + if (isCheckingSession) { + return ( +
+
+ +

Checking session...

+
+
+ ) + } + // Don't render content until PIN is verified (if PIN is set) if (user?.pin_hash && !isPinVerified) { return ( From 1e7d34d008e8ee88a5e7c846e46193ac103c49dc Mon Sep 17 00:00:00 2001 From: venu123143 Date: Thu, 20 Nov 2025 00:12:17 +0530 Subject: [PATCH 06/22] protected route and private route added --- src/api/file.api.ts | 9 +- src/components/player/VideoPlayer.tsx | 136 +++++++++++++ src/components/upload/FIleUploader.tsx | 161 ++++++++------- src/components/user/user-dropdown.tsx | 1 + src/contexts/UploadContext.tsx | 3 +- src/contexts/fileContext.tsx | 47 ++++- src/lib/utils.ts | 43 +++- src/pages/private-files-page.tsx | 265 +++++++++++++------------ src/routes/Upload.tsx | 3 +- 9 files changed, 465 insertions(+), 203 deletions(-) diff --git a/src/api/file.api.ts b/src/api/file.api.ts index 125e2df..2b8f13d 100644 --- a/src/api/file.api.ts +++ b/src/api/file.api.ts @@ -41,7 +41,13 @@ const getAllSharedFilesWithMe = async () => { }; const getFileSystemTree = async () => { - const response = await apiClient.get("/file-flow/file/all"); + const response = await apiClient.get("/file-flow/file/all?accessLevel=protected"); + return response.data; +}; + + +const getPrivateFiles = async () => { + const response = await apiClient.get("/file-flow/file/all?accessLevel=private"); return response.data; }; @@ -77,6 +83,7 @@ export default { renameFolder, moveFileOrFolder, createFile, + getPrivateFiles, shareFileOrFolder, getAllSharedFiles, getAllSharedFilesByMe, diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index f3efc75..8c6992d 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -44,6 +44,51 @@ const customStyles = ` background-color: #ef4444; } + /* Ensure timer is always visible */ + .video-js .vjs-current-time, + .video-js .vjs-duration { + display: inline-block !important; + padding: 0 0.5em; + } + + .video-js .vjs-time-divider { + display: inline-block !important; + padding: 0 0.2em; + } + + /* Skip indicator styles */ + .video-skip-indicator { + position: absolute; + top: 50%; + transform: translateY(-50%); + font-size: 2em; + font-weight: bold; + color: white; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); + pointer-events: none; + z-index: 1000; + animation: fadeOut 0.5s ease-out forwards; + } + + .video-skip-indicator.left { + left: 20%; + } + + .video-skip-indicator.right { + right: 20%; + } + + @keyframes fadeOut { + 0% { + opacity: 1; + transform: translateY(-50%) scale(1.2); + } + 100% { + opacity: 0; + transform: translateY(-50%) scale(1); + } + } + /* Mobile optimizations */ @media (max-width: 640px) { .video-js .vjs-control-bar { @@ -130,6 +175,7 @@ export function VideoPlayer({ const videoRef = useRef(null); const playerRef = useRef(null); const containerRef = useRef(null); + const doubleClickHandlerRef = useRef<((event: MouseEvent) => void) | null>(null); useEffect(() => { // Add custom styles to document @@ -208,6 +254,49 @@ export function VideoPlayer({ if (onError) onError(error); }); + // Double-click handler for skip forward/backward + const handleDoubleClick = (event: MouseEvent) => { + const playerEl = player.el() as HTMLElement | null; + if (!playerEl) return; + + const rect = playerEl.getBoundingClientRect(); + const clickX = event.clientX - rect.left; + const playerWidth = rect.width; + const isLeftSide = clickX < playerWidth / 2; + + const currentTime = player.currentTime(); + const duration = player.duration(); + + if (typeof currentTime === 'number' && typeof duration === 'number') { + let newTime: number; + let skipText: string; + + if (isLeftSide) { + // Skip backward 10 seconds + newTime = Math.max(0, currentTime - 10); + skipText = '-10s'; + } else { + // Skip forward 10 seconds + newTime = Math.min(duration, currentTime + 10); + skipText = '+10s'; + } + + player.currentTime(newTime); + + // Show skip indicator + showSkipIndicator(playerEl, skipText, isLeftSide); + } + }; + + // Store handler reference for cleanup + doubleClickHandlerRef.current = handleDoubleClick; + + // Listen for double-click events on the player element + const playerEl = player.el() as HTMLElement | null; + if (playerEl) { + playerEl.addEventListener('dblclick', handleDoubleClick); + } + // Keyboard shortcuts player.on('keydown', (e: any) => { const event = e as KeyboardEvent; @@ -268,6 +357,14 @@ export function VideoPlayer({ } return () => { + // Clean up double-click listener + if (playerRef.current && doubleClickHandlerRef.current) { + const playerEl = playerRef.current.el() as HTMLElement | null; + if (playerEl) { + playerEl.removeEventListener('dblclick', doubleClickHandlerRef.current); + } + doubleClickHandlerRef.current = null; + } if (playerRef.current) { playerRef.current.dispose(); playerRef.current = null; @@ -275,6 +372,45 @@ export function VideoPlayer({ }; }, [url, autoplay, muted, controls, loop, preload, poster]); + // Helper function to show skip indicator + function showSkipIndicator(playerEl: HTMLElement, text: string, isLeft: boolean) { + // Remove existing indicator if any + const existingIndicator = playerEl.querySelector('.video-skip-indicator') as HTMLElement | null; + if (existingIndicator) { + existingIndicator.remove(); + } + + // Create new indicator + const indicator = document.createElement('div'); + indicator.className = `video-skip-indicator ${isLeft ? 'left' : 'right'}`; + indicator.textContent = text; + indicator.style.position = 'absolute'; + indicator.style.top = '50%'; + indicator.style.transform = 'translateY(-50%)'; + indicator.style.fontSize = '2em'; + indicator.style.fontWeight = 'bold'; + indicator.style.color = 'white'; + indicator.style.textShadow = '2px 2px 4px rgba(0, 0, 0, 0.8)'; + indicator.style.pointerEvents = 'none'; + indicator.style.zIndex = '1000'; + indicator.style.animation = 'fadeOut 0.5s ease-out forwards'; + + if (isLeft) { + indicator.style.left = '20%'; + } else { + indicator.style.right = '20%'; + } + + playerEl.appendChild(indicator); + + // Remove after animation + setTimeout(() => { + if (indicator.parentNode) { + indicator.remove(); + } + }, 500); + } + // Helper function to determine video type function getVideoType(url: string): string { const extension = url.split('.').pop()?.toLowerCase(); diff --git a/src/components/upload/FIleUploader.tsx b/src/components/upload/FIleUploader.tsx index 5cb2983..cd83197 100644 --- a/src/components/upload/FIleUploader.tsx +++ b/src/components/upload/FIleUploader.tsx @@ -19,6 +19,7 @@ import { import { useNavigate } from 'react-router-dom'; import { useUpload } from '@/contexts/UploadContext'; import { useFile } from '@/contexts/fileContext'; +import type { AccessLevel } from '@/types/file.types'; export type FileType = 'excel' | 'pdf' | 'image' | 'video' | 'audio' | 'archive' | 'text' | 'any'; @@ -32,6 +33,7 @@ interface FileUploaderProps { allowedTypes?: FileConfig[]; maxFiles?: number; folderId?: string; + accessLevel?: AccessLevel; // 'public' | 'private' | 'protected' } const DEFAULT_FILE_CONFIGS: FileConfig[] = [ @@ -46,6 +48,7 @@ const FileUploader: React.FC = ({ allowedTypes = DEFAULT_FILE_CONFIGS, maxFiles = 10, folderId, + accessLevel, }) => { const { createFile } = useFile(); const navigate = useNavigate(); @@ -64,7 +67,7 @@ const FileUploader: React.FC = ({ autoClearCompleted } = useUpload(); - const { fileStates } = state; + const { fileStates, error: uploadError } = state; // Check if any uploads are in progress useEffect(() => { @@ -173,81 +176,95 @@ const FileUploader: React.FC = ({ const fileType = getFileType(file); try { - if (fileType === 'video') { - // Use chunked upload for video files - const result = await handleUpload(file, folderId); - if (result) { - await createFile({ - name: file.name, - parent_id: folderId === "root" ? null : folderId, - file_info: result - }); - updateFileState(file.name, { - url: result.storage_path, - status: 'completed', - progress: 100, - lastUploadedChunk: Math.ceil(file.size / (5 * 1024 * 1024)) - }); - setCompletedFiles(prev => new Set([...prev, file.name])); + switch (fileType) { + case 'video': { + // Use chunked upload for video files + const result = await handleUpload(file, folderId); + if (result) { + await createFile({ + name: file.name, + parent_id: folderId === "root" ? null : folderId, + access_level: accessLevel, + file_info: result + }); + updateFileState(file.name, { + url: result.storage_path, + status: 'completed', + progress: 100, + lastUploadedChunk: Math.ceil(file.size / (5 * 1024 * 1024)) + }); + setCompletedFiles(prev => new Set([...prev, file.name])); + } + break; } - } else { - // Use direct upload for non-video files (images, excel, pdf, text, etc.) - // Initialize file state if not exists - if (!fileStates[file.name]) { - updateFileState(file.name, { - uploadId: null, - url: null, - fileKey: null, - progress: 0, - status: 'uploading', - error: null, - lastUploadedChunk: 0, - totalChunks: 1, - fileName: file.name, - fileSize: file.size, - fileType: file.type - }); - } else { + case 'excel': + case 'pdf': + case 'image': + case 'audio': + case 'archive': + case 'text': + case 'any': + default: { + // Use direct upload for non-video files (images, excel, pdf, text, etc.) + // Initialize file state if not exists + if (!fileStates[file.name]) { + updateFileState(file.name, { + uploadId: null, + url: null, + fileKey: null, + progress: 0, + status: 'uploading', + error: null, + lastUploadedChunk: 0, + totalChunks: 1, + fileName: file.name, + fileSize: file.size, + fileType: file.type + }); + } else { + updateFileState(file.name, { + status: 'uploading', + progress: 0, + error: null + }); + } + + // Update progress to show upload started updateFileState(file.name, { - status: 'uploading', - progress: 0, - error: null - }); - } - - // Update progress to show upload started - updateFileState(file.name, { - progress: 50 - }); - - const uploadedFiles = await uploadFiles([file]); - - if (uploadedFiles && uploadedFiles.length > 0) { - const uploadedFile = uploadedFiles[0]; - - // Create file entry in database - await createFile({ - name: file.name, - parent_id: folderId === "root" ? null : folderId, - file_info: { - file_type: file.type, - file_size: file.size, - storage_path: uploadedFile.url, - thumbnail_path: uploadedFile.url, - duration: undefined - } + progress: 50 }); - updateFileState(file.name, { - url: uploadedFile.url, - status: 'completed', - progress: 100, - lastUploadedChunk: 1, - totalChunks: 1 // Set to 1 for direct uploads - }); - setCompletedFiles(prev => new Set([...prev, file.name])); - } else { - throw new Error('Upload failed - no file returned'); + const uploadedFiles = await uploadFiles([file]); + + if (uploadedFiles && uploadedFiles.length > 0) { + const uploadedFile = uploadedFiles[0]; + + // Create file entry in database + await createFile({ + name: file.name, + parent_id: folderId === "root" ? null : folderId, + access_level: accessLevel, + file_info: { + file_type: file.type, + file_size: file.size, + storage_path: uploadedFile.url, + thumbnail_path: uploadedFile.url, + duration: undefined + } + }); + + updateFileState(file.name, { + url: uploadedFile.url, + status: 'completed', + progress: 100, + lastUploadedChunk: 1, + totalChunks: 1 // Set to 1 for direct uploads + }); + setCompletedFiles(prev => new Set([...prev, file.name])); + } else { + throw new Error(uploadError || 'Upload failed - no file returned'); + } + break; } } } catch (error: any) { diff --git a/src/components/user/user-dropdown.tsx b/src/components/user/user-dropdown.tsx index 0379dec..c0aa9ad 100644 --- a/src/components/user/user-dropdown.tsx +++ b/src/components/user/user-dropdown.tsx @@ -36,6 +36,7 @@ export function UserDropdown({ name = "Sophie Chamberlain", email = "hi@sophie.c await handleLogout() } else { setIsOpen(false) + navigate(href) } } return ( diff --git a/src/contexts/UploadContext.tsx b/src/contexts/UploadContext.tsx index a370d99..bf3646e 100644 --- a/src/contexts/UploadContext.tsx +++ b/src/contexts/UploadContext.tsx @@ -306,6 +306,7 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) parts.push({ PartNumber: chunkResponse.PartNumber, ETag: chunkResponse.ETag }); updateFileState(file.name, { lastUploadedChunk: chunkNumber + 1 }); } catch (error: any) { + console.error('Failed to upload chunk', error); updateFileState(file.name, { status: 'error', error: `Failed to upload chunk ${chunkNumber + 1}: ${error.message}` @@ -384,7 +385,7 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) throw new Error(response.data?.message || 'Upload failed - invalid response'); } } catch (error: any) { - dispatch({ type: 'ERROR', payload: error?.message || 'Network error' }); + dispatch({ type: 'ERROR', payload: error?.response?.data?.message || 'Network error' }); return []; } }; diff --git a/src/contexts/fileContext.tsx b/src/contexts/fileContext.tsx index 6859635..7080e7d 100644 --- a/src/contexts/fileContext.tsx +++ b/src/contexts/fileContext.tsx @@ -14,6 +14,7 @@ import type { interface FileState { fileSystemTree: FileSystemNode[]; + privateFiles: FileSystemNode[]; trash: FileSystemNode[]; sharedFiles: SharedFileSystemNode[]; sharedFilesByMe: SharedFileSystemNode[]; @@ -24,6 +25,7 @@ interface FileState { type FileAction = | { type: 'SET_FILE_SYSTEM_TREE'; fileSystemTree: FileSystemNode[] } + | { type: 'SET_PRIVATE_FILES'; privateFiles: FileSystemNode[] } | { type: 'SET_TRASH'; trash: FileSystemNode[] } | { type: 'SET_SHARED_FILES'; sharedFiles: SharedFileSystemNode[] } | { type: 'SET_SHARED_FILES_BY_ME'; sharedFilesByMe: SharedFileSystemNode[] } @@ -33,6 +35,7 @@ type FileAction = const initialState: FileState = { fileSystemTree: [], + privateFiles: [], trash: [], sharedFiles: [], sharedFilesByMe: [], @@ -45,6 +48,8 @@ function fileReducer(state: FileState, action: FileAction): FileState { switch (action.type) { case 'SET_FILE_SYSTEM_TREE': return { ...state, fileSystemTree: action.fileSystemTree }; + case 'SET_PRIVATE_FILES': + return { ...state, privateFiles: action.privateFiles }; case 'SET_TRASH': return { ...state, trash: action.trash }; case 'SET_SHARED_FILES': @@ -74,6 +79,7 @@ interface FileContextType extends FileState { getAllSharedFilesByMe: () => Promise; getAllSharedFilesWithMe: () => Promise; getFileSystemTree: () => Promise; + getPrivateFiles: () => Promise; getTrash: () => Promise; getRecents: (page?: number, limit?: number) => Promise; deleteFileOrFolder: (id: string) => MutationResult; @@ -101,6 +107,23 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => enabled: !!user, // Only enable the query when user is authenticated }); + const { data: privateFilesData, isLoading: privateFilesLoading } = useQuery({ + queryKey: ['privateFiles'], + queryFn: async () => { + const result = await fileApi.getPrivateFiles(); + return result.data; + }, + retry: 2, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, // 10 minutes + enabled: !!user, // Only enable the query when user is authenticated + }); + React.useEffect(() => { + if (privateFilesData) { + dispatch({ type: 'SET_PRIVATE_FILES', privateFiles: privateFilesData }); + } + }, [privateFilesData]); + const { data: trashData, isLoading: trashLoading } = useQuery({ queryKey: ['trash'], queryFn: async () => { @@ -484,9 +507,30 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => } }; + const getPrivateFiles = async () => { + try { + dispatch({ type: 'SET_LOADING', loading: true }); + const data = await queryClient.fetchQuery({ + queryKey: ['privateFiles'], + queryFn: async () => { + const result = await fileApi.getPrivateFiles(); + return result.data; + }, + }); + dispatch({ type: 'SET_PRIVATE_FILES', privateFiles: data }); + dispatch({ type: 'SET_LOADING', loading: false }); + return data; + } + catch (error: any) { + dispatch({ type: 'SET_LOADING', loading: false }); + const errorMessage = error?.response?.data?.message || error?.message || 'Failed to get private files.'; + throw new Error(errorMessage); + } + }; + const value: FileContextType = { ...state, - loading: state.loading || fileSystemTreeLoading || trashLoading, + loading: state.loading || fileSystemTreeLoading || trashLoading || privateFilesLoading, createFolder, renameFolder, moveFileOrFolder, @@ -496,6 +540,7 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => getAllSharedFilesByMe, getAllSharedFilesWithMe, getFileSystemTree, + getPrivateFiles, getTrash, getRecents, deleteFileOrFolder, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index cff9ffc..078f860 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,7 +1,7 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" import type { FileSystemNode } from "@/types/file.types" -import type { StandardFileItem, DeletedFileItem } from "@/types/file-manager" +import type { StandardFileItem, DeletedFileItem, PrivateFileItem } from "@/types/file-manager" import { FileText, FolderIcon, @@ -151,3 +151,44 @@ export function transformFileSystemNodeToDeletedFileItem(node: FileSystemNode, p export function transformFileSystemNodesToDeletedFileItems(nodes: FileSystemNode[]): DeletedFileItem[] { return nodes.map(node => transformFileSystemNodeToDeletedFileItem(node)) } + +// Transform FileSystemNode to PrivateFileItem +export function transformFileSystemNodeToPrivateFileItem(node: FileSystemNode, parentPath: string[] = []): PrivateFileItem { + const isFolder = node.is_folder + const fileType = node.file_info?.file_type || null + const size = node.file_info?.file_size || 0 + const thumbnail = node.file_info?.thumbnail_path || null + + // Handle potential null updated_at + const modifiedDate = node.updated_at || node.created_at + + // Determine encrypted and sensitive from metadata or defaults + // For private files, we can check metadata or use defaults + const metadata = node.metadata || {} + const encrypted = metadata.encrypted !== undefined ? metadata.encrypted : (node.access_level === "private") + const sensitive = metadata.sensitive !== undefined ? metadata.sensitive : (node.tags?.includes("sensitive") || false) + + return { + id: node.id, + name: node.name, + type: isFolder ? "folder" : "file", + fileType: isFolder ? "folder" : getFileTypeCategory(fileType), + size: formatFileSize(size), + modified: formatRelativeTime(modifiedDate), + icon: isFolder ? FolderIcon : getFileIcon(fileType), + thumbnail, + file_info: node.file_info || undefined, + starred: false, // This would need to be fetched from favorites API + shared: false, // Private files are not shared + parentPath, + variant: "private", + encrypted, + sensitive, + children: node.children ? node.children.map(child => transformFileSystemNodeToPrivateFileItem(child, [...parentPath, node.name])) : undefined, + } +} + +// Transform FileSystemNode array to PrivateFileItem array +export function transformFileSystemNodesToPrivateFileItems(nodes: FileSystemNode[]): PrivateFileItem[] { + return nodes.map(node => transformFileSystemNodeToPrivateFileItem(node)) +} \ No newline at end of file diff --git a/src/pages/private-files-page.tsx b/src/pages/private-files-page.tsx index 21f4ab4..876d556 100644 --- a/src/pages/private-files-page.tsx +++ b/src/pages/private-files-page.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useRef } from "react" +import { useState, useEffect, useRef, useMemo } from "react" import { motion } from "framer-motion" import { Grid3X3, @@ -10,11 +10,6 @@ import { SortAsc, Download, Trash2, - FileText, - Music, - Archive, - FolderIcon, - Plus, Upload, Lock, Shield, @@ -26,124 +21,35 @@ import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" import { Checkbox } from "@/components/ui/checkbox" import { FileManager } from "@/components/file-manager/FileManager" +import { BreadcrumbNavigation } from "@/components/file-manager/BreadcrumbNavigation" import { privatePageConfig, defaultViewConfig } from "@/config/page-configs" import { useFile } from "@/contexts/fileContext" +import { transformFileSystemNodesToPrivateFileItems } from "@/lib/utils" import { toast } from "sonner" import type { PrivateFileItem, FileActionHandlers, FileItem } from "@/types/file-manager" import { VerifyPinModal } from "@/components/session/VerifyPin" import { useAuth } from "@/contexts/useAuth" - -const mockPrivateFiles: PrivateFileItem[] = [ - { - id: "1", - name: "Personal Journal.docx", - type: "file", - fileType: "document", - size: "1.2 MB", - modified: "1 hour ago", - icon: FileText, - thumbnail: null, - starred: true, - shared: false, - parentPath: [], - variant: "private", - encrypted: true, - sensitive: true, - }, - { - id: "2", - name: "Family Photos", - type: "folder", - fileType: "folder", - size: "234 MB", - modified: "3 hours ago", - icon: FolderIcon, - thumbnail: null, - starred: false, - shared: false, - parentPath: [], - variant: "private", - encrypted: true, - sensitive: false, - }, - { - id: "3", - name: "Tax Documents 2024.pdf", - type: "file", - fileType: "pdf", - size: "5.8 MB", - modified: "2 days ago", - icon: FileText, - thumbnail: null, - starred: true, - shared: false, - parentPath: [], - variant: "private", - encrypted: true, - sensitive: true, - }, - { - id: "4", - name: "Private Notes.txt", - type: "file", - fileType: "text", - size: "45 KB", - modified: "1 week ago", - icon: FileText, - thumbnail: null, - starred: false, - shared: false, - parentPath: [], - variant: "private", - encrypted: false, - sensitive: false, - }, - { - id: "5", - name: "Confidential Recording.mp3", - type: "file", - fileType: "audio", - size: "23.4 MB", - modified: "2 weeks ago", - icon: Music, - thumbnail: null, - starred: false, - shared: false, - parentPath: [], - variant: "private", - encrypted: true, - sensitive: true, - }, - { - id: "6", - name: "Personal Backup.zip", - type: "file", - fileType: "archive", - size: "156 MB", - modified: "1 month ago", - icon: Archive, - thumbnail: null, - starred: false, - shared: false, - parentPath: [], - variant: "private", - encrypted: true, - sensitive: false, - }, -] +import { useSocket } from "@/contexts/SocketContext" +import { ArrowLeft } from "lucide-react" +import { ACCESS_LEVEL } from "@/types/file.types" +import { AddNewFolder } from "@/components/file-manager/AddNewFolder" +import { useNavigate } from "react-router-dom" export function PrivateFilesPage() { + const { socket } = useSocket() const [viewMode, setViewMode] = useState<"grid" | "list">("grid") const [searchQuery, setSearchQuery] = useState("") const [selectedFiles, setSelectedFiles] = useState([]) + const [currentPath, setCurrentPath] = useState>([]) const [showSensitiveOnly, setShowSensitiveOnly] = useState(false) const [isPinVerified, setIsPinVerified] = useState(false) const [showPinModal, setShowPinModal] = useState(false) const [isCheckingSession, setIsCheckingSession] = useState(true) const hasCheckedSession = useRef(false) - const { deleteFileOrFolder } = useFile() + const { deleteFileOrFolder, privateFiles, createFolder } = useFile() const { user, getPinSession } = useAuth() + const navigate = useNavigate() // Check for active PIN session on mount (only once) useEffect(() => { @@ -205,11 +111,28 @@ export function PrivateFilesPage() { setShowPinModal(false) } - const filteredFiles = mockPrivateFiles.filter((file) => { - const matchesSearch = file.name.toLowerCase().includes(searchQuery.toLowerCase()) - const matchesSensitive = !showSensitiveOnly || file.sensitive - return matchesSearch && matchesSensitive - }) + // Transform dynamic data to PrivateFileItem format + const transformedPrivateFiles = useMemo(() => { + return transformFileSystemNodesToPrivateFileItems(privateFiles) + }, [privateFiles]) + + // Get current items based on currentPath (similar to all-files-page) + let currentItems = useMemo(() => { + let items: PrivateFileItem[] = transformedPrivateFiles + for (const folder of currentPath) { + const foundFolder = items.find((item) => item.id === folder.id && item.type === "folder") + if (foundFolder?.children) { + items = foundFolder.children as PrivateFileItem[] + } + } + return items + }, [currentPath, transformedPrivateFiles]) + + const filteredFiles = useMemo(() => { + const matchesSearch = (file: PrivateFileItem) => file.name.toLowerCase().includes(searchQuery.toLowerCase()) + const matchesSensitive = (file: PrivateFileItem) => !showSensitiveOnly || file.sensitive + return currentItems.filter((file) => matchesSearch(file) && matchesSensitive(file)) + }, [currentItems, searchQuery, showSensitiveOnly]) const toggleFileSelection = (fileId: string) => { setSelectedFiles((prev) => (prev.includes(fileId) ? prev.filter((id) => id !== fileId) : [...prev, fileId])) @@ -219,8 +142,66 @@ export function PrivateFilesPage() { setSelectedFiles(selectedFiles.length === filteredFiles.length ? [] : filteredFiles.map((f) => f.id)) } - const encryptedCount = mockPrivateFiles.filter((f) => f.encrypted).length - const sensitiveCount = mockPrivateFiles.filter((f) => f.sensitive).length + const handleItemClick = (item: FileItem) => { + if (item.type === "folder") { + setCurrentPath([...currentPath, { id: item.id, name: item.name }]) + setSelectedFiles([]) + setSearchQuery("") + } else { + if (!socket) return; + socket?.emit("last_accessed", { file_id: item.id }) + } + } + + const handleBreadcrumbNavigate = (index: number) => { + if (index === -1) { + setCurrentPath([]) + } else { + setCurrentPath(currentPath.slice(0, index + 1)) + } + setSelectedFiles([]) + setSearchQuery("") + } + + const handleBackClick = () => { + if (currentPath.length > 0) { + setCurrentPath(currentPath.slice(0, -1)) + setSelectedFiles([]) + } + } + + const encryptedCount = filteredFiles.filter((f) => f.encrypted).length + const sensitiveCount = filteredFiles.filter((f) => f.sensitive).length + + const handleCreateFolder = async (folderName: string): Promise<{ success: boolean; error?: string }> => { + try { + // Find the parent folder ID based on current path + let parentId: string | undefined = undefined + + // Get the last folder in the current path as the parent + if (currentPath.length > 0) { + const lastFolder = currentPath[currentPath.length - 1] + parentId = lastFolder.id + } + + const result = await createFolder({ + name: folderName.trim(), + parent_id: parentId, + access_level: ACCESS_LEVEL.PRIVATE // Set access level to private for private files page + }) + + if (result.success) { + toast.success("Private folder created successfully!") + } + + return result + } catch (error: any) { + return { + success: false, + error: error?.message || 'Failed to create folder' + } + } + } const handleDeleteFile = async (file: FileItem) => { try { @@ -240,7 +221,7 @@ export function PrivateFilesPage() { const actionHandlers: FileActionHandlers = { onFileSelect: toggleFileSelection, - onItemClick: (item) => console.log("Clicked on private item:", item.name), + onItemClick: handleItemClick, onDownload: (file) => console.log("Download file:", file.name), onShare: (file) => console.log("Share file:", file.name), onDelete: handleDeleteFile, @@ -294,28 +275,59 @@ export function PrivateFilesPage() { {/* Header */}
-
-
- -

Private Files

+
+ {currentPath.length > 0 && ( + + )} +
+
+ +

Private Files

+
+

+ {filteredFiles.length} private items • {encryptedCount} encrypted • {sensitiveCount} sensitive +

-

- {filteredFiles.length} private items • {encryptedCount} encrypted • {sensitiveCount} sensitive -

- - +
+ {currentPath.length > 0 && ( + + )} + {/* Toolbar */} @@ -411,6 +423,7 @@ export function PrivateFilesPage() { viewConfig={defaultViewConfig} actionHandlers={actionHandlers} viewMode={viewMode} + onCreateFolder={handleCreateFolder} />
diff --git a/src/routes/Upload.tsx b/src/routes/Upload.tsx index 47a06d4..1609e3c 100644 --- a/src/routes/Upload.tsx +++ b/src/routes/Upload.tsx @@ -6,7 +6,7 @@ const Upload = () => { const { folder_id } = useParams<{ folder_id: string }>(); const location = useLocation(); const navigate = useNavigate(); - const { folder_name } = location.state || {}; + const { folder_name, access_level } = location.state || {}; const allowedTypes: FileConfig[] = [ { @@ -56,6 +56,7 @@ const Upload = () => { allowedTypes={allowedTypes} maxFiles={10} folderId={folder_id} + accessLevel={access_level} />
From db9f826bceda6fd1882163a677afc7a38b5e457e Mon Sep 17 00:00:00 2001 From: venu123143 Date: Thu, 20 Nov 2025 00:31:21 +0530 Subject: [PATCH 07/22] skip and forward 10 seconds in the video player changes --- src/components/player/VideoPlayer.tsx | 111 ++++++++++++++++++++++---- 1 file changed, 97 insertions(+), 14 deletions(-) diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index 8c6992d..83a291b 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -176,6 +176,18 @@ export function VideoPlayer({ const playerRef = useRef(null); const containerRef = useRef(null); const doubleClickHandlerRef = useRef<((event: MouseEvent) => void) | null>(null); + const touchStartHandlerRef = useRef<((event: TouchEvent) => void) | null>(null); + const touchHandlerRef = useRef<{ + lastTapTime: number; + lastTapX: number; + lastTapY: number; + tapTimeout: NodeJS.Timeout | null; + }>({ + lastTapTime: 0, + lastTapX: 0, + lastTapY: 0, + tapTimeout: null + }); useEffect(() => { // Add custom styles to document @@ -254,13 +266,10 @@ export function VideoPlayer({ if (onError) onError(error); }); - // Double-click handler for skip forward/backward - const handleDoubleClick = (event: MouseEvent) => { - const playerEl = player.el() as HTMLElement | null; - if (!playerEl) return; - + // Skip handler function (shared for both mouse and touch) + const handleSkip = (x: number, playerEl: HTMLElement) => { const rect = playerEl.getBoundingClientRect(); - const clickX = event.clientX - rect.left; + const clickX = x - rect.left; const playerWidth = rect.width; const isLeftSide = clickX < playerWidth / 2; @@ -288,13 +297,76 @@ export function VideoPlayer({ } }; - // Store handler reference for cleanup + // Double-click handler for skip forward/backward (desktop) + const handleDoubleClick = (event: MouseEvent) => { + const playerEl = player.el() as HTMLElement | null; + if (!playerEl) return; + handleSkip(event.clientX, playerEl); + }; + + // Double-tap handler for skip forward/backward (mobile) + const handleTouchStart = (event: TouchEvent) => { + const playerEl = player.el() as HTMLElement | null; + if (!playerEl) return; + + const touch = event.touches[0]; + if (!touch) return; + + const currentTime = Date.now(); + const tapX = touch.clientX; + const tapY = touch.clientY; + const { lastTapTime, lastTapX, lastTapY, tapTimeout } = touchHandlerRef.current; + + // Clear any existing timeout + if (tapTimeout) { + clearTimeout(tapTimeout); + touchHandlerRef.current.tapTimeout = null; + } + + // Check if this is a double-tap (within 300ms and similar position) + const timeDiff = currentTime - lastTapTime; + const xDiff = Math.abs(tapX - lastTapX); + const yDiff = Math.abs(tapY - lastTapY); + const maxDistance = 50; // Maximum distance between taps to be considered a double-tap + + if ( + timeDiff < 300 && + timeDiff > 0 && + xDiff < maxDistance && + yDiff < maxDistance + ) { + // Double-tap detected + event.preventDefault(); + handleSkip(tapX, playerEl); + // Reset tap tracking + touchHandlerRef.current.lastTapTime = 0; + touchHandlerRef.current.lastTapX = 0; + touchHandlerRef.current.lastTapY = 0; + } else { + // Store this tap for potential double-tap + touchHandlerRef.current.lastTapTime = currentTime; + touchHandlerRef.current.lastTapX = tapX; + touchHandlerRef.current.lastTapY = tapY; + + // Set timeout to reset if no second tap + touchHandlerRef.current.tapTimeout = setTimeout(() => { + touchHandlerRef.current.lastTapTime = 0; + touchHandlerRef.current.lastTapX = 0; + touchHandlerRef.current.lastTapY = 0; + }, 300); + } + }; + + // Store handler references for cleanup doubleClickHandlerRef.current = handleDoubleClick; + touchStartHandlerRef.current = handleTouchStart; - // Listen for double-click events on the player element + // Listen for double-click events on the player element (desktop) const playerEl = player.el() as HTMLElement | null; if (playerEl) { playerEl.addEventListener('dblclick', handleDoubleClick); + // Listen for touch events (mobile) + playerEl.addEventListener('touchstart', handleTouchStart, { passive: false }); } // Keyboard shortcuts @@ -357,15 +429,26 @@ export function VideoPlayer({ } return () => { - // Clean up double-click listener - if (playerRef.current && doubleClickHandlerRef.current) { + // Clean up event listeners + if (playerRef.current) { const playerEl = playerRef.current.el() as HTMLElement | null; if (playerEl) { - playerEl.removeEventListener('dblclick', doubleClickHandlerRef.current); + // Remove double-click listener (desktop) + if (doubleClickHandlerRef.current) { + playerEl.removeEventListener('dblclick', doubleClickHandlerRef.current); + doubleClickHandlerRef.current = null; + } + // Remove touch listener (mobile) + if (touchStartHandlerRef.current) { + playerEl.removeEventListener('touchstart', touchStartHandlerRef.current); + touchStartHandlerRef.current = null; + } + } + // Clear touch handler timeout + if (touchHandlerRef.current.tapTimeout) { + clearTimeout(touchHandlerRef.current.tapTimeout); + touchHandlerRef.current.tapTimeout = null; } - doubleClickHandlerRef.current = null; - } - if (playerRef.current) { playerRef.current.dispose(); playerRef.current = null; } From e8ab5f20c9df2b9d2378b5b42fd50a1abfbc2d7b Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sat, 22 Nov 2025 14:07:52 +0530 Subject: [PATCH 08/22] ui changes, dark and light mode changes --- README-FILE-MANAGER.md | 255 ----------------- src/api/file.api.ts | 6 + .../file-manager/ChangeAccessLevelModal.tsx | 179 ++++++++++++ src/components/file-manager/FileGridItem.tsx | 47 +-- src/components/file-manager/FileListItem.tsx | 47 +-- src/components/layouts/top-bar.tsx | 30 +- src/components/settings/settings-content.tsx | 270 +++++++++++++----- src/components/settings/settings-header.tsx | 8 +- src/components/settings/settings-page.tsx | 8 +- src/components/ui/button.tsx | 2 +- src/components/ui/sonner.tsx | 6 +- src/components/upload/FIleUploader.tsx | 239 ++++++++++++---- src/config/page-configs.ts | 14 - src/contexts/ThemeContext.tsx | 127 ++++++++ src/contexts/fileContext.tsx | 34 +++ src/css/index.css | 138 +++++++++ src/lib/utils.ts | 1 + src/pages/all-files-page.tsx | 20 +- src/pages/notifications-page.tsx | 52 ++-- src/pages/private-files-page.tsx | 164 ++++++----- src/providers/AppProviders.tsx | 3 +- src/routes/Upload.tsx | 16 +- src/types/file-manager.ts | 4 +- 23 files changed, 1118 insertions(+), 552 deletions(-) delete mode 100644 README-FILE-MANAGER.md create mode 100644 src/components/file-manager/ChangeAccessLevelModal.tsx create mode 100644 src/contexts/ThemeContext.tsx diff --git a/README-FILE-MANAGER.md b/README-FILE-MANAGER.md deleted file mode 100644 index 06c5363..0000000 --- a/README-FILE-MANAGER.md +++ /dev/null @@ -1,255 +0,0 @@ -# File Manager System Documentation - -## Overview - -The File Manager System is a unified, flexible component system that can be used across different pages with minimal configuration changes. It supports multiple file variants (standard, deleted, private, shared) and provides consistent UI patterns while allowing for page-specific customizations. - -## Architecture - -### Core Components - -1. **FileManager** - Main orchestrator component -2. **FileGrid** - Grid view display -3. **FileList** - List view display -4. **FileGridItem** - Individual grid item -5. **FileListItem** - Individual list item -6. **EmptyState** - Empty state display - -### Type System - -The system uses strict TypeScript with discriminated unions for different file types: - -- `StandardFileItem` - Basic file/folder items -- `DeletedFileItem` - Items in trash with deletion metadata -- `PrivateFileItem` - Encrypted/sensitive items -- `SharedFileItem` - Shared items with permission metadata - -## Usage - -### Basic Implementation - -```tsx -import { FileManager } from "@/components/file-manager/FileManager" -import { standardPageConfig, defaultViewConfig } from "@/config/page-configs" -import type { FileActionHandlers } from "@/types/file-manager" - -const actionHandlers: FileActionHandlers = { - onFileSelect: (fileId) => console.log("Select", fileId), - onItemClick: (item) => console.log("Click", item.name), - onDownload: (file) => console.log("Download", file.name), - onShare: (file) => console.log("Share", file.name), - onStar: (file) => console.log("Star", file.name), - onDelete: (file) => console.log("Delete", file.name), -} - - -``` - -### Page Configurations - -#### Standard Page -```tsx -import { standardPageConfig } from "@/config/page-configs" - -// Shows: thumbnails, file type, size, modified date, starred, shared -// Actions: download, share, star, delete -``` - -#### Deleted Files Page -```tsx -import { deletedPageConfig } from "@/config/page-configs" - -// Shows: file type, size, deletion info, days left -// Actions: restore, delete -// Custom: Restore action -``` - -#### Private Files Page -```tsx -import { privatePageConfig } from "@/config/page-configs" - -// Shows: thumbnails, file type, size, modified date, starred, encrypted status, sensitive status -// Actions: download, share, star, delete, encrypt, decrypt -// Custom: Encrypt/Decrypt actions -``` - -#### Shared Files Page -```tsx -import { sharedPageConfig } from "@/config/page-configs" - -// Shows: thumbnails, file type, size, modified date, starred, shared status, sharing info -// Actions: download, share, star, delete, change permission -// Custom: Change Permission action -``` - -### View Configurations - -#### Default View -```tsx -import { defaultViewConfig } from "@/config/page-configs" - -// Grid: 2-6 columns responsive, standard gaps -// List: 16px height, shows thumbnails, file type, size, modified date -``` - -#### Compact View -```tsx -import { compactViewConfig } from "@/config/page-configs" - -// Grid: 3-7 columns responsive, smaller gaps -// List: 12px height, no thumbnails, shows file type, size, modified date -``` - -### Custom Actions - -You can add custom actions to any page configuration: - -```tsx -const customPageConfig: PageConfig = { - ...standardPageConfig, - customActions: [ - { - label: "Custom Action", - icon: CustomIcon, - action: (file) => console.log("Custom action for", file.name), - variant: "default" // or "destructive", "secondary" - } - ] -} -``` - -### Action Handlers - -Implement only the handlers you need: - -```tsx -const actionHandlers: FileActionHandlers = { - // Required - onFileSelect: (fileId) => setSelectedFiles(prev => [...prev, fileId]), - onItemClick: (item) => handleItemClick(item), - - // Optional - only implement what you need - onDownload: (file) => downloadFile(file), - onShare: (file) => shareFile(file), - onStar: (file) => toggleStar(file), - onDelete: (file) => deleteFile(file), - onRestore: (file) => restoreFile(file), - onEncrypt: (file) => encryptFile(file), - onDecrypt: (file) => decryptFile(file), - onPermissionChange: (file, permission) => updatePermission(file, permission), - onCustomAction: (action, file) => handleCustomAction(action, file) -} -``` - -## Features - -### Responsive Design -- Grid view adapts columns based on screen size -- List view maintains readability across devices -- Touch-friendly interactions - -### Accessibility -- Proper ARIA labels -- Keyboard navigation support -- Screen reader friendly - -### Performance -- Virtual scrolling support (can be added) -- Lazy loading of thumbnails -- Efficient re-renders with React.memo - -### Customization -- Configurable display options -- Custom action support -- Theme-aware styling -- Flexible layout options - -## Migration Guide - -### From Old Components - -**Before:** -```tsx - -``` - -**After:** -```tsx - -``` - -### Data Structure Updates - -Add `variant` property to all file items: - -```tsx -// Before -const file = { - id: "1", - name: "document.pdf", - type: "file", - // ... other properties -} - -// After -const file = { - id: "1", - name: "document.pdf", - type: "file", - variant: "standard", // Add this - // ... other properties -} -``` - -## Best Practices - -1. **Use Type Guards**: Always use the provided type guards when checking file variants -2. **Implement Required Handlers**: Always implement `onFileSelect` and `onItemClick` -3. **Conditional Actions**: Only show actions that have implemented handlers -4. **Consistent Styling**: Use the provided CSS classes for consistent appearance -5. **Performance**: Avoid creating new objects in render functions - -## Troubleshooting - -### Common Issues - -1. **TypeScript Errors**: Ensure all file items have the `variant` property -2. **Missing Actions**: Check that required action handlers are implemented -3. **Styling Issues**: Verify CSS classes are properly imported -4. **Performance**: Use React.memo for expensive components - -### Debug Mode - -Enable debug logging by setting environment variable: -```bash -REACT_APP_DEBUG_FILE_MANAGER=true -``` - -## Examples - -See the following files for complete examples: -- `src/pages/all-files-page.tsx` - Standard implementation -- `src/config/page-configs.ts` - Configuration examples -- `src/types/file-manager.ts` - Type definitions diff --git a/src/api/file.api.ts b/src/api/file.api.ts index 2b8f13d..3baeafd 100644 --- a/src/api/file.api.ts +++ b/src/api/file.api.ts @@ -78,6 +78,11 @@ const getRecents = async (page: number = 1, limit: number = 20) => { return response.data; }; +const updateFileAccessLevel = async (id: string, data: any) => { + const response = await apiClient.patch(`/file-flow/file/${id}/update-access-level`, data); + return response.data; +}; + export default { createFolder, renameFolder, @@ -94,4 +99,5 @@ export default { restoreFileOrFolder, emptyTrash, getRecents, + updateFileAccessLevel, }; diff --git a/src/components/file-manager/ChangeAccessLevelModal.tsx b/src/components/file-manager/ChangeAccessLevelModal.tsx new file mode 100644 index 0000000..4f94d98 --- /dev/null +++ b/src/components/file-manager/ChangeAccessLevelModal.tsx @@ -0,0 +1,179 @@ +"use client" + +import { useState, useEffect } from "react" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Label } from "@/components/ui/label" +import { Shield, Lock, Globe, Users } from "lucide-react" +import { ACCESS_LEVEL, type AccessLevel } from "@/types/file.types" +import type { FileItem } from "@/types/file-manager" + +interface ChangeAccessLevelModalProps { + isOpen: boolean + onClose: () => void + file: FileItem | null + currentAccessLevel?: string + onConfirm: (fileId: string, accessLevel: AccessLevel) => Promise<{ success: boolean; error?: string }> +} + +const accessLevelOptions = [ + { + value: ACCESS_LEVEL.PUBLIC, + label: "Public", + description: "Anyone can access this file", + icon: Globe, + color: "text-green-600 dark:text-green-400" + }, + { + value: ACCESS_LEVEL.PROTECTED, + label: "Protected", + description: "Only users you share with can access", + icon: Users, + color: "text-blue-600 dark:text-blue-400" + }, + { + value: ACCESS_LEVEL.PRIVATE, + label: "Private", + description: "Only you can access this file", + icon: Lock, + color: "text-red-600 dark:text-red-400" + } +] + +export function ChangeAccessLevelModal({ + isOpen, + onClose, + file, + currentAccessLevel, + onConfirm +}: ChangeAccessLevelModalProps) { + const [selectedLevel, setSelectedLevel] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + // Set initial selected level when modal opens + useEffect(() => { + if (isOpen && currentAccessLevel) { + setSelectedLevel(currentAccessLevel as AccessLevel) + } else if (isOpen && file?.access_level) { + setSelectedLevel(file.access_level as AccessLevel) + } + }, [isOpen, currentAccessLevel, file]) + + const handleConfirm = async () => { + if (!file || !selectedLevel) return + + setIsLoading(true) + setError(null) + + try { + const result = await onConfirm(file.id, selectedLevel) + if (result.success) { + onClose() + setSelectedLevel(null) + } else { + setError(result.error || "Failed to update access level") + } + } catch (err: any) { + setError(err.message || "An error occurred") + } finally { + setIsLoading(false) + } + } + + const handleClose = () => { + setSelectedLevel(null) + setError(null) + onClose() + } + + if (!file) return null + + return ( + + + + + + Change Access Level + + + Select a new access level for {file.name} + + + +
+
+ {accessLevelOptions.map((option) => { + const Icon = option.icon + const isSelected = selectedLevel === option.value + const isCurrent = currentAccessLevel === option.value + + return ( + + ) + })} +
+ + {error && ( +
+

{error}

+
+ )} + +
+ + +
+
+
+
+ ) +} + diff --git a/src/components/file-manager/FileGridItem.tsx b/src/components/file-manager/FileGridItem.tsx index b31bfd8..7e43ac0 100644 --- a/src/components/file-manager/FileGridItem.tsx +++ b/src/components/file-manager/FileGridItem.tsx @@ -4,14 +4,15 @@ import { isDeletedFile, isPrivateFile, isSharedFile } from "@/types/file-manager import type { FileItem, PageConfig, FileActionHandlers } from "@/types/file-manager"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; -import { MoreHorizontal, Download, Share2, Edit, Trash2, RotateCcw, Lock, Unlock, Users, Play, FolderOpen } from "lucide-react"; +import { MoreHorizontal, Download, Share2, Edit, Trash2, RotateCcw, Lock, Users, Play, FolderOpen, ShieldCheck, Globe, Check } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { VideoPlayerModal } from "@/components/player/VideoPlayerModal"; import { ImageViewer } from "@/components/custom/ImageViewer"; import { isVideoFile, getVideoFileUrl } from "@/lib/video-utils"; import { isImageFile, getImageFileUrl } from "@/lib/image-utils"; +import { ACCESS_LEVEL } from "@/types/file.types"; interface FileGridItemProps { file: FileItem; @@ -39,10 +40,9 @@ export function FileGridItem({ onRename, onDelete, onRestore, - onEncrypt, - onDecrypt, onCustomAction, - onMove + onMove, + onChangeAccessLevel } = actionHandlers; const isVideo = isVideoFile(file); @@ -178,24 +178,37 @@ export function FileGridItem({ Rename )} + {onChangeAccessLevel && ( + + + + Change Access Level + + + onChangeAccessLevel(file, ACCESS_LEVEL.PUBLIC)}> + + Public + {file.access_level === ACCESS_LEVEL.PUBLIC && } + + onChangeAccessLevel(file, ACCESS_LEVEL.PROTECTED)}> + + Protected + {file.access_level === ACCESS_LEVEL.PROTECTED && } + + onChangeAccessLevel(file, ACCESS_LEVEL.PRIVATE)}> + + Private + {file.access_level === ACCESS_LEVEL.PRIVATE && } + + + + )} {onRestore && isDeletedFile(file) && ( onRestore(file)}> Restore )} - {onEncrypt && isPrivateFile(file) && !file.encrypted && ( - onEncrypt(file)}> - - Encrypt - - )} - {onDecrypt && isPrivateFile(file) && file.encrypted && ( - onDecrypt(file)}> - - Decrypt - - )} {renderCustomActions()} {onDelete && ( <> diff --git a/src/components/file-manager/FileListItem.tsx b/src/components/file-manager/FileListItem.tsx index 111c160..2fd0b2a 100644 --- a/src/components/file-manager/FileListItem.tsx +++ b/src/components/file-manager/FileListItem.tsx @@ -3,14 +3,15 @@ import { useState } from "react"; import { isDeletedFile, isPrivateFile, isSharedFile } from "@/types/file-manager"; import type { FileItem, PageConfig, ViewConfig, FileActionHandlers } from "@/types/file-manager"; import { Checkbox } from "@/components/ui/checkbox"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { VideoPlayerModal } from "@/components/player/VideoPlayerModal"; import { ImageViewer } from "@/components/custom/ImageViewer"; import { isVideoFile, getVideoFileUrl } from "@/lib/video-utils"; import { isImageFile, getImageFileUrl } from "@/lib/image-utils"; -import { MoreHorizontal, Download, Share2, Edit, Trash2, RotateCcw, Lock, Unlock, Users, Shield, Play, FolderOpen } from "lucide-react"; +import { ACCESS_LEVEL } from "@/types/file.types"; +import { MoreHorizontal, Download, Share2, Edit, Trash2, RotateCcw, Lock, Users, Shield, Play, FolderOpen, ShieldCheck, Globe, Check } from "lucide-react"; interface FileListItemProps { file: FileItem; @@ -40,10 +41,9 @@ export function FileListItem({ onRename, onDelete, onRestore, - onEncrypt, - onDecrypt, onCustomAction, - onMove + onMove, + onChangeAccessLevel } = actionHandlers; const isVideo = isVideoFile(file); @@ -287,24 +287,37 @@ export function FileListItem({ Rename )} + {onChangeAccessLevel && ( + + + + Change Access Level + + + onChangeAccessLevel(file, ACCESS_LEVEL.PUBLIC)}> + + Public + {file.access_level === ACCESS_LEVEL.PUBLIC && } + + onChangeAccessLevel(file, ACCESS_LEVEL.PROTECTED)}> + + Protected + {file.access_level === ACCESS_LEVEL.PROTECTED && } + + onChangeAccessLevel(file, ACCESS_LEVEL.PRIVATE)}> + + Private + {file.access_level === ACCESS_LEVEL.PRIVATE && } + + + + )} {onRestore && isDeletedFile(file) && ( onRestore(file)}> Restore )} - {onEncrypt && isPrivateFile(file) && !file.encrypted && ( - onEncrypt(file)}> - - Encrypt - - )} - {onDecrypt && isPrivateFile(file) && file.encrypted && ( - onDecrypt(file)}> - - Decrypt - - )} {renderCustomActions()} {onDelete && ( <> diff --git a/src/components/layouts/top-bar.tsx b/src/components/layouts/top-bar.tsx index 959695c..8653ff7 100644 --- a/src/components/layouts/top-bar.tsx +++ b/src/components/layouts/top-bar.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { useLocation, Link } from "react-router-dom" -import { Menu } from "lucide-react" +import { Menu, Sun, Moon } from "lucide-react" import { Breadcrumb, BreadcrumbItem, @@ -11,8 +11,10 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb" +import { Button } from "@/components/ui/button" import { UserDropdown } from "@/components/user/user-dropdown" import { useAuth } from '@/contexts/useAuth' +import { useTheme } from '@/contexts/ThemeContext' interface TopBarProps { onSidebarToggle: () => void @@ -21,6 +23,7 @@ interface TopBarProps { export function TopBar({ onSidebarToggle }: TopBarProps) { const location = useLocation() const { user } = useAuth() + const { resolvedTheme, toggleTheme } = useTheme() const generateBreadcrumbs = () => { const pathnames = location.pathname.split('/').filter(x => x) @@ -55,13 +58,14 @@ export function TopBar({ onSidebarToggle }: TopBarProps) {
{/* Sidebar Toggle Button */} - + @@ -82,8 +86,22 @@ export function TopBar({ onSidebarToggle }: TopBarProps) {
- - +
+ {/* Theme Toggle Button */} + + +
) } diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 1f368f0..6819a30 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -9,21 +9,30 @@ import { Switch } from "@/components/ui/switch" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { useAuth } from "@/contexts/useAuth" -import { - User, - Shield, - Bell, - Smartphone, - HardDrive, - Globe, - Lock, - Eye, +import { useTheme } from "@/contexts/ThemeContext" +import { + User, + Shield, + Bell, + Smartphone, + HardDrive, + Globe, + Lock, + Eye, Download, Upload, Settings, Palette, Key, - CheckCircle2 + CheckCircle2, + Sun, + Moon, + Monitor, + Mail, + UserCircle, + Calendar, + CheckCircle, + XCircle, } from "lucide-react" interface SettingsContentProps { @@ -32,6 +41,7 @@ interface SettingsContentProps { export function SettingsContent({ activeTab }: SettingsContentProps) { const { user, setPin, changePin, setPinLoading, changePinLoading, saveUser } = useAuth() + const { theme, setTheme, resolvedTheme } = useTheme() const [pin, setPinValue] = useState("") const [confirmPin, setConfirmPin] = useState("") const [oldPin, setOldPin] = useState("") @@ -42,18 +52,18 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { const handleSetPin = async () => { setError("") - + // Validation if (!pin || pin.length !== 4) { setError("PIN must be exactly 4 digits") return } - + if (!/^\d+$/.test(pin)) { setError("PIN must contain only numbers") return } - + if (pin !== confirmPin) { setError("PINs do not match") return @@ -76,23 +86,23 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { const handleChangePin = async () => { setError("") - + // Validation if (!oldPin || oldPin.length !== 4) { setError("Old PIN must be exactly 4 digits") return } - + if (!newPin || newPin.length !== 4) { setError("New PIN must be exactly 4 digits") return } - + if (!/^\d+$/.test(oldPin) || !/^\d+$/.test(newPin)) { setError("PINs must contain only numbers") return } - + if (newPin !== confirmNewPin) { setError("New PINs do not match") return @@ -131,25 +141,110 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { Profile Settings - Manage your account information and preferences + Your account information and details
+ {user?.display_name && ( +
+ + +
+ )} +
+ + +
- - + +
- - + +
+ {user?.last_login && ( +
+ + +
+ )}
-
- - + +
+
+
+ {user?.email_verified ? ( + + ) : ( + + )} +
+

Email Verified

+

+ {user?.email_verified ? "Verified" : "Not verified"} +

+
+
+
+
+
+ {user?.is_active ? ( + + ) : ( + + )} +
+

Account Status

+

+ {user?.is_active ? "Active" : "Inactive"} +

+
+
+
- @@ -172,9 +267,9 @@ export function SettingsContent({ activeTab }: SettingsContentProps) {
-
+
Free Plan - + 100 GB total storage
@@ -191,20 +286,46 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { Customize the look and feel of your application - -
-
- -

Switch between light and dark themes

+ +
+
+ +

Choose your preferred color scheme

- -
-
-
- -

Reduce spacing for more content

+
+ + +
- + {theme === 'system' && ( +

+ Currently using {resolvedTheme === 'dark' ? 'dark' : 'light'} mode based on your system preference +

+ )}
@@ -260,7 +381,7 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { -
+

Use an authenticator app for additional security

@@ -283,14 +404,14 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { -
+

Allow others to see your profile

-
+

Help improve the app with usage data

@@ -323,21 +444,21 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { -
+

Get notified about security events

-
+

Receive storage usage notifications

-
+

Stay informed about new features

@@ -359,14 +480,14 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { -
+

When someone shares files with you

-
+

When approaching storage limits

@@ -405,9 +526,9 @@ export function SettingsContent({ activeTab }: SettingsContentProps) {
{!isChangingPin ? ( <> -
- -
+
+ +

PIN Already Set

You have already set a PIN for your account. Your session will be created when you verify your PIN. @@ -415,8 +536,8 @@ export function SettingsContent({ activeTab }: SettingsContentProps) {

-
)} -
- - @@ -567,8 +689,8 @@ export function SettingsContent({ activeTab }: SettingsContentProps) {

{error}

)} - +
-
+
-
+
-
+

Dropbox

Connected for backup

- +
@@ -651,10 +773,12 @@ export function SettingsContent({ activeTab }: SettingsContentProps) {
-
- - - +
+ +
+ + +

diff --git a/src/components/settings/settings-header.tsx b/src/components/settings/settings-header.tsx index b2e4dfc..ed79132 100644 --- a/src/components/settings/settings-header.tsx +++ b/src/components/settings/settings-header.tsx @@ -1,8 +1,10 @@ -export function SettingsHeader() { +import type { IUser } from "@/types/user.types" + +export function SettingsHeader({ user }: { user: IUser | null }) { return (

-

Sophie Chamberlain

+

Hi, {user?.display_name}

Manage your details and personal preferences here.

) -} +} diff --git a/src/components/settings/settings-page.tsx b/src/components/settings/settings-page.tsx index 8921c11..d779bdb 100644 --- a/src/components/settings/settings-page.tsx +++ b/src/components/settings/settings-page.tsx @@ -4,13 +4,13 @@ import { useState } from "react" import { SettingsHeader } from "./settings-header" import { SettingsTabs } from "./settings-tabs" import { SettingsContent } from "./settings-content" - +import { useAuth } from "@/contexts/useAuth" export function SettingsPage() { const [activeTab, setActiveTab] = useState("general") - + const { user } = useAuth() return ( -
- +
+
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 4c3a29c..7f8783a 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 0cc2740..03669bd 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -1,12 +1,12 @@ -import { useTheme } from "@/hooks/useTheme" +import { useTheme } from "@/contexts/ThemeContext" import { Toaster as Sonner, type ToasterProps } from "sonner" const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() + const { resolvedTheme } = useTheme() return ( = ({ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; - const getStatusColor = (status: string) => { + const getStatusColor = (status: string): React.CSSProperties => { switch (status) { - case 'completed': return 'text-green-600'; - case 'error': return 'text-red-600'; - case 'processing': return 'text-orange-600'; - case 'uploading': return 'text-blue-600'; - default: return 'text-gray-600'; + case 'completed': return { color: 'var(--color-upload-success-icon)' }; + case 'error': return { color: 'var(--color-upload-button-red)' }; + case 'processing': return { color: 'var(--color-upload-warning-icon)' }; + case 'uploading': return { color: 'var(--color-upload-icon-text)' }; + default: return { color: 'var(--color-upload-text-secondary)' }; } }; - const getProgressBarColor = (status: string) => { + const getProgressBarColor = (status: string): React.CSSProperties => { switch (status) { - case 'completed': return 'bg-green-500'; - case 'error': return 'bg-red-500'; - case 'processing': return 'bg-orange-500'; - case 'uploading': return 'bg-blue-500'; - default: return 'bg-gray-300'; + case 'completed': return { backgroundColor: 'var(--color-upload-success-icon)' }; + case 'error': return { backgroundColor: 'var(--color-upload-button-red)' }; + case 'processing': return { backgroundColor: 'var(--color-upload-warning-icon)' }; + case 'uploading': return { backgroundColor: 'var(--color-upload-icon-text)' }; + default: return { backgroundColor: 'var(--color-upload-progress-bg)' }; } }; @@ -353,10 +353,23 @@ const FileUploader: React.FC = ({ {/* Drop Zone */}
{ + if (!isDragOver) { + e.currentTarget.style.borderColor = 'var(--color-upload-dropzone-border-hover)'; + e.currentTarget.style.backgroundColor = 'var(--color-upload-dropzone-bg-hover)'; + } + }} + onMouseLeave={(e) => { + if (!isDragOver) { + e.currentTarget.style.borderColor = 'var(--color-upload-dropzone-border)'; + e.currentTarget.style.backgroundColor = 'transparent'; + } + }} onDrop={onDrop} onDragOver={(e) => { e.preventDefault(); @@ -375,22 +388,38 @@ const FileUploader: React.FC = ({ />
-
- +
+
-

+

{isDragOver ? 'Release to upload' : 'Drop files here or click to browse'}

-

+

Maximum {maxFiles} file{maxFiles > 1 ? 's' : ''} allowed

{allowedTypes.map((config, index) => ( {config.type} (max {config.maxSize}MB) @@ -401,14 +430,29 @@ const FileUploader: React.FC = ({
{/* Warning Message */} {showWarning && ( -
+
- +
-

+

Upload in Progress

-

+

Please do not refresh or close this page while files are being uploaded. You can freely go to other pages - uploads will continue in the background.

@@ -418,18 +462,40 @@ const FileUploader: React.FC = ({ )} {/* Close Button - Show when all files are completed */} {allFilesCompleted && ( -
-
- - +
+
+ + All files uploaded successfully!
@@ -437,8 +503,14 @@ const FileUploader: React.FC = ({ {/* File List */} {selectedFiles.length > 0 && ( -
-

+
+

Selected Files ({selectedFiles.length}/{maxFiles})

@@ -461,25 +533,47 @@ const FileUploader: React.FC = ({ return (
-
-
+
+
{getFileIcon(getFileType(file))}
-

+

{file.name}

-

+

{formatFileSize(file.size)}

@@ -488,7 +582,7 @@ const FileUploader: React.FC = ({ {/* Progress Bar */}
- + {fileState.status === 'processing' ? 'Processing...' : fileState.status === 'error' ? 'Failed' : fileState.status === 'completed' ? 'Completed' @@ -496,26 +590,42 @@ const FileUploader: React.FC = ({ : 'Ready to upload'} {fileState.status === 'uploading' && fileState.totalChunks > 1 && ( - + Chunk {fileState.lastUploadedChunk}/{fileState.totalChunks} )}
-
+
{/* Action Buttons */} -
-
+
+
{fileState.status === 'idle' && (
{fileState.error && ( -

+

{fileState.error}

)} diff --git a/src/config/page-configs.ts b/src/config/page-configs.ts index dc1a9e6..4acfe2b 100644 --- a/src/config/page-configs.ts +++ b/src/config/page-configs.ts @@ -38,20 +38,6 @@ export const privatePageConfig: PageConfig = { showSensitive: true, emptyStateMessage: "No private files found", emptyStateIcon: Lock, - customActions: [ - { - label: "Encrypt", - icon: Lock, - action: () => {}, - variant: "secondary", - }, - { - label: "Decrypt", - icon: Lock, - action: () => {}, - variant: "secondary", - }, - ], }; // Shared files page configuration diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..b8ede7e --- /dev/null +++ b/src/contexts/ThemeContext.tsx @@ -0,0 +1,127 @@ +import React, { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; + +export type Theme = 'light' | 'dark' | 'system'; + +interface ThemeContextType { + theme: Theme; + resolvedTheme: 'light' | 'dark'; + setTheme: (theme: Theme) => void; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +// Get stored theme from localStorage +function getStoredTheme(): Theme | null { + if (typeof window === 'undefined') return null; + return localStorage.getItem('theme') as Theme | null; +} + +// Get system preferred theme +function getSystemTheme(): 'light' | 'dark' { + if (typeof window === 'undefined') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +// Get initial theme +function getInitialTheme(): Theme { + return getStoredTheme() || 'system'; +} + +// Resolve theme to actual light/dark +function resolveTheme(theme: Theme): 'light' | 'dark' { + if (theme === 'system') { + return getSystemTheme(); + } + return theme; +} + +export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + // Initialize theme and apply immediately to prevent flash + const initialTheme = getInitialTheme(); + const initialResolved = resolveTheme(initialTheme); + + // Apply theme synchronously before first render + if (typeof window !== 'undefined') { + const root = window.document.documentElement; + if (initialResolved === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + } + + const [theme, setThemeState] = useState(initialTheme); + const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(initialResolved); + + // Apply theme to document when it changes + useEffect(() => { + const root = window.document.documentElement; + const resolved = resolveTheme(theme); + + setResolvedTheme(resolved); + + if (resolved === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + + // Store theme preference + localStorage.setItem('theme', theme); + }, [theme]); + + // Listen for system theme changes when theme is 'system' + useEffect(() => { + if (theme !== 'system') return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = () => { + const resolved = resolveTheme(theme); + setResolvedTheme(resolved); + const root = window.document.documentElement; + if (resolved === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, [theme]); + + const setTheme = (newTheme: Theme) => { + setThemeState(newTheme); + }; + + const toggleTheme = () => { + // Toggle between light and dark (skip system for toggle) + if (theme === 'light') { + setTheme('dark'); + } else if (theme === 'dark') { + setTheme('light'); + } else { + // If system, toggle to opposite of current resolved theme + setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); + } + }; + + const value: ThemeContextType = { + theme, + resolvedTheme, + setTheme, + toggleTheme, + }; + + return {children}; +}; + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + return context; +}; + diff --git a/src/contexts/fileContext.tsx b/src/contexts/fileContext.tsx index 7080e7d..dcd2da7 100644 --- a/src/contexts/fileContext.tsx +++ b/src/contexts/fileContext.tsx @@ -10,6 +10,7 @@ import type { ShareFileOrFolderInput, FileSystemNode, SharedFileSystemNode, + AccessLevel, } from '@/types/file.types'; interface FileState { @@ -85,6 +86,7 @@ interface FileContextType extends FileState { deleteFileOrFolder: (id: string) => MutationResult; restoreFileOrFolder: (id: string) => MutationResult; emptyTrash: () => MutationResult; + updateFileAccessLevel: (id: string, data: { access_level: AccessLevel }) => MutationResult; } const FileContext = createContext(undefined); @@ -528,6 +530,37 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => } }; + const { mutateAsync: updateFileAccessLevelMutationFn } = useMutation({ + mutationFn: async ({ id, data }: { id: string; data: { access_level: AccessLevel } }) => { + const result = await fileApi.updateFileAccessLevel(id, data); + return result.data; + }, + retry: false, + onSuccess: async () => { + // Refetch both queries to ensure data is up to date after access level change + await Promise.all([ + queryClient.refetchQueries({ queryKey: ['fileSystemTree'] }), + queryClient.refetchQueries({ queryKey: ['privateFiles'] }), + ]); + dispatch({ type: 'SET_LOADING', loading: false }); + }, + onError: () => { + dispatch({ type: 'SET_LOADING', loading: false }); + }, + }); + + const updateFileAccessLevel = async (id: string, data: { access_level: AccessLevel }) => { + try { + dispatch({ type: 'SET_LOADING', loading: true }); + await updateFileAccessLevelMutationFn({ id, data }); + return { success: true }; + } catch (error: any) { + dispatch({ type: 'SET_LOADING', loading: false }); + const errorMessage = error?.response?.data?.message || error?.message || 'Failed to update file access level.'; + return { success: false, error: errorMessage }; + } + }; + const value: FileContextType = { ...state, loading: state.loading || fileSystemTreeLoading || trashLoading || privateFilesLoading, @@ -546,6 +579,7 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => deleteFileOrFolder, restoreFileOrFolder, emptyTrash, + updateFileAccessLevel, }; return {children}; diff --git a/src/css/index.css b/src/css/index.css index 2a7c15b..79cd524 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -44,6 +44,52 @@ --error-container: 0 0% 100%; --error-icon: 0 84.2% 60.2%; --error-icon-background: 0 84.2% 95%; + + /* Upload component colors - preserving original light mode */ + --upload-bg-gradient-from: 249 250 251; /* gray-50 */ + --upload-bg-gradient-to: 243 244 246; /* gray-100 */ + --upload-dropzone-border: 209 213 219; /* gray-300 */ + --upload-dropzone-border-hover: 156 163 175; /* gray-400 */ + --upload-dropzone-bg-hover: 243 244 246; /* gray-100 */ + --upload-dropzone-active-border: 96 165 250; /* blue-400 */ + --upload-dropzone-active-bg: 239 246 255; /* blue-50 */ + --upload-icon-bg: 219 234 254; /* blue-100 */ + --upload-icon-text: 37 99 235; /* blue-600 */ + --upload-icon-text-muted: 156 163 175; /* gray-400 */ + --upload-text-primary: 55 65 81; /* gray-700 */ + --upload-text-secondary: 107 114 128; /* gray-500 */ + --upload-text-heading: 17 24 39; /* gray-900 */ + --upload-badge-bg: 239 246 255; /* blue-50 */ + --upload-badge-text: 29 78 216; /* blue-700 */ + --upload-badge-border: 191 219 254; /* blue-200 */ + --upload-warning-bg: 254 252 232; /* yellow-50 */ + --upload-warning-border: 254 240 138; /* yellow-200 */ + --upload-warning-icon: 217 119 6; /* yellow-600 */ + --upload-warning-heading: 133 77 14; /* yellow-800 */ + --upload-warning-text: 161 98 7; /* yellow-700 */ + --upload-success-bg: 240 253 244; /* green-50 */ + --upload-success-border: 187 247 208; /* green-200 */ + --upload-success-icon: 22 163 74; /* green-600 */ + --upload-success-heading: 22 101 52; /* green-800 */ + --upload-success-button: 22 163 74; /* green-600 */ + --upload-success-button-hover: 20 83 45; /* green-700 */ + --upload-filelist-bg: 249 250 251; /* gray-50 */ + --upload-filelist-card: 255 255 255; /* white */ + --upload-filelist-border: 229 231 235; /* gray-200 */ + --upload-filelist-text: 17 24 39; /* gray-900 */ + --upload-filelist-text-muted: 107 114 128; /* gray-500 */ + --upload-filelist-icon: 75 85 99; /* gray-600 */ + --upload-progress-bg: 229 231 235; /* gray-200 */ + --upload-button-blue: 37 99 235; /* blue-600 */ + --upload-button-blue-hover: 29 78 216; /* blue-700 */ + --upload-button-red: 220 38 38; /* red-600 */ + --upload-button-red-hover: 185 28 28; /* red-700 */ + --upload-button-gray: 75 85 99; /* gray-600 */ + --upload-button-gray-hover: 55 65 81; /* gray-700 */ + --upload-button-green: 22 163 74; /* green-600 */ + --upload-button-green-hover: 20 83 45; /* green-700 */ + --upload-remove-hover-text: 220 38 38; /* red-600 */ + --upload-remove-hover-bg: 254 242 242; /* red-50 */ } .dark { @@ -87,6 +133,52 @@ --error-icon: 0 62.8% 50%; --error-icon-background: 0 62.8% 15%; + /* Upload component colors - dark mode */ + --upload-bg-gradient-from: 17 24 39; /* dark gray */ + --upload-bg-gradient-to: 31 41 55; /* darker gray */ + --upload-dropzone-border: 55 65 81; /* gray-700 */ + --upload-dropzone-border-hover: 75 85 99; /* gray-600 */ + --upload-dropzone-bg-hover: 31 41 55; /* gray-800 */ + --upload-dropzone-active-border: 96 165 250; /* blue-400 */ + --upload-dropzone-active-bg: 30 58 138; /* blue-900/30 */ + --upload-icon-bg: 30 58 138; /* blue-900/30 */ + --upload-icon-text: 96 165 250; /* blue-400 */ + --upload-icon-text-muted: 107 114 128; /* gray-500 */ + --upload-text-primary: 209 213 219; /* gray-300 */ + --upload-text-secondary: 156 163 175; /* gray-400 */ + --upload-text-heading: 249 250 251; /* gray-50 */ + --upload-badge-bg: 30 58 138; /* blue-900/30 */ + --upload-badge-text: 147 197 253; /* blue-300 */ + --upload-badge-border: 30 64 175; /* blue-800 */ + --upload-warning-bg: 113 63 18; /* yellow-900/20 */ + --upload-warning-border: 133 77 14; /* yellow-800 */ + --upload-warning-icon: 234 179 8; /* yellow-500 */ + --upload-warning-heading: 250 204 21; /* yellow-400 */ + --upload-warning-text: 234 179 8; /* yellow-500 */ + --upload-success-bg: 20 83 45; /* green-900/20 */ + --upload-success-border: 22 101 52; /* green-800 */ + --upload-success-icon: 34 197 94; /* green-500 */ + --upload-success-heading: 74 222 128; /* green-400 */ + --upload-success-button: 34 197 94; /* green-500 */ + --upload-success-button-hover: 22 163 74; /* green-600 */ + --upload-filelist-bg: 17 24 39; /* gray-900 */ + --upload-filelist-card: 31 41 55; /* gray-800 */ + --upload-filelist-border: 55 65 81; /* gray-700 */ + --upload-filelist-text: 249 250 251; /* gray-50 */ + --upload-filelist-text-muted: 156 163 175; /* gray-400 */ + --upload-filelist-icon: 156 163 175; /* gray-400 */ + --upload-progress-bg: 55 65 81; /* gray-700 */ + --upload-button-blue: 37 99 235; /* blue-600 */ + --upload-button-blue-hover: 29 78 216; /* blue-700 */ + --upload-button-red: 239 68 68; /* red-500 */ + --upload-button-red-hover: 220 38 38; /* red-600 */ + --upload-button-gray: 75 85 99; /* gray-600 */ + --upload-button-gray-hover: 107 114 128; /* gray-500 */ + --upload-button-green: 34 197 94; /* green-500 */ + --upload-button-green-hover: 22 163 74; /* green-600 */ + --upload-remove-hover-text: 239 68 68; /* red-500 */ + --upload-remove-hover-bg: 127 29 29; /* red-900/30 */ + } @theme inline { @@ -132,6 +224,52 @@ --error-container: hsl(var(--error-container)); --error-icon: hsl(var(--error-icon)); --error-icon-background: hsl(var(--error-icon-background)); + + /* Upload component color mappings */ + --color-upload-bg-gradient-from: rgb(var(--upload-bg-gradient-from)); + --color-upload-bg-gradient-to: rgb(var(--upload-bg-gradient-to)); + --color-upload-dropzone-border: rgb(var(--upload-dropzone-border)); + --color-upload-dropzone-border-hover: rgb(var(--upload-dropzone-border-hover)); + --color-upload-dropzone-bg-hover: rgb(var(--upload-dropzone-bg-hover)); + --color-upload-dropzone-active-border: rgb(var(--upload-dropzone-active-border)); + --color-upload-dropzone-active-bg: rgb(var(--upload-dropzone-active-bg)); + --color-upload-icon-bg: rgb(var(--upload-icon-bg)); + --color-upload-icon-text: rgb(var(--upload-icon-text)); + --color-upload-icon-text-muted: rgb(var(--upload-icon-text-muted)); + --color-upload-text-primary: rgb(var(--upload-text-primary)); + --color-upload-text-secondary: rgb(var(--upload-text-secondary)); + --color-upload-text-heading: rgb(var(--upload-text-heading)); + --color-upload-badge-bg: rgb(var(--upload-badge-bg)); + --color-upload-badge-text: rgb(var(--upload-badge-text)); + --color-upload-badge-border: rgb(var(--upload-badge-border)); + --color-upload-warning-bg: rgb(var(--upload-warning-bg)); + --color-upload-warning-border: rgb(var(--upload-warning-border)); + --color-upload-warning-icon: rgb(var(--upload-warning-icon)); + --color-upload-warning-heading: rgb(var(--upload-warning-heading)); + --color-upload-warning-text: rgb(var(--upload-warning-text)); + --color-upload-success-bg: rgb(var(--upload-success-bg)); + --color-upload-success-border: rgb(var(--upload-success-border)); + --color-upload-success-icon: rgb(var(--upload-success-icon)); + --color-upload-success-heading: rgb(var(--upload-success-heading)); + --color-upload-success-button: rgb(var(--upload-success-button)); + --color-upload-success-button-hover: rgb(var(--upload-success-button-hover)); + --color-upload-filelist-bg: rgb(var(--upload-filelist-bg)); + --color-upload-filelist-card: rgb(var(--upload-filelist-card)); + --color-upload-filelist-border: rgb(var(--upload-filelist-border)); + --color-upload-filelist-text: rgb(var(--upload-filelist-text)); + --color-upload-filelist-text-muted: rgb(var(--upload-filelist-text-muted)); + --color-upload-filelist-icon: rgb(var(--upload-filelist-icon)); + --color-upload-progress-bg: rgb(var(--upload-progress-bg)); + --color-upload-button-blue: rgb(var(--upload-button-blue)); + --color-upload-button-blue-hover: rgb(var(--upload-button-blue-hover)); + --color-upload-button-red: rgb(var(--upload-button-red)); + --color-upload-button-red-hover: rgb(var(--upload-button-red-hover)); + --color-upload-button-gray: rgb(var(--upload-button-gray)); + --color-upload-button-gray-hover: rgb(var(--upload-button-gray-hover)); + --color-upload-button-green: rgb(var(--upload-button-green)); + --color-upload-button-green-hover: rgb(var(--upload-button-green-hover)); + --color-upload-remove-hover-text: rgb(var(--upload-remove-hover-text)); + --color-upload-remove-hover-bg: rgb(var(--upload-remove-hover-bg)); } html, body, #root { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 078f860..4641b7b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -98,6 +98,7 @@ export function transformFileSystemNodeToFileItem(node: FileSystemNode, parentPa shared: node.access_level === "public", // Simplified assumption parentPath, variant: "standard", + access_level: node.access_level, children: node.children ? node.children.map(child => transformFileSystemNodeToFileItem(child, [...parentPath, node.name])) : undefined, } } diff --git a/src/pages/all-files-page.tsx b/src/pages/all-files-page.tsx index 92b2dfb..281dbc9 100644 --- a/src/pages/all-files-page.tsx +++ b/src/pages/all-files-page.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useMemo } from "react" import type { FileItem, FileActionHandlers } from "@/types/file-manager" -import type { SharePermission } from "@/types/file.types" +import { ACCESS_LEVEL, type SharePermission, type AccessLevel } from "@/types/file.types" import { FileManagerHeader } from "@/components/file-manager/FileManagerHeader" import { BreadcrumbNavigation } from "@/components/file-manager/BreadcrumbNavigation" import { Toolbar } from "@/components/file-manager/Toolbar" @@ -32,8 +32,7 @@ export default function AllFilesPage() { // Share file popup state const [isShareModalOpen, setIsShareModalOpen] = useState(false) const [fileToShare, setFileToShare] = useState(null) - - const { createFolder, fileSystemTree, deleteFileOrFolder, renameFolder, moveFileOrFolder, shareFileOrFolder } = useFile(); + const { createFolder, fileSystemTree, deleteFileOrFolder, renameFolder, moveFileOrFolder, shareFileOrFolder, updateFileAccessLevel } = useFile(); const navigate = useNavigate(); // Transform dynamic data to FileItem format @@ -124,7 +123,7 @@ export default function AllFilesPage() { const folderId = lastItem?.id ?? "root"; const folderName = lastItem?.name ?? "root"; navigate(`/all-files/${folderId}`, { - state: { folder_id: folderId, folder_name: folderName } + state: { folder_id: folderId, folder_name: folderName, access_level: ACCESS_LEVEL.PROTECTED } }); } @@ -227,6 +226,18 @@ export default function AllFilesPage() { setFileToShare(null); } + const handleChangeAccessLevel = async (file: FileItem, accessLevel: string) => { + try { + const result = await updateFileAccessLevel(file.id, { access_level: accessLevel as AccessLevel }); + if (!result.success) { + // You could show a toast notification here + console.error(result.error); + } + } catch (error: any) { + console.error("Failed to update access level:", error); + } + } + const actionHandlers: FileActionHandlers = { onFileSelect: toggleFileSelection, onItemClick: handleItemClick, @@ -235,6 +246,7 @@ export default function AllFilesPage() { onMove: handleMoveFile, onRename: handleRenameFile, onDelete: handleDeleteFile, + onChangeAccessLevel: handleChangeAccessLevel, } return ( diff --git a/src/pages/notifications-page.tsx b/src/pages/notifications-page.tsx index 240aa79..56dc72d 100644 --- a/src/pages/notifications-page.tsx +++ b/src/pages/notifications-page.tsx @@ -73,21 +73,21 @@ const getNotificationTypeColor = (type: string) => { switch (type) { case NotificationType.FILE_UPLOAD_COMPLETED: case NotificationType.MULTIPART_UPLOAD_COMPLETED: - return "text-green-600 bg-green-100" + return "text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-950/30" case NotificationType.FILE_UPLOAD_FAILED: case NotificationType.MULTIPART_UPLOAD_FAILED: - return "text-red-600 bg-red-100" + return "text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-950/30" case NotificationType.STORAGE_QUOTA_WARNING: - return "text-yellow-600 bg-yellow-100" + return "text-yellow-600 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-950/30" case NotificationType.STORAGE_QUOTA_EXCEEDED: - return "text-red-600 bg-red-100" + return "text-red-600 dark:text-red-400 bg-red-100 dark:bg-red-950/30" case NotificationType.FILE_SHARED: case NotificationType.FILE_COMMENTED: - return "text-blue-600 bg-blue-100" + return "text-primary bg-primary/10 dark:bg-primary/20" case NotificationType.FILE_DELETED: - return "text-gray-600 bg-gray-100" + return "text-muted-foreground bg-muted" default: - return "text-gray-600 bg-gray-100" + return "text-muted-foreground bg-muted" } } @@ -123,8 +123,8 @@ const NotificationItem = ({ notification, onMarkAsRead }: { notification: Notifi exit={{ opacity: 0, y: -10 }} transition={{ duration: 0.2 }} className={` - bg-white border border-gray-200 rounded-lg p-3 sm:p-4 transition-all duration-200 cursor-pointer - ${!notification.is_read ? 'border-l-4 border-l-blue-500 shadow-sm' : 'hover:border-gray-300 hover:shadow-sm'} + bg-card border border-border rounded-lg p-3 sm:p-4 transition-all duration-200 cursor-pointer + ${!notification.is_read ? 'border-l-4 border-l-primary shadow-sm' : 'hover:border-border hover:shadow-sm'} `} onClick={handleClick} > @@ -135,7 +135,7 @@ const NotificationItem = ({ notification, onMarkAsRead }: { notification: Notifi {getNotificationIcon(notification.type)}
{!notification.is_read && ( -
+
)}
@@ -145,7 +145,7 @@ const NotificationItem = ({ notification, onMarkAsRead }: { notification: Notifi
{/* Title and Type */}
-

+

{notification.title}

{/* Message */} -

+

{notification.message}

{/* File Info */} {notification.file_id && ( -
+
File ID: {notification.file_id}
@@ -171,7 +171,7 @@ const NotificationItem = ({ notification, onMarkAsRead }: { notification: Notifi {/* Additional Data */} {notification.data && Object.keys(notification.data).length > 0 && ( -
+
{notification.data.fileName && (
@@ -187,7 +187,7 @@ const NotificationItem = ({ notification, onMarkAsRead }: { notification: Notifi {/* Timestamp */}
- + {formatTimestamp(notification.created_at)}
@@ -394,8 +394,8 @@ export default function NotificationsPage() { className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-6 sm:mb-8" >
-
- +
+

Notifications

@@ -436,7 +436,7 @@ export default function NotificationsPage() { transition={{ duration: 0.3, delay: 0.1 }} className="mb-4 sm:mb-6" > -
+
{notificationCategories.map((category) => ( )} -
+
- -

Private Files

+ +

Private Files

-

- {filteredFiles.length} private items • {encryptedCount} encrypted • {sensitiveCount} sensitive +

+ {filteredFiles.length} private items + + {encryptedCount} encrypted + + {sensitiveCount} sensitive

-
- -
-
+
+
setSearchQuery(e.target.value)} - className="pl-10" + className="pl-10 w-full" />
- - - +
+ + + +
-
- {selectedFiles.length > 0 && {selectedFiles.length} selected} -
+
+ {selectedFiles.length > 0 && ( + + {selectedFiles.length} + + )} + {selectedFiles.length > 0 && ( + + {selectedFiles.length} selected + + )} +
- -
diff --git a/src/providers/AppProviders.tsx b/src/providers/AppProviders.tsx index 5312c6a..d306688 100644 --- a/src/providers/AppProviders.tsx +++ b/src/providers/AppProviders.tsx @@ -4,7 +4,7 @@ import { BrowserRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ErrorBoundary } from "@/components/error/ErrorBoundry"; import { UploadProvider } from "@/contexts/UploadContext"; -// import { ThemeProvider } from "@/contexts/ThemeContext"; +import { ThemeProvider } from "@/contexts/ThemeContext"; import { AuthProvider } from "@/contexts/useAuth"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { FileProvider } from "@/contexts/fileContext"; @@ -18,6 +18,7 @@ const AppProviders = ({ children }: { children: ReactNode }) => { const providers = [ (children: ReactNode) => {children}, (children: ReactNode) => {children}, + (children: ReactNode) => {children}, (children: ReactNode) => {children}, (children: ReactNode) => {children}, (children: ReactNode) => {children}, diff --git a/src/routes/Upload.tsx b/src/routes/Upload.tsx index 1609e3c..93680f8 100644 --- a/src/routes/Upload.tsx +++ b/src/routes/Upload.tsx @@ -32,20 +32,20 @@ const Upload = () => { ]; return ( -
-
+
+
{/* Header */} -
-
+
+
-

Upload Files to {folder_name}

-

Add files to your folder

+

Upload Files to {folder_name || 'Folder'}

+

Add files to your folder

diff --git a/src/types/file-manager.ts b/src/types/file-manager.ts index 65ccc86..4a4f358 100644 --- a/src/types/file-manager.ts +++ b/src/types/file-manager.ts @@ -16,6 +16,7 @@ export interface BaseFileItem { parentPath: string[]; children?: FileItem[]; file_info?: FileInfo; + access_level?: string; } // Extended interfaces for different page types @@ -110,9 +111,8 @@ export interface FileActionHandlers { onDelete?: (file: FileItem) => void; onRename?: (file: FileItem) => void; onRestore?: (file: FileItem) => void; - onEncrypt?: (file: FileItem) => void; - onDecrypt?: (file: FileItem) => void; onMove?: (file: FileItem) => void; + onChangeAccessLevel?: (file: FileItem, accessLevel: string) => void; onPermissionChange?: (file: FileItem, permission: string) => void; onCustomAction?: (action: string, file: FileItem) => void; } From 9da1de66a61cdad452c95f14ac6495e578e56875 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sat, 22 Nov 2025 14:29:20 +0530 Subject: [PATCH 09/22] when dont want to go to the private folder, you can come back to the previous folder again --- src/components/session/VerifyPin.tsx | 28 +++++++++------------------- src/pages/private-files-page.tsx | 7 +++++++ 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/components/session/VerifyPin.tsx b/src/components/session/VerifyPin.tsx index 20de617..8a1478f 100644 --- a/src/components/session/VerifyPin.tsx +++ b/src/components/session/VerifyPin.tsx @@ -12,6 +12,7 @@ import { toast } from "sonner" interface VerifyPinModalProps { isOpen: boolean onVerified: () => void + onClose?: () => void // Callback when modal is closed without verification title?: string description?: string required?: boolean // If true, modal cannot be closed until verified @@ -20,6 +21,7 @@ interface VerifyPinModalProps { export function VerifyPinModal({ isOpen, onVerified, + onClose, title = "Verify PIN", description = "Please enter your 4-digit PIN to continue", required = true, @@ -27,12 +29,14 @@ export function VerifyPinModal({ const { verifyPin, verifyPinLoading, user } = useAuth() const [pin, setPin] = useState("") const [error, setError] = useState("") + const [isVerified, setIsVerified] = useState(false) // Reset state when modal opens useEffect(() => { if (isOpen) { setPin("") setError("") + setIsVerified(false) } }, [isOpen]) @@ -65,6 +69,7 @@ export function VerifyPinModal({ if (result.success) { setPin("") setError("") + setIsVerified(true) toast.success("PIN verified successfully") onVerified() } else { @@ -78,11 +83,11 @@ export function VerifyPinModal({ } } - // Prevent closing if required and not verified + // Handle modal close const handleOpenChange = (open: boolean) => { - if (!open && required) { - // Prevent closing - do nothing - return + if (!open && !isVerified) { + // If modal is closing without verification, call onClose callback (e.g., navigate back) + onClose?.() } } @@ -93,21 +98,6 @@ export function VerifyPinModal({ { - if (required) { - e.preventDefault() - } - }} - onEscapeKeyDown={(e) => { - if (required) { - e.preventDefault() - } - }} - onPointerDownOutside={(e) => { - if (required) { - e.preventDefault() - } - }} >
diff --git a/src/pages/private-files-page.tsx b/src/pages/private-files-page.tsx index 77760eb..9ca2d3f 100644 --- a/src/pages/private-files-page.tsx +++ b/src/pages/private-files-page.tsx @@ -50,6 +50,11 @@ export function PrivateFilesPage() { const { user, getPinSession } = useAuth() const navigate = useNavigate() + const handlePinModalClose = () => { + // Navigate back to previous route when modal is closed without verification + navigate(-1) + } + // Check for active PIN session on mount (only once) useEffect(() => { // Prevent multiple calls @@ -260,6 +265,7 @@ export function PrivateFilesPage() { Date: Sat, 22 Nov 2025 14:37:50 +0530 Subject: [PATCH 10/22] lazy loading implementing for routes --- src/App.tsx | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b575d21..ab59002 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,22 +1,24 @@ import "./css/App.css"; -import { Suspense } from "react"; +import { Suspense, lazy } from "react"; import { ProtectedRoute } from "@/components/layouts/index"; import { USER_ROLES } from "@/types/user.types"; import { Routes, Route } from "react-router-dom"; -import NotFound from "@/routes/Notfound"; -import Home from "@/routes/Home"; -import DeletedFiles from "@/routes/DeletedFiles"; -import AllFiles from "@/routes/AllFiles"; -import PrivateFiles from "@/routes/PrivateFiles"; -import Settings from "@/routes/Settings"; -import SharedFiles from "@/routes/SharedFiles"; -import Notifications from "@/routes/Notifications"; import Login from "@/routes/auth/Login"; import Register from "@/routes/auth/Register"; -import Unauthorized from "@/routes/Unauthorized"; -import Upload from "@/routes/Upload"; import UploadPopup from "@/components/upload/UploadPopup"; +// Lazy load routes +const NotFound = lazy(() => import("@/routes/Notfound")); +const Home = lazy(() => import("@/routes/Home")); +const DeletedFiles = lazy(() => import("@/routes/DeletedFiles")); +const AllFiles = lazy(() => import("@/routes/AllFiles")); +const PrivateFiles = lazy(() => import("@/routes/PrivateFiles")); +const Settings = lazy(() => import("@/routes/Settings")); +const SharedFiles = lazy(() => import("@/routes/SharedFiles")); +const Notifications = lazy(() => import("@/routes/Notifications")); +const Unauthorized = lazy(() => import("@/routes/Unauthorized")); +const Upload = lazy(() => import("@/routes/Upload")); + // Loading component for Suspense fallback const Loading = () => (
From 90585c670b0c25f4117a7a013716b2989af282c7 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sat, 22 Nov 2025 23:38:31 +0530 Subject: [PATCH 11/22] api token ui changes --- bun.lock | 6 + package.json | 2 + src/api/api-token.api.ts | 46 +++ .../settings/api-token-settings.tsx | 280 ++++++++++++++++++ src/components/settings/settings-content.tsx | 83 +----- src/components/settings/settings-tabs.tsx | 2 +- src/components/ui/calendar.tsx | 71 +++++ src/contexts/ApiTokenContext.tsx | 96 ++++++ src/providers/AppProviders.tsx | 5 +- 9 files changed, 512 insertions(+), 79 deletions(-) create mode 100644 src/api/api-token.api.ts create mode 100644 src/components/settings/api-token-settings.tsx create mode 100644 src/components/ui/calendar.tsx create mode 100644 src/contexts/ApiTokenContext.tsx diff --git a/bun.lock b/bun.lock index b3758a3..bfbced6 100644 --- a/bun.lock +++ b/bun.lock @@ -27,9 +27,11 @@ "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "framer-motion": "^12.23.12", "lucide-react": "^0.539.0", "react": "^19.1.1", + "react-day-picker": "^8.10.0", "react-dom": "^19.1.1", "react-hook-form": "^7.62.0", "react-router-dom": "^7.8.0", @@ -456,6 +458,8 @@ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], @@ -694,6 +698,8 @@ "react": ["react@19.1.1", "", {}, "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ=="], + "react-day-picker": ["react-day-picker@8.10.1", "", { "peerDependencies": { "date-fns": "^2.28.0 || ^3.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA=="], + "react-dom": ["react-dom@19.1.1", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.1" } }, "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw=="], "react-hook-form": ["react-hook-form@7.62.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA=="], diff --git a/package.json b/package.json index 10653ec..db712f9 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,11 @@ "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "framer-motion": "^12.23.12", "lucide-react": "^0.539.0", "react": "^19.1.1", + "react-day-picker": "^8.10.0", "react-dom": "^19.1.1", "react-hook-form": "^7.62.0", "react-router-dom": "^7.8.0", diff --git a/src/api/api-token.api.ts b/src/api/api-token.api.ts new file mode 100644 index 0000000..4db08dc --- /dev/null +++ b/src/api/api-token.api.ts @@ -0,0 +1,46 @@ +import apiClient from "@/api/axios"; + +export interface ApiToken { + id: string; + name: string; + token?: string; // Only present when generated + last_used_at?: string; + usage_count: number; + expires_at?: string; + created_at: string; + is_active: boolean; +} + +export interface GenerateTokenDto { + name: string; + expires_at?: string; +} + +const generateToken = async (data: GenerateTokenDto) => { + try { + const response = await apiClient.post('/api-token/generate', data); + return response.data; + } catch (error) { + throw error; + } +}; + +const listTokens = async () => { + try { + const response = await apiClient.get('/api-token/list'); + return response.data; + } catch (error) { + throw error; + } +}; + +const revokeToken = async (id: string) => { + try { + const response = await apiClient.delete(`/api-token/${id}`); + return response.data; + } catch (error) { + throw error; + } +}; + +export default { generateToken, listTokens, revokeToken }; diff --git a/src/components/settings/api-token-settings.tsx b/src/components/settings/api-token-settings.tsx new file mode 100644 index 0000000..5937887 --- /dev/null +++ b/src/components/settings/api-token-settings.tsx @@ -0,0 +1,280 @@ +import { useState, useEffect } from "react" +import { useApiToken } from "@/contexts/ApiTokenContext" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Settings, Plus, Trash2, Copy, Check, AlertTriangle, CalendarIcon } from "lucide-react" +import { toast } from "sonner" +import { format } from "date-fns" +import { cn } from "@/lib/utils" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +export function ApiTokenSettings() { + const { tokens, loading, fetchTokens, generateToken, revokeToken } = useApiToken() + const [isGenerateOpen, setIsGenerateOpen] = useState(false) + const [newTokenName, setNewTokenName] = useState("") + const [expiresAt, setExpiresAt] = useState() + const [generatedToken, setGeneratedToken] = useState(null) + const [copied, setCopied] = useState(false) + const [tokenToDelete, setTokenToDelete] = useState(null) + + useEffect(() => { + fetchTokens() + }, [fetchTokens]) + + const handleGenerate = async () => { + if (!newTokenName.trim()) { + toast.error("Please enter a token name") + return + } + + const result = await generateToken({ + name: newTokenName.trim(), + expires_at: expiresAt ? expiresAt.toISOString() : undefined + }) + + if (result.success && result.token?.token) { + setGeneratedToken(result.token.token) + setNewTokenName("") + setExpiresAt(undefined) + // Don't close dialog yet, show the token + } + } + + const handleCopy = () => { + if (generatedToken) { + navigator.clipboard.writeText(generatedToken) + setCopied(true) + toast.success("Token copied to clipboard") + setTimeout(() => setCopied(false), 2000) + } + } + + const handleCloseDialog = () => { + setIsGenerateOpen(false) + setGeneratedToken(null) + setNewTokenName("") + setExpiresAt(undefined) + } + + const handleDeleteClick = (id: string) => { + setTokenToDelete(id) + } + + const confirmDelete = async () => { + if (tokenToDelete) { + await revokeToken(tokenToDelete) + setTokenToDelete(null) + } + } + + return ( + + +
+
+ + + API Access + + + Manage API tokens for external access (Max 3 tokens) + +
+ +
+
+ + {tokens.length === 0 ? ( +
+ No API tokens generated yet. +
+ ) : ( +
+ + + + Name + Created + Expires + Last Used + Status + Usage Count + Actions + + + + {tokens.map((token) => ( + + {token.name} + {new Date(token.created_at).toLocaleDateString()} + + {token.expires_at + ? new Date(token.expires_at).toLocaleDateString() + : 'Never'} + + + {token.last_used_at + ? new Date(token.last_used_at).toLocaleString() + : 'Never'} + + + + {token.is_active ? "Active" : "Inactive"} + + + {token.usage_count} + + + + + ))} + +
+
+ )} + + + + + Generate API Token + + Create a new API token to access your files programmatically. + + + + {!generatedToken ? ( +
+
+ + setNewTokenName(e.target.value)} + /> +
+
+ + + + + + + date < new Date()} + captionLayout="dropdown-buttons" + fromYear={new Date().getFullYear()} + toYear={new Date().getFullYear() + 10} + /> + + +
+
+ ) : ( +
+
+ +
+

Make sure to copy your token now.

+

You won't be able to see it again!

+
+
+
+ +
+ + +
+
+
+ )} + + + {!generatedToken ? ( + <> + + + + ) : ( + + )} + +
+
+ + !open && setTokenToDelete(null)}> + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete the API token + and any applications using it will lose access immediately. + + + + Cancel + + Delete Token + + + + +
+
+ ) +} diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 6819a30..dc2379a 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -16,12 +16,8 @@ import { Bell, Smartphone, HardDrive, - Globe, Lock, Eye, - Download, - Upload, - Settings, Palette, Key, CheckCircle2, @@ -34,6 +30,7 @@ import { CheckCircle, XCircle, } from "lucide-react" +import { ApiTokenSettings } from "./api-token-settings" interface SettingsContentProps { activeTab: string @@ -707,85 +704,17 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { ) } - const renderAppsTab = () => ( + const renderApiTokenTab = () => ( - {/* Connected Apps */} - - - - - Connected Applications - - - Manage third-party applications connected to your account - - - -
-
-
-
- -
-
-

Google Drive

-

Connected for file sync

-
-
- -
-
-
-
-
-
- -
-
-

Dropbox

-

Connected for backup

-
-
- -
-
-
-
- {/* API Access */} - - - - - API Access - - - Manage API keys and integrations - - - -
- -
- -
- - -
-
-
-

- Use this API key to integrate FileFlow with your applications -

-
-
+
) @@ -799,8 +728,8 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { return renderPinTab() case "notifications": return renderNotificationsTab() - case "apps": - return renderAppsTab() + case "api-token": + return renderApiTokenTab() default: return renderGeneralTab() } diff --git a/src/components/settings/settings-tabs.tsx b/src/components/settings/settings-tabs.tsx index 05f7327..9f4a8e6 100644 --- a/src/components/settings/settings-tabs.tsx +++ b/src/components/settings/settings-tabs.tsx @@ -8,7 +8,7 @@ const tabs = [ { id: "security", label: "Security" }, { id: "pin", label: "PIN" }, { id: "notifications", label: "Notifications" }, - { id: "apps", label: "Apps" }, + { id: "api-token", label: "API Token" }, ] interface SettingsTabsProps { diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 0000000..2163bac --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,71 @@ +"use client" + +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +export type CalendarProps = React.ComponentProps + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + , + IconRight: () => , + }} + {...props} + /> + ) +} +Calendar.displayName = "Calendar" + +export { Calendar } diff --git a/src/contexts/ApiTokenContext.tsx b/src/contexts/ApiTokenContext.tsx new file mode 100644 index 0000000..f87b769 --- /dev/null +++ b/src/contexts/ApiTokenContext.tsx @@ -0,0 +1,96 @@ +import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; +import apiTokenApi, { type ApiToken, type GenerateTokenDto } from '@/api/api-token.api'; +import { toast } from 'sonner'; + +interface ApiTokenContextType { + tokens: ApiToken[]; + loading: boolean; + fetchTokens: () => Promise; + generateToken: (data: GenerateTokenDto) => Promise<{ success: boolean; token?: ApiToken; error?: string }>; + revokeToken: (id: string) => Promise<{ success: boolean; error?: string }>; +} + +const ApiTokenContext = createContext(undefined); + +export const ApiTokenProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchTokens = useCallback(async () => { + try { + setLoading(true); + const response = await apiTokenApi.listTokens(); + setTokens(response.data); + } catch (error: any) { + console.error("Failed to fetch tokens:", error); + // toast.error("Failed to load API tokens."); + } finally { + setLoading(false); + } + }, []); + + const generateToken = async (data: GenerateTokenDto) => { + try { + if (tokens.length >= 3) { + const error = "You can only generate up to 3 API tokens."; + toast.error(error); + return { success: false, error }; + } + setLoading(true); + const response = await apiTokenApi.generateToken(data); + const newToken = response.data; + + // Add the new token to the list (or refetch) + // Since the list endpoint might not return the full token (with secret), + // we might want to just refetch or append carefully. + // The generate endpoint returns the full token object including the secret 'token' field. + // The list endpoint does NOT return the secret 'token' field. + + // We'll append it to the list for display, but the user should copy the secret immediately. + setTokens(prev => [newToken, ...prev]); + + toast.success("API Token generated successfully!"); + return { success: true, token: newToken }; + } catch (error: any) { + const errorMessage = error?.response?.data?.message || error?.message || "Failed to generate token."; + toast.error(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setLoading(false); + } + }; + + const revokeToken = async (id: string) => { + try { + setLoading(true); + await apiTokenApi.revokeToken(id); + setTokens(prev => prev.filter(t => t.id !== id)); + toast.success("API Token revoked successfully."); + return { success: true }; + } catch (error: any) { + const errorMessage = error?.response?.data?.message || error?.message || "Failed to revoke token."; + toast.error(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setLoading(false); + } + }; + + const value = { + tokens, + loading, + fetchTokens, + generateToken, + revokeToken, + }; + + return {children}; +}; + +export const useApiToken = () => { + const context = useContext(ApiTokenContext); + if (!context) { + throw new Error('useApiToken must be used within an ApiTokenProvider'); + } + return context; +}; diff --git a/src/providers/AppProviders.tsx b/src/providers/AppProviders.tsx index d306688..f397463 100644 --- a/src/providers/AppProviders.tsx +++ b/src/providers/AppProviders.tsx @@ -7,11 +7,13 @@ import { UploadProvider } from "@/contexts/UploadContext"; import { ThemeProvider } from "@/contexts/ThemeContext"; import { AuthProvider } from "@/contexts/useAuth"; import { Toaster as Sonner } from "@/components/ui/sonner"; -import { FileProvider } from "@/contexts/fileContext"; +import { FileProvider } from "@/contexts/fileContext"; import { NotificationProvider } from "@/contexts/NotificationContext"; import { NotificationUIProvider } from "@/contexts/NotificationUIContext"; import { SocketProvider } from "@/contexts/SocketContext"; +import { ApiTokenProvider } from "@/contexts/ApiTokenContext"; + const queryClient = new QueryClient(); const AppProviders = ({ children }: { children: ReactNode }) => { @@ -29,6 +31,7 @@ const AppProviders = ({ children }: { children: ReactNode }) => { ), (children: ReactNode) => {children}, + (children: ReactNode) => {children}, (children: ReactNode) => {children}, (children: ReactNode) => {children}, (children: ReactNode) => {children}, From c0c1c870007a1ffb0ea8b975b1380047555b77d8 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sun, 23 Nov 2025 12:07:00 +0530 Subject: [PATCH 12/22] share files changes --- .../file-manager/ShareFileModal.tsx | 2 +- src/components/settings/settings-content.tsx | 80 ------ src/components/settings/settings-tabs.tsx | 1 - src/lib/utils.ts | 52 +++- src/pages/shared-files-page.tsx | 261 +++++++----------- src/routes/auth/Register.tsx | 4 +- src/types/file.types.ts | 28 +- 7 files changed, 179 insertions(+), 249 deletions(-) diff --git a/src/components/file-manager/ShareFileModal.tsx b/src/components/file-manager/ShareFileModal.tsx index 0a2cd61..6558ed7 100644 --- a/src/components/file-manager/ShareFileModal.tsx +++ b/src/components/file-manager/ShareFileModal.tsx @@ -51,7 +51,7 @@ export function ShareFileModal({ page: 1, limit: 20, is_active: true, - email_verified: true + email_verified: false }) setUsers(Array.isArray(result) ? result : []) setShowDropdown(true) diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index dc2379a..a2e92d9 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -13,8 +13,6 @@ import { useTheme } from "@/contexts/ThemeContext" import { User, Shield, - Bell, - Smartphone, HardDrive, Lock, Eye, @@ -420,82 +418,6 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { ) - const renderNotificationsTab = () => ( - - {/* Email Notifications */} - - - - - Email Notifications - - - Choose which emails you want to receive - - - -
-
- -

Get notified about security events

-
- -
-
-
- -

Receive storage usage notifications

-
- -
-
-
- -

Stay informed about new features

-
- -
-
-
- - {/* Push Notifications */} - - - - - Push Notifications - - - Manage notifications on your devices - - - -
-
- -

When someone shares files with you

-
- -
-
-
- -

When approaching storage limits

-
- -
-
-
-
- ) - const renderPinTab = () => { const hasPin = user?.pin_hash @@ -726,8 +648,6 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { return renderSecurityTab() case "pin": return renderPinTab() - case "notifications": - return renderNotificationsTab() case "api-token": return renderApiTokenTab() default: diff --git a/src/components/settings/settings-tabs.tsx b/src/components/settings/settings-tabs.tsx index 9f4a8e6..d3f8a5a 100644 --- a/src/components/settings/settings-tabs.tsx +++ b/src/components/settings/settings-tabs.tsx @@ -7,7 +7,6 @@ const tabs = [ { id: "general", label: "General" }, { id: "security", label: "Security" }, { id: "pin", label: "PIN" }, - { id: "notifications", label: "Notifications" }, { id: "api-token", label: "API Token" }, ] diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 4641b7b..69f2a0e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,7 +1,7 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" -import type { FileSystemNode } from "@/types/file.types" -import type { StandardFileItem, DeletedFileItem, PrivateFileItem } from "@/types/file-manager" +import type { FileSystemNode, SharedFileSystemNode } from "@/types/file.types" +import type { StandardFileItem, DeletedFileItem, PrivateFileItem, SharedFileItem } from "@/types/file-manager" import { FileText, FolderIcon, @@ -192,4 +192,52 @@ export function transformFileSystemNodeToPrivateFileItem(node: FileSystemNode, p // Transform FileSystemNode array to PrivateFileItem array export function transformFileSystemNodesToPrivateFileItems(nodes: FileSystemNode[]): PrivateFileItem[] { return nodes.map(node => transformFileSystemNodeToPrivateFileItem(node)) +} + +// Transform SharedFileSystemNode to SharedFileItem +export function transformSharedFileSystemNodeToSharedFileItem(node: SharedFileSystemNode, parentPath: string[] = []): SharedFileItem { + const isFolder = node.is_folder + const fileType = node.file_info?.file_type || null + const size = node.file_info?.file_size || 0 + const thumbnail = node.file_info?.thumbnail_path || null + + // Handle potential null updated_at + const modifiedDate = node.updated_at || node.created_at + + return { + id: node.id, + name: node.name, + type: isFolder ? "folder" : "file", + fileType: isFolder ? "folder" : getFileTypeCategory(fileType), + size: formatFileSize(size), + modified: formatRelativeTime(modifiedDate), + icon: isFolder ? FolderIcon : getFileIcon(fileType), + thumbnail, + file_info: node.file_info || undefined, + starred: false, // This would need to be fetched from favorites API + shared: true, + parentPath, + variant: "shared", + sharedBy: { + name: node.shared_by_user?.display_name || "Unknown", + avatar: node.shared_by_user?.avatar_url || null, + initials: (node.shared_by_user?.display_name || "U").charAt(0).toUpperCase(), + }, + sharedWith: [ + { + name: node.shared_with_user?.display_name || "Unknown", + avatar: node.shared_with_user?.avatar_url || null, + initials: (node.shared_with_user?.display_name || "U").charAt(0).toUpperCase(), + } + ], + permission: (node.permission_level as "view" | "edit" | "admin") || "view", + sharedDate: formatRelativeTime(node.share_created_at), + isOwner: false, // This will be overridden in the page component based on current user + children: node.children ? node.children.map((child: SharedFileSystemNode) => transformSharedFileSystemNodeToSharedFileItem(child, [...parentPath, node.name])) : undefined, + } +} + +// Transform SharedFileSystemNode array to SharedFileItem array +export function transformSharedFileSystemNodesToSharedFileItems(nodes: SharedFileSystemNode[]): SharedFileItem[] { + return nodes.map(node => transformSharedFileSystemNodeToSharedFileItem(node)) } \ No newline at end of file diff --git a/src/pages/shared-files-page.tsx b/src/pages/shared-files-page.tsx index 74b3dba..cd53e2a 100644 --- a/src/pages/shared-files-page.tsx +++ b/src/pages/shared-files-page.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useState, useMemo } from "react" import { motion } from "framer-motion" import { Grid3X3, @@ -9,13 +9,7 @@ import { Filter, SortAsc, Download, - Trash2, - FileText, - Video, - FolderIcon, Users, - UserPlus, - Link, Settings, } from "lucide-react" @@ -27,99 +21,28 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" import { FileManager } from "@/components/file-manager/FileManager" import { sharedPageConfig, defaultViewConfig } from "@/config/page-configs" import { useFile } from "@/contexts/fileContext" -import { toast } from "sonner" -import type { SharedFileItem, FileActionHandlers, FileItem } from "@/types/file-manager" +import type { FileActionHandlers, FileItem } from "@/types/file-manager" +import { transformSharedFileSystemNodesToSharedFileItems } from "@/lib/utils" +import { useAuth } from "@/contexts/useAuth" +import { BreadcrumbNavigation } from "@/components/file-manager/BreadcrumbNavigation" + -const mockSharedFiles: SharedFileItem[] = [ - { - id: "1", - name: "Marketing Campaign.pptx", - type: "file", - fileType: "presentation", - size: "12.4 MB", - modified: "2 hours ago", - icon: FileText, - thumbnail: null, - starred: true, - shared: true, - parentPath: [], - variant: "shared", - sharedBy: { name: "John Doe", avatar: "/john-avatar.png", initials: "JD" }, - sharedWith: [ - { name: "Alice Smith", avatar: "/alice-avatar.png", initials: "AS" }, - { name: "Bob Wilson", avatar: "/bob-avatar.png", initials: "BW" }, - ], - permission: "edit", - sharedDate: "2024-01-15", - isOwner: false, - }, - { - id: "2", - name: "Design Assets", - type: "folder", - fileType: "folder", - size: "234 MB", - modified: "1 day ago", - icon: FolderIcon, - thumbnail: null, - starred: false, - shared: true, - parentPath: [], - variant: "shared", - sharedBy: { name: "Sophie Chamberlain", avatar: "/sophie-avatar.png", initials: "SC" }, - sharedWith: [ - { name: "Design Team", avatar: null, initials: "DT" }, - { name: "Marketing Team", avatar: null, initials: "MT" }, - ], - permission: "view", - sharedDate: "2024-01-10", - isOwner: true, - }, - { - id: "3", - name: "Project Demo.mp4", - type: "file", - fileType: "video", - size: "89.2 MB", - modified: "3 days ago", - icon: Video, - starred: false, - shared: true, - parentPath: [], - variant: "shared", - sharedBy: { name: "Mike Johnson", avatar: "/mike-avatar.png", initials: "MJ" }, - sharedWith: [{ name: "Client Team", avatar: null, initials: "CT" }], - permission: "view", - sharedDate: "2024-01-08", - isOwner: false, - }, - { - id: "4", - name: "Brand Guidelines.pdf", - type: "file", - fileType: "pdf", - size: "5.8 MB", - modified: "1 week ago", - icon: FileText, - thumbnail: null, - starred: true, - shared: true, - parentPath: [], - variant: "shared", - sharedBy: { name: "Sophie Chamberlain", avatar: "/sophie-avatar.png", initials: "SC" }, - sharedWith: [{ name: "Everyone", avatar: null, initials: "EV" }], - permission: "view", - sharedDate: "2024-01-01", - isOwner: true, - }, -] export function SharedFilesPage() { const [viewMode, setViewMode] = useState<"grid" | "list">("grid") const [searchQuery, setSearchQuery] = useState("") const [selectedFiles, setSelectedFiles] = useState([]) const [activeTab, setActiveTab] = useState("all") - const { getAllSharedFilesWithMe, getAllSharedFilesByMe, getAllSharedFiles, deleteFileOrFolder } = useFile() + const [currentPath, setCurrentPath] = useState>([]) + const { + getAllSharedFilesWithMe, + getAllSharedFilesByMe, + getAllSharedFiles, + sharedFiles, + sharedFilesByMe, + sharedFilesWithMe, + } = useFile() + const { user } = useAuth() useEffect(() => { getAllSharedFilesWithMe() @@ -127,14 +50,45 @@ export function SharedFilesPage() { getAllSharedFiles() }, []) - const filteredFiles = mockSharedFiles.filter((file) => { - const matchesSearch = file.name.toLowerCase().includes(searchQuery.toLowerCase()) - const matchesTab = - activeTab === "all" || - (activeTab === "shared-by-me" && file.isOwner) || - (activeTab === "shared-with-me" && !file.isOwner) - return matchesSearch && matchesTab - }) + // Reset path when tab changes + useEffect(() => { + setCurrentPath([]) + }, [activeTab]) + + const transformedFiles = useMemo(() => { + let filesToTransform: any[] = []; + + if (activeTab === "all") { + filesToTransform = sharedFiles; + } else if (activeTab === "shared-by-me") { + filesToTransform = sharedFilesByMe; + } else if (activeTab === "shared-with-me") { + filesToTransform = sharedFilesWithMe; + } + + return transformSharedFileSystemNodesToSharedFileItems(filesToTransform).map(file => ({ + ...file, + isOwner: file.sharedBy.name === user?.display_name // Simple check, ideally check ID + })); + }, [sharedFiles, sharedFilesByMe, sharedFilesWithMe, activeTab, user]); + + const currentItems = useMemo(() => { + let items: FileItem[] = transformedFiles + for (const folder of currentPath) { + const foundFolder = items.find((item) => item.id === folder.id && item.type === "folder") + if (foundFolder?.children) { + items = foundFolder.children + } + } + return items + }, [currentPath, transformedFiles]) + + const filteredFiles = useMemo(() => { + return currentItems.filter((file) => { + const matchesSearch = file.name.toLowerCase().includes(searchQuery.toLowerCase()) + return matchesSearch + }) + }, [currentItems, searchQuery]) const toggleFileSelection = (fileId: string) => { setSelectedFiles((prev) => (prev.includes(fileId) ? prev.filter((id) => id !== fileId) : [...prev, fileId])) @@ -144,57 +98,50 @@ export function SharedFilesPage() { setSelectedFiles(selectedFiles.length === filteredFiles.length ? [] : filteredFiles.map((f) => f.id)) } - const sharedByMeCount = mockSharedFiles.filter((f) => f.isOwner).length - const sharedWithMeCount = mockSharedFiles.filter((f) => !f.isOwner).length + const sharedByMeCount = sharedFilesByMe.length + const sharedWithMeCount = sharedFilesWithMe.length - const handleDeleteFile = async (file: FileItem) => { - try { - const result = await deleteFileOrFolder(file.id); - if (result.success) { - toast.success(`${file.type === 'folder' ? 'Folder' : 'File'} deleted successfully!`); - // Remove from selected files if it was selected - setSelectedFiles(prev => prev.filter(id => id !== file.id)); - } else { - toast.error(result.error || 'Failed to delete item'); - } - } catch (error) { - console.error('Delete error:', error); - toast.error('An error occurred while deleting the item'); + const handleItemClick = (item: FileItem) => { + if (item.type === "folder") { + setCurrentPath([...currentPath, { id: item.id, name: item.name }]) + setSelectedFiles([]) + setSearchQuery("") + } else { + console.log("Clicked on shared item:", item.name) + } + } + + const handleBreadcrumbNavigate = (index: number) => { + if (index === -1) { + setCurrentPath([]) + } else { + setCurrentPath(currentPath.slice(0, index + 1)) } + setSelectedFiles([]) + setSearchQuery("") } const actionHandlers: FileActionHandlers = { onFileSelect: toggleFileSelection, - onItemClick: (item) => console.log("Clicked on shared item:", item.name), + onItemClick: handleItemClick, onDownload: (file) => console.log("Download file:", file.name), onShare: (file) => console.log("Share file:", file.name), - onDelete: handleDeleteFile, } return ( -
+
{/* Header */} -
+

Shared with me

-

+

{filteredFiles.length} shared items • {sharedByMeCount} shared by me • {sharedWithMeCount} shared with me

-
- - -
@@ -215,36 +162,42 @@ export function SharedFilesPage() { + {currentPath.length > 0 && ( + + )} + {/* Toolbar */} -
-
+
+
setSearchQuery(e.target.value)} - className="pl-10" + className="pl-10 w-full" />
- - +
+ + +
-
+
{selectedFiles.length > 0 && {selectedFiles.length} selected} -
+
- -
)} diff --git a/src/routes/auth/Register.tsx b/src/routes/auth/Register.tsx index ef560e9..b314e25 100644 --- a/src/routes/auth/Register.tsx +++ b/src/routes/auth/Register.tsx @@ -156,7 +156,7 @@ const Register = () => { />
- + )} /> @@ -223,7 +223,7 @@ const Register = () => { {password && (

Password requirements:

-
+
{passwordValidations.map((validation, index) => (
{validation.test ? ( diff --git a/src/types/file.types.ts b/src/types/file.types.ts index 7aa48fd..ddeb979 100644 --- a/src/types/file.types.ts +++ b/src/types/file.types.ts @@ -89,30 +89,42 @@ export interface FileSystemNode { children: FileSystemNode[]; } +export interface SharedUserDetails { + id: string; + email: string; + display_name: string; + avatar_url?: string; +} export interface SharedFileSystemNode { id: string; owner_id: string; - parent_id?: string; + parent_id: string | null; name: string; is_folder: boolean; access_level: string; - file_info?: any; + file_info?: FileInfo | null; description?: string; - tags: string[]; - metadata: any; + tags?: string[]; + metadata?: Record; last_accessed_at?: Date; created_at: Date; updated_at: Date; - children: SharedFileSystemNode[]; - // Share-specific fields + + // Share information share_id: string; shared_by_user_id: string; shared_with_user_id: string; + + // User details objects + shared_by_user: SharedUserDetails; + shared_with_user: SharedUserDetails; + permission_level: string; share_message?: string; expires_at?: Date; share_created_at: Date; -} - + // Recursive children + children: SharedFileSystemNode[]; +} \ No newline at end of file From ad25bce77c39b596330733f677f7d40d7f20698f Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sun, 7 Dec 2025 16:26:31 +0530 Subject: [PATCH 13/22] refresh tokens implemented --- src/api/auth.api.ts | 64 +++- src/api/axios.ts | 98 ++++- src/components/settings/active-sessions.tsx | 384 +++++++++++++++++++ src/components/settings/settings-content.tsx | 7 + src/components/settings/settings-page.tsx | 5 +- src/components/settings/settings-tabs.tsx | 1 + src/contexts/useAuth.tsx | 68 +++- src/hooks/useQueryState.tsx | 37 ++ src/store/connect-socket.ts | 17 +- src/types/user.types.ts | 6 +- 10 files changed, 657 insertions(+), 30 deletions(-) create mode 100644 src/components/settings/active-sessions.tsx create mode 100644 src/hooks/useQueryState.tsx diff --git a/src/api/auth.api.ts b/src/api/auth.api.ts index 1930862..7811251 100644 --- a/src/api/auth.api.ts +++ b/src/api/auth.api.ts @@ -82,5 +82,67 @@ const getSession = async () => { } }; +/** + * Refresh access token using refresh token + */ +const refreshToken = async (refreshToken: string) => { + try { + const response = await apiClient.post('/auth/refresh', { refresh_token: refreshToken }); + return response.data; + } catch (error) { + throw error; + } +}; + +/** + * Revoke a specific refresh token (logout from single device) + */ +const revokeToken = async (refreshToken: string) => { + try { + const response = await apiClient.post('/auth/revoke', { refresh_token: refreshToken }); + return response.data; + } catch (error) { + throw error; + } +}; + +/** + * Revoke all refresh tokens (logout from all devices) + */ +const revokeAllTokens = async () => { + try { + const response = await apiClient.post('/auth/revoke-all'); + return response.data; + } catch (error) { + throw error; + } +}; + +/** + * Get all active sessions for the authenticated user + */ +const getActiveSessions = async () => { + try { + const response = await apiClient.get('/auth/sessions'); + return response.data; + } catch (error) { + throw error; + } +}; + -export default { login, register, verifyEmail, logout, getAllUsers, setPin, verifyPin, changePin, getSession }; \ No newline at end of file +export default { + login, + register, + verifyEmail, + logout, + getAllUsers, + setPin, + verifyPin, + changePin, + getSession, + refreshToken, + revokeToken, + revokeAllTokens, + getActiveSessions +}; \ No newline at end of file diff --git a/src/api/axios.ts b/src/api/axios.ts index ced3c43..cba1f3d 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -1,20 +1,36 @@ import axios from 'axios'; export const API_BASE_URL = import.meta.env.VITE_API_BACKEND_URL || 'http://localhost:3000/api/v1'; -import { getAuthState } from '@/store/auth.store'; +import { getAuthState, useAuthStore } from '@/store/auth.store'; + // Initialize the Axios client const apiClient = axios.create({ - baseURL: API_BASE_URL, // Replace with your API base URL + baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json' }, timeout: 100000, withCredentials: true, }); +// Flag to prevent multiple refresh requests +let isRefreshing = false; +let failedQueue: Array<{ resolve: (value?: unknown) => void; reject: (reason?: any) => void }> = []; + +const processQueue = (error: any = null) => { + failedQueue.forEach(prom => { + if (error) { + prom.reject(error); + } else { + prom.resolve(); + } + }); + failedQueue = []; +}; + apiClient.interceptors.request.use( (config) => { const { token } = getAuthState(); - if (token?.jwt_token) { - config.headers.Authorization = `Bearer ${token?.jwt_token}`; + if (token?.access_token) { + config.headers.Authorization = `Bearer ${token.access_token}`; } return config; }, @@ -23,23 +39,83 @@ apiClient.interceptors.request.use( } ); -// Add response interceptor +// Add response interceptor with token refresh logic apiClient.interceptors.response.use( async (response) => { return response; }, async (error) => { - if (error.response?.status === 401) { - await logout(); + const originalRequest = error.config; + + // If error is 401 and we haven't tried to refresh yet + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + // If already refreshing, queue this request + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then(() => { + const { token } = getAuthState(); + if (token?.access_token) { + originalRequest.headers.Authorization = `Bearer ${token.access_token}`; + } + return apiClient(originalRequest); + }).catch(err => { + return Promise.reject(err); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + const { token } = getAuthState(); + + if (!token?.refresh_token) { + // No refresh token available, logout + isRefreshing = false; + processQueue(error); + await logout(); + return Promise.reject(error); + } + + try { + // Attempt to refresh the token + const response = await axios.post(`${API_BASE_URL}/auth/refresh`, { + refresh_token: token.refresh_token + }); + + const { access_token, refresh_token, expires_at, refresh_expires_at } = response.data.data; + + // Update the token in the store + useAuthStore.getState().setToken({ + access_token, + refresh_token, + expires_at, + refresh_expires_at + }); + + // Update the authorization header + originalRequest.headers.Authorization = `Bearer ${access_token}`; + + isRefreshing = false; + processQueue(); + + // Retry the original request + return apiClient(originalRequest); + } catch (refreshError) { + // Refresh failed, logout user + isRefreshing = false; + processQueue(refreshError); + await logout(); + return Promise.reject(refreshError); + } } - return Promise.reject(error); // Ensure error propagates + + return Promise.reject(error); } ); - - const logout = async () => { - delete apiClient.defaults.headers.common['Authorization']; + useAuthStore.getState().removeToken(); localStorage.clear(); window.location.href = "/login"; } diff --git a/src/components/settings/active-sessions.tsx b/src/components/settings/active-sessions.tsx new file mode 100644 index 0000000..82c835c --- /dev/null +++ b/src/components/settings/active-sessions.tsx @@ -0,0 +1,384 @@ +"use client" + +import { useEffect, useState } from "react" +import { motion } from "framer-motion" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { useAuth } from "@/contexts/useAuth" +import { Smartphone, Monitor, Tablet, MapPin, Calendar, LogOut, AlertCircle, Loader2, Clock, Info } from "lucide-react" +import { toast } from "sonner" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog" +import { cn } from "@/lib/utils" + +interface Session { + id: string + refresh_token: string + created_at: string + expires_at: string + last_used_at: string | null + ip: string | null + user_agent: string | null +} + +export function ActiveSessions() { + const { getActiveSessions, revokeToken, logoutAll, logoutAllLoading } = useAuth() + const [sessions, setSessions] = useState([]) + const [loading, setLoading] = useState(true) + const [revokingId, setRevokingId] = useState(null) + + const loadSessions = async () => { + setLoading(true) + try { + const data = await getActiveSessions() + setSessions(data) + } catch (error) { + toast.error("Failed to load sessions") + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadSessions() + }, []) + + const getDeviceIcon = (userAgent: string | null) => { + if (!userAgent) return + + const ua = userAgent.toLowerCase() + if (ua.includes("mobile") || ua.includes("android") || ua.includes("iphone")) { + return + } + if (ua.includes("tablet") || ua.includes("ipad")) { + return + } + return + } + + const getBrowserName = (userAgent: string | null) => { + if (!userAgent) return "Unknown Browser" + + const ua = userAgent.toLowerCase() + if (ua.includes("chrome")) return "Chrome" + if (ua.includes("firefox")) return "Firefox" + if (ua.includes("safari")) return "Safari" + if (ua.includes("edge")) return "Edge" + if (ua.includes("opera")) return "Opera" + return "Unknown Browser" + } + + const getDeviceType = (userAgent: string | null) => { + if (!userAgent) return "Unknown Device" + + const ua = userAgent.toLowerCase() + if (ua.includes("windows")) return "Windows" + if (ua.includes("mac")) return "macOS" + if (ua.includes("linux")) return "Linux" + if (ua.includes("android")) return "Android" + if (ua.includes("iphone") || ua.includes("ipad")) return "iOS" + return "Unknown Device" + } + + const handleRevokeSession = async (sessionId: string, refreshToken: string) => { + setRevokingId(sessionId) + try { + await revokeToken(refreshToken) + await loadSessions() + toast.success("Session revoked successfully") + } catch (error) { + // Error is already handled in revokeToken + } finally { + setRevokingId(null) + } + } + + const handleLogoutAll = async () => { + await logoutAll() + } + + const formatDate = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + } + + const isSessionExpired = (expiresAt: string) => { + return new Date(expiresAt) < new Date() + } + + const getDaysUntilExpiry = (expiresAt: string) => { + const now = new Date() + const expiry = new Date(expiresAt) + const diffTime = expiry.getTime() - now.getTime() + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + return diffDays + } + + return ( + + + +
+
+ + + Active Sessions + + + Manage your active sessions across all devices + +
+ + + + + + + Logout from all devices? + + This will revoke all active sessions and log you out from all devices. You'll need to log in again. + + + + Cancel + + Logout All + + + + +
+
+ + + {loading ? ( +
+
+ +

Loading sessions...

+
+
+ ) : sessions.length === 0 ? ( +
+
+ +
+

No active sessions

+

You don't have any active sessions at the moment.

+
+ ) : ( +
+ {sessions.map((session, index) => { + const expired = isSessionExpired(session.expires_at) + const daysUntilExpiry = getDaysUntilExpiry(session.expires_at) + + return ( + + + +
+
+
+ {getDeviceIcon(session.user_agent)} +
+
+ +
+
+
+
+

+ {getBrowserName(session.user_agent)} on {getDeviceType(session.user_agent)} +

+ {expired ? ( + + Expired + + ) : daysUntilExpiry <= 7 ? ( + + Expires soon + + ) : null} +
+ {session.user_agent && ( +

+ {session.user_agent} +

+ )} +
+ + {!expired && ( + + + + + + + Revoke this session? + + This will log out this device. You'll need to log in again on this device. + + + + Cancel + handleRevokeSession(session.id, session.refresh_token)} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + Revoke Session + + + + + )} +
+ + + +
+ {session.ip && ( +
+ + {session.ip} +
+ )} +
+ + + Created: {formatDate(session.created_at)} + +
+ {session.last_used_at && ( +
+ + + Last used: {formatDate(session.last_used_at)} + +
+ )} +
+ + + Expires: {formatDate(session.expires_at)} + +
+
+
+
+
+
+
+ ) + })} +
+ )} +
+
+ + + + + + About Sessions + + + +
+
+ + Each session represents a logged-in device +
+
+ + Sessions automatically expire after 30 days +
+
+ + You can revoke individual sessions or logout from all devices +
+
+ + Revoking a session will require re-authentication on that device +
+
+
+
+
+ ) +} diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index a2e92d9..1b2f657 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -29,6 +29,7 @@ import { XCircle, } from "lucide-react" import { ApiTokenSettings } from "./api-token-settings" +import { ActiveSessions } from "./active-sessions" interface SettingsContentProps { activeTab: string @@ -640,6 +641,10 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { ) + const renderSessionsTab = () => ( + + ) + const renderContent = () => { switch (activeTab) { case "general": @@ -650,6 +655,8 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { return renderPinTab() case "api-token": return renderApiTokenTab() + case "sessions": + return renderSessionsTab() default: return renderGeneralTab() } diff --git a/src/components/settings/settings-page.tsx b/src/components/settings/settings-page.tsx index d779bdb..3abc722 100644 --- a/src/components/settings/settings-page.tsx +++ b/src/components/settings/settings-page.tsx @@ -1,12 +1,13 @@ "use client" -import { useState } from "react" import { SettingsHeader } from "./settings-header" import { SettingsTabs } from "./settings-tabs" import { SettingsContent } from "./settings-content" import { useAuth } from "@/contexts/useAuth" +import { useQueryState } from "@/hooks/useQueryState" + export function SettingsPage() { - const [activeTab, setActiveTab] = useState("general") + const [activeTab, setActiveTab] = useQueryState("tab", (v) => (["general", "security", "pin", "api-token", "sessions"].includes(v || "") ? (v as string) : "general"), (v) => v) const { user } = useAuth() return (
diff --git a/src/components/settings/settings-tabs.tsx b/src/components/settings/settings-tabs.tsx index d3f8a5a..06fb63a 100644 --- a/src/components/settings/settings-tabs.tsx +++ b/src/components/settings/settings-tabs.tsx @@ -8,6 +8,7 @@ const tabs = [ { id: "security", label: "Security" }, { id: "pin", label: "PIN" }, { id: "api-token", label: "API Token" }, + { id: "sessions", label: "Sessions" }, ] interface SettingsTabsProps { diff --git a/src/contexts/useAuth.tsx b/src/contexts/useAuth.tsx index 1a33d9c..22a53b9 100644 --- a/src/contexts/useAuth.tsx +++ b/src/contexts/useAuth.tsx @@ -59,7 +59,9 @@ interface AuthContextType extends AuthState { register: (data: any) => Promise<{ success: boolean; error?: string }>; saveUser: (user: IUser) => void; logout: () => Promise; + logoutAll: () => Promise; logoutLoading: boolean; + logoutAllLoading: boolean; VerifyEmail: (token: string) => Promise; getAllUsers: (attributes: GetAllUsersAttributes) => Promise; setPin: (pin: string) => Promise<{ success: boolean; error?: string }>; @@ -70,6 +72,8 @@ interface AuthContextType extends AuthState { changePinLoading: boolean; getPinSession: () => Promise<{ success: boolean; user: IUser; session: PinSessionData } | { success: false; error: string }>; isPinSessionValid: () => boolean; + getActiveSessions: () => Promise; + revokeToken: (refreshToken: string) => Promise<{ success: boolean; error?: string }>; } const AuthContext = createContext(undefined); @@ -104,7 +108,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => onSuccess: async (data) => { if (data) { saveUser(data.user); - setToken(data.jwt); + setToken({ + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: data.expires_at, + refresh_expires_at: data.refresh_expires_at + }); dispatch({ type: 'LOGIN_SUCCESS', user: data.user }); toast.success("Login successful! Welcome back."); } @@ -316,6 +325,55 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => return { success: false, error: errorMessage }; } }; + + const { mutateAsync: logoutAllMutationFn, isPending: logoutAllLoading } = useMutation({ + mutationFn: async () => { + const result = await authApi.revokeAllTokens(); + return result.data; + }, + onSuccess: () => { + // Clear all local data and state + localStorage.removeItem(CONSTANTS.STORAGE_KEYS.USER_DATA); + disconnectSocket(); + removeToken(); + dispatch({ type: 'LOGOUT' }); + queryClient.clear(); + toast.success("Logged out from all devices successfully"); + }, + onError: () => { + toast.error("Failed to logout from all devices"); + }, + }); + + const logoutAll = async () => { + try { + await logoutAllMutationFn(); + } catch (error) { + console.error('Logout all error:', error); + } + }; + + const getActiveSessions = async () => { + try { + const result = await authApi.getActiveSessions(); + return result.data?.sessions || []; + } catch (error) { + console.error('Get active sessions error:', error); + return []; + } + }; + + const revokeToken = async (refreshToken: string) => { + try { + await authApi.revokeToken(refreshToken); + return { success: true }; + } catch (error: any) { + const errorMessage = error?.response?.data?.message || error?.message || "Failed to revoke session"; + toast.error(errorMessage); + return { success: false, error: errorMessage }; + } + }; + const getPinSession = useCallback(async (): Promise<{ success: true; user: IUser; session: PinSessionData } | { success: false; error: string }> => { try { dispatch({ type: 'SET_LOADING', loading: true }); @@ -333,11 +391,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => if (!state.pinSession || !state.pinSession.pin_verified) { return false; } - + const verifiedAt = new Date(state.pinSession.verified_at); const now = new Date(); const diffInMinutes = (now.getTime() - verifiedAt.getTime()) / (1000 * 60); - + return diffInMinutes <= 20; }; @@ -347,7 +405,9 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => register, saveUser, logout, + logoutAll, logoutLoading, + logoutAllLoading, getAllUsers, VerifyEmail, setPin, @@ -358,6 +418,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => changePinLoading, getPinSession, isPinSessionValid, + getActiveSessions, + revokeToken, }; return {children}; diff --git a/src/hooks/useQueryState.tsx b/src/hooks/useQueryState.tsx new file mode 100644 index 0000000..94417b0 --- /dev/null +++ b/src/hooks/useQueryState.tsx @@ -0,0 +1,37 @@ +import { useSearchParams } from "react-router-dom"; +import { useCallback } from "react"; + +type Parser = (value: string | null) => T; +type Serializer = (value: T) => string; + +/** + * Reusable hook for managing query parameters in the URL. + * + * @param key - The query parameter key + * @param parser - Function to parse the string value from URL to your desired type + * @param serializer - Function to serialize your value to a string for the URL + * @returns A tuple of [value, setValue] similar to useState + * + */ +export function useQueryState(key: string, parser: Parser, serializer: Serializer) { + const [params, setParams] = useSearchParams(); + const value = parser(params.get(key)); + + const setValue = useCallback((newValue: T) => { + const newParams = new URLSearchParams(params); + newParams.set(key, serializer(newValue)); + setParams(newParams, { replace: true }); // no history spam + }, [key, serializer, params, setParams]); + + return [value, setValue] as const; +} + +/** + * Helper function to serialize values to JSON for query params + */ +export const jsonSerializer = (v: T) => JSON.stringify(v); + +/** + * Helper function to parse JSON from query params + */ +export const jsonParser = (v: string | null, defaultValue: T): T => v ? JSON.parse(v) : defaultValue; diff --git a/src/store/connect-socket.ts b/src/store/connect-socket.ts index 481a587..0df9bc4 100644 --- a/src/store/connect-socket.ts +++ b/src/store/connect-socket.ts @@ -1,6 +1,5 @@ import { io, Socket } from "socket.io-client"; -import { toast } from "sonner"; import { getAuthState } from '@/store/auth.store'; const createSocket = (url: string): Promise => { @@ -11,11 +10,11 @@ const createSocket = (url: string): Promise => { transports: ['websocket'], timeout: 10000, autoConnect: false, // Prevent auto-connection - reconnection: false, // Disable automatic reconnection - reconnectionAttempts: 0, - reconnectionDelay: 0, + reconnection: true, // Disable automatic reconnection + reconnectionAttempts: 2, + reconnectionDelay: 1000, auth: { - token: token?.jwt_token + token: token?.access_token } }); @@ -26,20 +25,16 @@ const createSocket = (url: string): Promise => { }); socket.on('connect_error', (error) => { - const endTime = performance.now(); - const duration = endTime - startTime; - console.error(`Connection error after ${duration.toFixed(2)} ms:`, error); - toast.error(`Failed to connect: ${error.message || error}`); + console.error(`Failed to connect: ${error.message || error}`); reject(error); }); socket.on('disconnect', (reason) => { - toast.warning(`Socket disconnected: ${reason}`, { closeButton: true }); console.warn(`Socket disconnected: ${reason}`); }); socket.on('close', (reason) => { - toast.warning(`Socket closed: ${reason}`, { closeButton: true }); + console.warn(`Socket closed: ${reason}`); }); socket.connect(); // This is necessary unless autoConnect: false is removed diff --git a/src/types/user.types.ts b/src/types/user.types.ts index 8e131a5..6124c72 100644 --- a/src/types/user.types.ts +++ b/src/types/user.types.ts @@ -5,8 +5,10 @@ export const USER_ROLES = { } as const; export interface JwtToken { - jwt_token: string; - expiresAt: number + access_token: string; + refresh_token: string; + expires_at: number; + refresh_expires_at: number; } export type UserRole = typeof USER_ROLES[keyof typeof USER_ROLES]; From 9d4ca5ad7a7071fbb2a8542e0f27e27b69be0684 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sun, 7 Dec 2025 18:57:26 +0530 Subject: [PATCH 14/22] file context changes --- src/contexts/NotificationContext.tsx | 8 +++++--- src/contexts/UploadContext.tsx | 10 +++++----- src/hooks/useQueryState.tsx | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/contexts/NotificationContext.tsx b/src/contexts/NotificationContext.tsx index 4f7df8f..859196a 100644 --- a/src/contexts/NotificationContext.tsx +++ b/src/contexts/NotificationContext.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner'; import { useAuth } from './useAuth'; import { useSocket } from "@/contexts/SocketContext"; import { useNotificationUI } from "./NotificationUIContext"; +import { getAuthState } from '@/store/auth.store'; // 🔹 Notification Types export const NotificationType = { @@ -162,6 +163,7 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr const [state, dispatch] = useReducer(notificationReducer, initialState); const queryClient = useQueryClient(); const { user } = useAuth(); + const { token } = getAuthState(); const { socket, initializeSocket } = useSocket(); const { showNotification } = useNotificationUI(); @@ -170,14 +172,14 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr useEffect(() => { - if (!user) return; + if (!token?.access_token) return; void initializeSocket(); - }, [user, initializeSocket]); + }, [token, initializeSocket]); // 🔹 Socket listener with improved error handling useEffect(() => { if (!socket) return; - + const handleNewNotification = (notification: NotificationAttributes) => { // Show custom notification UI instead of toast showNotification(notification); diff --git a/src/contexts/UploadContext.tsx b/src/contexts/UploadContext.tsx index bf3646e..6d4b3af 100644 --- a/src/contexts/UploadContext.tsx +++ b/src/contexts/UploadContext.tsx @@ -214,12 +214,12 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) const completedFiles = Object.values(state.fileStates).filter( file => file.status === 'completed' ); - + if (completedFiles.length > 0) { const timeoutId = setTimeout(() => { dispatch({ type: 'CLEAR_ALL_COMPLETED' }); }, 5000); // 5 seconds delay - + // Return cleanup function return () => clearTimeout(timeoutId); } @@ -378,7 +378,7 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) fileName: files[index]?.name || 'unknown', url: fileData.storage_path })); - + dispatch({ type: 'UPLOAD_SUCCESS', payload: uploadedFiles }); return uploadedFiles; } else { @@ -392,11 +392,11 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) const deleteFile = async (fileName: string) => { dispatch({ type: 'DELETE_START' }); - + try { const response = await apiClient.delete(`/upload/file/${fileName}`, { headers: { - Authorization: token?.jwt_token ? `Bearer ${token.jwt_token}` : undefined, + Authorization: token?.access_token ? `Bearer ${token.access_token}` : undefined, }, }); diff --git a/src/hooks/useQueryState.tsx b/src/hooks/useQueryState.tsx index 94417b0..7efe284 100644 --- a/src/hooks/useQueryState.tsx +++ b/src/hooks/useQueryState.tsx @@ -29,9 +29,9 @@ export function useQueryState(key: string, parser: Parser, serializer: Ser /** * Helper function to serialize values to JSON for query params */ -export const jsonSerializer = (v: T) => JSON.stringify(v); +// export const jsonSerializer = (v: T) => JSON.stringify(v); /** * Helper function to parse JSON from query params */ -export const jsonParser = (v: string | null, defaultValue: T): T => v ? JSON.parse(v) : defaultValue; +{/* export const jsonParser = (v: string | null, defaultValue: T): T => v ? JSON.parse(v) : defaultValue; */ } From dd205df82863f47c77ccb33ccd11bc50de675833 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sun, 7 Dec 2025 23:51:14 +0530 Subject: [PATCH 15/22] ui changes --- src/api/axios.ts | 168 ++++++++++---------- src/components/session/VerifyPin.tsx | 1 - src/components/settings/active-sessions.tsx | 31 ++-- src/components/settings/settings-page.tsx | 2 +- src/components/settings/settings-tabs.tsx | 52 +++--- src/contexts/useAuth.tsx | 1 - 6 files changed, 130 insertions(+), 125 deletions(-) diff --git a/src/api/axios.ts b/src/api/axios.ts index cba1f3d..c40b7c2 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -1,111 +1,119 @@ +import axios, { AxiosError, type InternalAxiosRequestConfig } from "axios"; +import { getAuthState, useAuthStore } from "@/store/auth.store"; -import axios from 'axios'; -export const API_BASE_URL = import.meta.env.VITE_API_BACKEND_URL || 'http://localhost:3000/api/v1'; -import { getAuthState, useAuthStore } from '@/store/auth.store'; +export const API_BASE_URL = + import.meta.env.VITE_API_BACKEND_URL || "http://localhost:3000/api/v1"; -// Initialize the Axios client const apiClient = axios.create({ baseURL: API_BASE_URL, - headers: { 'Content-Type': 'application/json' }, + headers: { "Content-Type": "application/json" }, timeout: 100000, withCredentials: true, }); -// Flag to prevent multiple refresh requests +// ---------------------------- +// Refresh Token Queue Handling +// ---------------------------- let isRefreshing = false; -let failedQueue: Array<{ resolve: (value?: unknown) => void; reject: (reason?: any) => void }> = []; - -const processQueue = (error: any = null) => { - failedQueue.forEach(prom => { - if (error) { - prom.reject(error); - } else { - prom.resolve(); - } +let failedQueue: Array<{ + resolve: (token: string) => void; + reject: (error: any) => void; +}> = []; + +const processQueue = (error: any, token: string | null = null) => { + failedQueue.forEach(({ resolve, reject }) => { + error ? reject(error) : resolve(token as string); }); failedQueue = []; }; -apiClient.interceptors.request.use( - (config) => { - const { token } = getAuthState(); - if (token?.access_token) { - config.headers.Authorization = `Bearer ${token.access_token}`; - } - return config; - }, - (error) => { - return Promise.reject(error); +// ---------------------------- +// Logout Utility +// ---------------------------- +const logout = () => { + useAuthStore.getState().removeToken(); + localStorage.clear(); + window.location.href = "/login"; +}; + +// ---------------------------- +// Refresh Token Function +// ---------------------------- +const refreshAccessToken = async (): Promise => { + const { token } = getAuthState(); + if (!token?.refresh_token) throw new Error("No refresh token"); + + const res = await axios.post(`${API_BASE_URL}/auth/refresh`, { + refresh_token: token.refresh_token, + }); + + const { access_token, refresh_token, expires_at, refresh_expires_at } = + res.data.data; + + useAuthStore.getState().setToken({ + access_token, + refresh_token, + expires_at, + refresh_expires_at, + }); + + return access_token; +}; + +// ---------------------------- +// Request Interceptor +// ---------------------------- +apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const { token } = getAuthState(); + if (token?.access_token) { + config.headers.Authorization = `Bearer ${token.access_token}`; } + return config; +}, + (error) => Promise.reject(error) ); -// Add response interceptor with token refresh logic -apiClient.interceptors.response.use( - async (response) => { - return response; - }, - async (error) => { - const originalRequest = error.config; +// ---------------------------- +// Response Interceptor +// ---------------------------- +apiClient.interceptors.response.use((response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as any; - // If error is 401 and we haven't tried to refresh yet + // If unauthorized and token can be refreshed if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + // If refresh is already running -> queue this request if (isRefreshing) { - // If already refreshing, queue this request return new Promise((resolve, reject) => { - failedQueue.push({ resolve, reject }); - }).then(() => { - const { token } = getAuthState(); - if (token?.access_token) { - originalRequest.headers.Authorization = `Bearer ${token.access_token}`; - } - return apiClient(originalRequest); - }).catch(err => { - return Promise.reject(err); + failedQueue.push({ + resolve: (newToken) => { + originalRequest.headers.Authorization = `Bearer ${newToken}`; + resolve(apiClient(originalRequest)); + }, + reject, + }); }); } - originalRequest._retry = true; + // Start refresh isRefreshing = true; - const { token } = getAuthState(); - - if (!token?.refresh_token) { - // No refresh token available, logout - isRefreshing = false; - processQueue(error); - await logout(); - return Promise.reject(error); - } - try { - // Attempt to refresh the token - const response = await axios.post(`${API_BASE_URL}/auth/refresh`, { - refresh_token: token.refresh_token - }); - - const { access_token, refresh_token, expires_at, refresh_expires_at } = response.data.data; - - // Update the token in the store - useAuthStore.getState().setToken({ - access_token, - refresh_token, - expires_at, - refresh_expires_at - }); - - // Update the authorization header - originalRequest.headers.Authorization = `Bearer ${access_token}`; + const newToken = await refreshAccessToken(); + // Update queued requests + processQueue(null, newToken); isRefreshing = false; - processQueue(); - // Retry the original request + // Retry original request with new token + originalRequest.headers.Authorization = `Bearer ${newToken}`; return apiClient(originalRequest); - } catch (refreshError) { - // Refresh failed, logout user + } catch (refreshError: any) { + processQueue(refreshError, null); isRefreshing = false; - processQueue(refreshError); - await logout(); + logout(); return Promise.reject(refreshError); } } @@ -114,10 +122,4 @@ apiClient.interceptors.response.use( } ); -const logout = async () => { - useAuthStore.getState().removeToken(); - localStorage.clear(); - window.location.href = "/login"; -} - export default apiClient; diff --git a/src/components/session/VerifyPin.tsx b/src/components/session/VerifyPin.tsx index 8a1478f..8853336 100644 --- a/src/components/session/VerifyPin.tsx +++ b/src/components/session/VerifyPin.tsx @@ -70,7 +70,6 @@ export function VerifyPinModal({ setPin("") setError("") setIsVerified(true) - toast.success("PIN verified successfully") onVerified() } else { setError(result.error || "Invalid PIN. Please try again.") diff --git a/src/components/settings/active-sessions.tsx b/src/components/settings/active-sessions.tsx index 82c835c..78b934b 100644 --- a/src/components/settings/active-sessions.tsx +++ b/src/components/settings/active-sessions.tsx @@ -141,7 +141,7 @@ export function ActiveSessions() { className="space-y-6" > - +
@@ -158,7 +158,7 @@ export function ActiveSessions() { variant="destructive" size="sm" disabled={logoutAllLoading || sessions.length === 0} - className="shrink-0" + className="shrink-0 w-full sm:w-auto" > {logoutAllLoading ? ( <> @@ -194,16 +194,16 @@ export function ActiveSessions() {
- + {loading ? ( -
+

Loading sessions...

) : sessions.length === 0 ? ( -
+
@@ -229,11 +229,14 @@ export function ActiveSessions() { expired && "opacity-50 border-dashed" )} > - -
+ +
-
-
+
+

- {getBrowserName(session.user_agent)} on {getDeviceType(session.user_agent)} + {getBrowserName(session.user_agent)} on {getDeviceType(session.user_agent)}

{expired ? ( @@ -260,7 +263,7 @@ export function ActiveSessions() { ) : null}
{session.user_agent && ( -

+

{session.user_agent}

)} @@ -273,7 +276,7 @@ export function ActiveSessions() { variant="ghost" size="sm" disabled={revokingId === session.id} - className="shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10" + className="shrink-0 w-full sm:w-auto text-destructive hover:text-destructive hover:bg-destructive/10 border border-destructive/20 sm:border-transparent" > {revokingId === session.id ? ( <> diff --git a/src/components/settings/settings-page.tsx b/src/components/settings/settings-page.tsx index 3abc722..c2ebbe7 100644 --- a/src/components/settings/settings-page.tsx +++ b/src/components/settings/settings-page.tsx @@ -10,7 +10,7 @@ export function SettingsPage() { const [activeTab, setActiveTab] = useQueryState("tab", (v) => (["general", "security", "pin", "api-token", "sessions"].includes(v || "") ? (v as string) : "general"), (v) => v) const { user } = useAuth() return ( -
+
diff --git a/src/components/settings/settings-tabs.tsx b/src/components/settings/settings-tabs.tsx index 06fb63a..1c3a7a4 100644 --- a/src/components/settings/settings-tabs.tsx +++ b/src/components/settings/settings-tabs.tsx @@ -18,31 +18,33 @@ interface SettingsTabsProps { export function SettingsTabs({ activeTab, onTabChange }: SettingsTabsProps) { return ( -
- +
+
+ +
) } diff --git a/src/contexts/useAuth.tsx b/src/contexts/useAuth.tsx index 22a53b9..0bd0886 100644 --- a/src/contexts/useAuth.tsx +++ b/src/contexts/useAuth.tsx @@ -214,7 +214,6 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => if (data?.session) { dispatch({ type: 'SET_PIN_SESSION', pinSession: data.session }); } - toast.success("PIN verified successfully. Session created."); }, onError: () => { dispatch({ type: 'SET_LOADING', loading: false }); From a28ea8d5c07aa9c44d9e6f8f433b54b1fd2704b7 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Tue, 9 Dec 2025 09:57:51 +0530 Subject: [PATCH 16/22] some ui and funcnality changes --- src/api/axios.ts | 5 +-- src/pages/home-dashboard.tsx | 68 +++++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/api/axios.ts b/src/api/axios.ts index c40b7c2..dbc96c2 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -15,10 +15,11 @@ const apiClient = axios.create({ // Refresh Token Queue Handling // ---------------------------- let isRefreshing = false; -let failedQueue: Array<{ +interface IFailedQueueItem { resolve: (token: string) => void; reject: (error: any) => void; -}> = []; +} +let failedQueue: IFailedQueueItem[] = []; const processQueue = (error: any, token: string | null = null) => { failedQueue.forEach(({ resolve, reject }) => { diff --git a/src/pages/home-dashboard.tsx b/src/pages/home-dashboard.tsx index 6e998e1..c7ac9f8 100644 --- a/src/pages/home-dashboard.tsx +++ b/src/pages/home-dashboard.tsx @@ -1,4 +1,5 @@ "use client" +import { useState } from "react" import { motion } from "framer-motion" import { Clock, @@ -12,8 +13,8 @@ import { Star, Upload, FolderPlus, - Search, Activity, + Lock, } from "lucide-react" import { useNavigate } from "react-router-dom" @@ -23,7 +24,10 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Progress } from "@/components/ui/progress" import { cn } from "@/lib/utils" import { useAuth } from "@/contexts/useAuth" -// recent files are now loaded via RecentFiles component +import { ACCESS_LEVEL } from "@/types/file.types" +import { AddNewFolder } from "@/components/file-manager/AddNewFolder" +import { useFile } from "@/contexts/fileContext" +import { toast } from "sonner" const quickStats = [ { label: "Total Files", value: "1,247", icon: FileText, change: "+12%", color: "from-blue-500 to-blue-600" }, @@ -44,12 +48,59 @@ const quickActions = [ { label: "Upload Files", icon: Upload, color: "from-blue-500 to-blue-600", description: "Add new files" }, { label: "New Folder", icon: FolderPlus, color: "from-green-500 to-green-600", description: "Create folder" }, { label: "Share Files", icon: Users, color: "from-purple-500 to-purple-600", description: "Share with team" }, - { label: "Search Files", icon: Search, color: "from-orange-500 to-orange-600", description: "Find anything" }, + { label: "Private Files", icon: Lock, color: "from-orange-500 to-orange-600", description: "Secure your files" }, ] export function HomeDashboard() { const navigate = useNavigate() const { user } = useAuth() + const { createFolder } = useFile() + const [isCreateFolderModalOpen, setIsCreateFolderModalOpen] = useState(false) + + const navigateToUploadFile = ({ folderId, folderName }: { folderId: string, folderName: string }) => { + navigate(`/all-files/${folderId}`, { + state: { folder_id: folderId, folder_name: folderName, access_level: ACCESS_LEVEL.PROTECTED } + }); + } + + const handleCreateFolder = async (folderName: string): Promise<{ success: boolean; error?: string }> => { + try { + // Create folder at root level (no parent_id) + const result = await createFolder({ + name: folderName.trim(), + parent_id: undefined + }) + if (result.success) { + toast.success("Folder created successfully") + navigate('/all-files') + } + return result + } catch (error: any) { + return { + success: false, + error: error?.message || 'Failed to create folder' + } + } + } + + const handleQuickActionClick = (label: string) => { + switch (label) { + case "Upload Files": + navigateToUploadFile({ folderId: "root", folderName: "root" }); + break; + case "New Folder": + setIsCreateFolderModalOpen(true); + break; + case "Share Files": + navigate('/shared-files'); + break; + case "Private Files": + navigate('/private-files'); + break; + default: + break; + } + }; return (
@@ -86,6 +137,7 @@ export function HomeDashboard() { transition={{ duration: 0.3, delay: 0.1 + index * 0.05 }} whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} + onClick={() => handleQuickActionClick(action.label)} >
- + {/* Create Folder Modal */} + {isCreateFolderModalOpen && setIsCreateFolderModalOpen(false)} + />}
) From 5f5291e9c1219d6c05d85f797bdeee5bf65ef497 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Tue, 9 Dec 2025 10:13:08 +0530 Subject: [PATCH 17/22] navigation ui changes --- src/components/sidebar/sidebar-navigation.tsx | 84 +++++++++++++------ 1 file changed, 57 insertions(+), 27 deletions(-) diff --git a/src/components/sidebar/sidebar-navigation.tsx b/src/components/sidebar/sidebar-navigation.tsx index 21f3b84..d34b1f0 100644 --- a/src/components/sidebar/sidebar-navigation.tsx +++ b/src/components/sidebar/sidebar-navigation.tsx @@ -1,54 +1,84 @@ "use client" -import { useState, useMemo } from "react" +import { useState, useMemo, useEffect } from "react" import { Home, FolderOpen, Lock, Users, Trash2, Bell, Settings } from "lucide-react" import { SidebarNavItem } from "./sidebar-nav-item" -import { useNavigate } from "react-router-dom" +import { useNavigate, useLocation } from "react-router-dom" import { useNotifications } from "@/contexts/NotificationContext" -// Define base navigation items without dynamic properties -const baseNavigationItems = [ +interface NavItem { + icon: any + label: string + href: string + badge?: number +} + +interface SidebarNavigationProps { + onNavigate?: () => void +} + +const BASE_NAV_ITEMS: NavItem[] = [ { icon: Home, label: "Home", href: "/" }, { icon: FolderOpen, label: "All files", href: "/all-files" }, { icon: Lock, label: "Private files", href: "/private-files" }, { icon: Users, label: "Shared with me", href: "/shared-files" }, { icon: Trash2, label: "Deleted files", href: "/deleted-files" }, - { icon: Bell, label: "Notifications", href: "/notifications", badge: 0 }, + { icon: Bell, label: "Notifications", href: "/notifications" }, { icon: Settings, label: "Settings", href: "/settings" }, ] -interface SidebarNavigationProps { - onNavigate?: () => void -} - export function SidebarNavigation({ onNavigate }: SidebarNavigationProps) { - const { unreadCount } = useNotifications() - const [activeItem, setActiveItem] = useState("/settings") const navigate = useNavigate() + const location = useLocation() + const { unreadCount } = useNotifications() + + const [activeItem, setActiveItem] = useState("") + + /** + * Determine the active menu based on current route + */ + useEffect(() => { + const pathname = location.pathname - // Memoize the navigation items to prevent unnecessary recalculations + // Match case 1: exact match (for "/") + if (pathname === "/") { + setActiveItem("/") + return + } + + // Match case 2: nested routes (e.g., /all-files/123) + const matched = BASE_NAV_ITEMS.find(item => + pathname === item.href || pathname.startsWith(`${item.href}/`) + ) + + if (matched) { + setActiveItem(matched.href) + } + }, [location.pathname]) + + /** + * Add unread count to notification tab dynamically + */ const navigationItems = useMemo(() => { - return baseNavigationItems.map(item => { - if (item.href === "/notifications") { - return { - ...item, - badge: unreadCount > 0 ? unreadCount : undefined - } - } - return item - }) - }, [unreadCount]) // Only recalculate when unreadCount changes + return BASE_NAV_ITEMS.map(item => + item.href === "/notifications" + ? { ...item, badge: unreadCount || undefined } + : item + ) + }, [unreadCount]) - const navigationItemClick = (href: string) => { + /** + * Handle sidebar click navigation + */ + const handleItemClick = (href: string) => { setActiveItem(href) navigate(href) - // Close sidebar on mobile after navigation onNavigate?.() } return ( ) -} \ No newline at end of file +} From 1f3cf1a7b32ade11655d72e8656686ee50ed79b2 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sun, 28 Dec 2025 19:30:45 +0530 Subject: [PATCH 18/22] file flow upload ui changes --- src/api/upload.api.ts | 175 +++++++++++++++++++++++ src/components/upload/FIleUploader.tsx | 159 ++++++++++++++------- src/components/upload/UploadPopup.tsx | 70 ++++++++-- src/contexts/UploadContext.tsx | 185 +++++++++++++++---------- src/hooks/useUploadMutations.ts | 67 +++++++++ src/providers/AppProviders.tsx | 19 ++- 6 files changed, 533 insertions(+), 142 deletions(-) create mode 100644 src/api/upload.api.ts create mode 100644 src/hooks/useUploadMutations.ts diff --git a/src/api/upload.api.ts b/src/api/upload.api.ts new file mode 100644 index 0000000..795abaa --- /dev/null +++ b/src/api/upload.api.ts @@ -0,0 +1,175 @@ +import apiClient from "@/api/axios"; +import type { AxiosProgressEvent } from "axios"; + +// Types +export interface InitiateUploadPayload { + fileName: string; + mimeType: string; + folderId?: string; +} + +export interface InitiateUploadResponse { + uploadId: string; + key: string; +} + +export interface UploadChunkPayload { + uploadId: string; + key: string; + chunk: Blob; + metadata: { + chunkNumber: number; + totalChunks: number; + fileSize: number; + originalFileName: string; + mimeType: string; + }; + onUploadProgress?: (progressEvent: AxiosProgressEvent) => void; +} + +export interface UploadChunkResponse { + PartNumber: number; + ETag: string; +} + +export interface CompletePart { + PartNumber: number; + ETag: string; +} + +export interface CompleteUploadPayload { + uploadId: string; + key: string; + parts: CompletePart[]; +} + +export interface CompleteUploadResponse { + file_type: string; + file_size: number; + storage_path: string; + thumbnail_path?: string; + duration?: number; +} + +export interface AbortUploadPayload { + uploadId: string; + key: string; +} + +export interface GetUploadedPartsPayload { + uploadId: string; + key: string; +} + +export interface GetUploadedPartsResponse { + parts: CompletePart[]; +} + +export interface DirectUploadResponse { + storage_path: string; +} + +// API Functions + +/** + * Initiate a multipart upload + */ +export const initiateMultipartUpload = async ( + payload: InitiateUploadPayload +): Promise => { + const response = await apiClient.post('/upload/initiate', payload); + return response.data.data; +}; + +/** + * Upload a single chunk + */ +export const uploadChunk = async ( + payload: UploadChunkPayload +): Promise => { + const formData = new FormData(); + formData.append('chunk', payload.chunk); + formData.append('key', payload.key); + formData.append('metadata', JSON.stringify(payload.metadata)); + + const response = await apiClient.post( + `/upload/chunk/file/${payload.uploadId}`, + formData, + { + headers: { 'Content-Type': 'multipart/form-data' }, + onUploadProgress: payload.onUploadProgress, + } + ); + + return response.data.data; +}; + +/** + * Complete a multipart upload + */ +export const completeMultipartUpload = async ( + payload: CompleteUploadPayload +): Promise => { + const response = await apiClient.post( + `/upload/complete/file/${payload.uploadId}`, + { key: payload.key, parts: payload.parts } + ); + return response.data.data; +}; + +/** + * Abort a multipart upload + */ +export const abortMultipartUpload = async ( + payload: AbortUploadPayload +): Promise => { + await apiClient.post(`/upload/abort/file/${payload.uploadId}`, { + key: payload.key, + }); +}; + +/** + * Get already uploaded parts for a multipart upload + */ +export const getUploadedParts = async ( + payload: GetUploadedPartsPayload +): Promise => { + const response = await apiClient.get( + `/upload/parts/file/${payload.uploadId}?key=${payload.key}` + ); + return response.data; +}; + +/** + * Direct upload for non-video files (images, PDFs, etc.) + */ +export const directUpload = async ( + files: File[] +): Promise => { + const formData = new FormData(); + files.forEach((file) => formData.append('files', file)); + + const response = await apiClient.post('/upload/file', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + if (response.data?.success && response.data?.data) { + return response.data.data.map((fileData: any) => ({ + storage_path: fileData.storage_path, + })); + } + + throw new Error(response.data?.message || 'Upload failed - invalid response'); +}; + +export default { + initiateMultipartUpload, + uploadChunk, + completeMultipartUpload, + abortMultipartUpload, + getUploadedParts, + directUpload, +}; + diff --git a/src/components/upload/FIleUploader.tsx b/src/components/upload/FIleUploader.tsx index 4750fcd..269a170 100644 --- a/src/components/upload/FIleUploader.tsx +++ b/src/components/upload/FIleUploader.tsx @@ -14,7 +14,8 @@ import { Pause, RotateCcw, CheckCircle, - AlertTriangle + AlertTriangle, + Loader2 } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { useUpload } from '@/contexts/UploadContext'; @@ -64,6 +65,7 @@ const FileUploader: React.FC = ({ abortUpload, removeFile, updateFileState, + setButtonLoading, autoClearCompleted } = useUpload(); @@ -175,10 +177,14 @@ const FileUploader: React.FC = ({ const handleFileUpload = async (file: File) => { const fileType = getFileType(file); + // Set button loading state + setButtonLoading(file.name, 'upload', true); + try { switch (fileType) { case 'video': { // Use chunked upload for video files + updateFileState(file.name, { isUploading: true }); const result = await handleUpload(file, folderId); if (result) { await createFile({ @@ -191,7 +197,8 @@ const FileUploader: React.FC = ({ url: result.storage_path, status: 'completed', progress: 100, - lastUploadedChunk: Math.ceil(file.size / (5 * 1024 * 1024)) + lastUploadedChunk: Math.ceil(file.size / (5 * 1024 * 1024)), + isUploading: false }); setCompletedFiles(prev => new Set([...prev, file.name])); } @@ -219,13 +226,18 @@ const FileUploader: React.FC = ({ totalChunks: 1, fileName: file.name, fileSize: file.size, - fileType: file.type + fileType: file.type, + isUploading: true, + isRetrying: false, + isAborting: false, + isRemoving: false }); } else { updateFileState(file.name, { status: 'uploading', progress: 0, - error: null + error: null, + isUploading: true }); } @@ -258,7 +270,8 @@ const FileUploader: React.FC = ({ status: 'completed', progress: 100, lastUploadedChunk: 1, - totalChunks: 1 // Set to 1 for direct uploads + totalChunks: 1, // Set to 1 for direct uploads + isUploading: false }); setCompletedFiles(prev => new Set([...prev, file.name])); } else { @@ -270,25 +283,35 @@ const FileUploader: React.FC = ({ } catch (error: any) { updateFileState(file.name, { status: 'error', - error: error.message || 'Upload failed' + error: error.response?.data?.message || error.response?.data?.error || error.message || 'Upload failed', + isUploading: false, + isRetrying: false }); + } finally { + setButtonLoading(file.name, 'upload', false); } }; const handleRemoveFile = async (file: File) => { const fileState = fileStates[file.name]; - if (fileState?.status === 'uploading' && fileState.totalChunks > 1) { - // Only abort chunked uploads (video files) - await abortUpload(file.name); - } else { - removeFile(file.name); + setButtonLoading(file.name, 'remove', true); + + try { + if (fileState?.status === 'uploading' && fileState.totalChunks > 1) { + // Only abort chunked uploads (video files) + await abortUpload(file.name); + } else { + removeFile(file.name); + } + setSelectedFiles(prev => prev.filter(f => f.name !== file.name)); + setCompletedFiles(prev => { + const newSet = new Set(prev); + newSet.delete(file.name); + return newSet; + }); + } finally { + setButtonLoading(file.name, 'remove', false); } - setSelectedFiles(prev => prev.filter(f => f.name !== file.name)); - setCompletedFiles(prev => { - const newSet = new Set(prev); - newSet.delete(file.name); - return newSet; - }); }; const formatFileSize = (bytes: number): string => { @@ -388,23 +411,23 @@ const FileUploader: React.FC = ({ />
-
-
-

{isDragOver ? 'Release to upload' : 'Drop files here or click to browse'}

-

@@ -430,7 +453,7 @@ const FileUploader: React.FC = ({

{/* Warning Message */} {showWarning && ( -
= ({ }} >
-
-

Upload in Progress

-

@@ -462,7 +485,7 @@ const FileUploader: React.FC = ({ )} {/* Close Button - Show when all files are completed */} {allFilesCompleted && ( -

= ({ }} >
- - @@ -503,11 +526,11 @@ const FileUploader: React.FC = ({ {/* File List */} {selectedFiles.length > 0 && ( -
-

@@ -541,20 +564,20 @@ const FileUploader: React.FC = ({ >
-
{getFileIcon(getFileType(file))}
-

{file.name}

-

@@ -590,7 +613,7 @@ const FileUploader: React.FC = ({ : 'Ready to upload'} {fileState.status === 'uploading' && fileState.totalChunks > 1 && ( - @@ -598,13 +621,13 @@ const FileUploader: React.FC = ({ )}

-
= ({ {fileState.status === 'idle' && ( )} {fileState.status === 'error' && ( )} {fileState.status === 'uploading' && fileState.totalChunks > 1 && ( )} {fileState.status === 'completed' && fileState.url && ( @@ -682,8 +741,8 @@ const FileUploader: React.FC = ({
{fileState.error && ( -

diff --git a/src/components/upload/UploadPopup.tsx b/src/components/upload/UploadPopup.tsx index 5151d8d..1b129b3 100644 --- a/src/components/upload/UploadPopup.tsx +++ b/src/components/upload/UploadPopup.tsx @@ -9,7 +9,8 @@ import { RotateCcw, Trash2, CheckCircle, - AlertCircle + AlertCircle, + Loader2 } from 'lucide-react'; import { useUpload } from '@/contexts/UploadContext'; import { useLocation } from 'react-router-dom'; @@ -23,7 +24,8 @@ const UploadPopup: React.FC = () => { setPopupMinimized, setPopupVisible, clearAllCompleted, - handleUpload + handleUpload, + setButtonLoading } = useUpload(); const { fileStates, isPopupMinimized, isPopupVisible } = state; @@ -81,13 +83,38 @@ const UploadPopup: React.FC = () => { const handleRetry = async (fileName: string) => { const fileState = fileStates[fileName]; if (fileState) { - // Create a File object from the stored metadata - const file = new File([], fileState.fileName, { type: fileState.fileType }); - Object.defineProperty(file, 'size', { value: fileState.fileSize }); - await handleUpload(file); + // Set button loading state + setButtonLoading(fileName, 'retry', true); + + try { + // Create a File object from the stored metadata for retry + const file = new File([], fileState.fileName, { type: fileState.fileType }); + Object.defineProperty(file, 'size', { value: fileState.fileSize }); + + // The handleUpload function will automatically resume from the last uploaded chunk + // because it checks for existing uploadId and fileKey in the state + await handleUpload(file); + } finally { + setButtonLoading(fileName, 'retry', false); + } } }; + const handleAbort = async (fileName: string) => { + setButtonLoading(fileName, 'abort', true); + try { + await abortUpload(fileName); + } finally { + setButtonLoading(fileName, 'abort', false); + } + }; + + const handleRemove = (fileName: string) => { + setButtonLoading(fileName, 'remove', true); + removeFile(fileName); + // No need to set loading to false as the file will be removed + }; + return (

{
{fileState.status === 'uploading' && ( )} {fileState.status === 'error' && ( )} {(fileState.status === 'completed' || fileState.status === 'error') && ( )}
diff --git a/src/contexts/UploadContext.tsx b/src/contexts/UploadContext.tsx index 6d4b3af..494640e 100644 --- a/src/contexts/UploadContext.tsx +++ b/src/contexts/UploadContext.tsx @@ -1,7 +1,8 @@ // @/contexts/uploadContext.tsx import React, { createContext, useContext, useReducer, useCallback, type ReactNode } from 'react'; -import apiClient from '@/api/axios'; +import { useInitiateUpload, useUploadChunk, useCompleteUpload, useAbortUpload, useDirectUpload, useGetUploadedParts } from '@/hooks/useUploadMutations'; import { getAuthState } from '@/store/auth.store'; +import apiClient from '@/api/axios'; export const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB chunks @@ -32,6 +33,10 @@ export interface FileUploadState { fileName: string; fileSize: number; fileType: string; + isUploading: boolean; + isRetrying: boolean; + isAborting: boolean; + isRemoving: boolean; } export interface FileObject { @@ -51,6 +56,7 @@ type UploadAction = | { type: 'UPLOAD_SUCCESS'; payload: FileObject[] } | { type: 'DELETE_SUCCESS' } | { type: 'ERROR'; payload: string } + | { type: 'SET_BUTTON_LOADING'; payload: { fileName: string; button: 'upload' | 'retry' | 'abort' | 'remove'; loading: boolean } } | { type: 'RESET' }; interface UploadState { @@ -93,6 +99,10 @@ const uploadReducer = (state: UploadState, action: UploadAction): UploadState => fileName: action.payload.fileName, fileSize: action.payload.fileSize, fileType: action.payload.fileType, + isUploading: false, + isRetrying: false, + isAborting: false, + isRemoving: false, } }, // isPopupVisible: true, @@ -153,6 +163,20 @@ const uploadReducer = (state: UploadState, action: UploadAction): UploadState => return { ...state, loading: false, success: true, uploadedFiles: [] }; case 'ERROR': return { ...state, loading: false, error: action.payload, success: false }; + case 'SET_BUTTON_LOADING': + return { + ...state, + fileStates: { + ...state.fileStates, + [action.payload.fileName]: { + ...state.fileStates[action.payload.fileName], + isUploading: action.payload.button === 'upload' ? action.payload.loading : state.fileStates[action.payload.fileName]?.isUploading || false, + isRetrying: action.payload.button === 'retry' ? action.payload.loading : state.fileStates[action.payload.fileName]?.isRetrying || false, + isAborting: action.payload.button === 'abort' ? action.payload.loading : state.fileStates[action.payload.fileName]?.isAborting || false, + isRemoving: action.payload.button === 'remove' ? action.payload.loading : state.fileStates[action.payload.fileName]?.isRemoving || false, + } + } + }; case 'RESET': return initialState; @@ -175,6 +199,7 @@ interface UploadContextType { autoClearCompleted: () => void; uploadFiles: (files: File[]) => Promise; deleteFile: (fileName: string) => Promise; + setButtonLoading: (fileName: string, button: 'upload' | 'retry' | 'abort' | 'remove', loading: boolean) => void; reset: () => void; } @@ -185,6 +210,14 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) const [state, dispatch] = useReducer(uploadReducer, initialState); const { token } = getAuthState(); + // React Query mutations + const initiateUploadMutation = useInitiateUpload(); + const uploadChunkMutation = useUploadChunk(); + const completeUploadMutation = useCompleteUpload(); + const abortUploadMutation = useAbortUpload(); + const directUploadMutation = useDirectUpload(); + const getUploadedPartsMutation = useGetUploadedParts(); + const updateFileState = useCallback((fileName: string, updates: Partial) => { dispatch({ type: 'UPDATE_FILE_STATE', payload: { fileName, updates } }); }, []); @@ -209,11 +242,13 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) dispatch({ type: 'CLEAR_ALL_COMPLETED' }); }, []); + const setButtonLoading = useCallback((fileName: string, button: 'upload' | 'retry' | 'abort' | 'remove', loading: boolean) => { + dispatch({ type: 'SET_BUTTON_LOADING', payload: { fileName, button, loading } }); + }, []); + // Auto-clear completed uploads after 5 seconds const autoClearCompleted = useCallback(() => { - const completedFiles = Object.values(state.fileStates).filter( - file => file.status === 'completed' - ); + const completedFiles = Object.values(state.fileStates).filter(file => file.status === 'completed'); if (completedFiles.length > 0) { const timeoutId = setTimeout(() => { @@ -234,17 +269,16 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) let uploadId = state.fileStates[file.name]?.uploadId; let key = state.fileStates[file.name]?.fileKey; let parts: { PartNumber: number; ETag: string }[] = []; - let startChunk = state.fileStates[file.name]?.lastUploadedChunk || 0; + let startChunk = 0; const totalChunks = Math.ceil(file.size / CHUNK_SIZE); if (!uploadId) { // Initiate upload (1st time) - const response = await apiClient.post('/upload/initiate', { + const initiateResponse = await initiateUploadMutation.mutateAsync({ fileName: file.name, mimeType: file.type, folderId }); - const initiateResponse = response.data.data as UploadResponse; uploadId = initiateResponse.uploadId; key = initiateResponse.key; @@ -256,9 +290,14 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) }); } else { // Resume upload - get already uploaded parts - const { data: uploadedParts } = await apiClient.get(`/upload/parts/file/${uploadId}?key=${key}`); - parts = uploadedParts.parts || []; - startChunk = Math.max(...parts.map(part => part.PartNumber), 0); + const uploadedPartsResponse = await getUploadedPartsMutation.mutateAsync({ + uploadId, + key: key! + }); + parts = uploadedPartsResponse.parts || []; + + // Fix: Calculate correct startChunk from already uploaded parts + startChunk = parts.length > 0 ? Math.max(...parts.map(part => part.PartNumber)) : 0; // Calculate progress based on already uploaded chunks const resumeProgress = Math.round((startChunk / totalChunks) * 100); @@ -268,7 +307,12 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) }); } + // Upload chunks for (let chunkNumber = startChunk; chunkNumber < totalChunks; chunkNumber++) { + // Skip already uploaded chunks + if (state.fileStates[file.name]?.isAborting) { + throw new Error('Upload aborted'); + } if (parts.some(part => part.PartNumber === chunkNumber + 1)) { continue; } @@ -277,115 +321,103 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) const end = Math.min(start + CHUNK_SIZE, file.size); const chunk = file.slice(start, end); - const formData = new FormData(); - formData.append('chunk', chunk); - if (key) { - formData.append('key', key); - } - formData.append('metadata', JSON.stringify({ - chunkNumber: chunkNumber + 1, - totalChunks, - fileSize: file.size, - originalFileName: file.name, - mimeType: file.type, - })); - - try { - const response = await apiClient.post(`/upload/chunk/file/${uploadId}`, formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - onUploadProgress: (progressEvent: any) => { - if (progressEvent.total) { - const chunkProgress = progressEvent.loaded / progressEvent.total; - const overallProgress = Math.round(((chunkNumber + chunkProgress) / totalChunks) * 100); - updateFileState(file.name, { progress: overallProgress }); - } - }, - }); - - const chunkResponse = response.data.data as { PartNumber: number; ETag: string }; - parts.push({ PartNumber: chunkResponse.PartNumber, ETag: chunkResponse.ETag }); - updateFileState(file.name, { lastUploadedChunk: chunkNumber + 1 }); - } catch (error: any) { - console.error('Failed to upload chunk', error); - updateFileState(file.name, { - status: 'error', - error: `Failed to upload chunk ${chunkNumber + 1}: ${error.message}` - }); - throw error; - } - } + const chunkResponse = await uploadChunkMutation.mutateAsync({ + uploadId: uploadId!, + key: key!, + chunk, + metadata: { + chunkNumber: chunkNumber + 1, + totalChunks, + fileSize: file.size, + originalFileName: file.name, + mimeType: file.type, + }, + onUploadProgress: (progressEvent) => { + if (progressEvent.total) { + const chunkProgress = progressEvent.loaded / progressEvent.total; + const overallProgress = Math.round(((chunkNumber + chunkProgress) / totalChunks) * 100); + updateFileState(file.name, { progress: overallProgress }); + } + }, + }); + parts.push({ PartNumber: chunkResponse.PartNumber, ETag: chunkResponse.ETag }); + updateFileState(file.name, { lastUploadedChunk: chunkNumber + 1 }); + } updateFileState(file.name, { status: 'processing', error: null }); try { - const response = await apiClient.post(`/upload/complete/file/${uploadId}`, { key, parts }); - const completeResponse = response.data.data as CompleteUploadResponse; + const completeResponse = await completeUploadMutation.mutateAsync({ + uploadId: uploadId!, + key: key!, + parts + }); updateFileState(file.name, { status: 'completed', progress: 100, - url: completeResponse.storage_path + url: completeResponse.storage_path, + isUploading: false, + isRetrying: false }); return completeResponse; } catch (error: any) { updateFileState(file.name, { status: 'error', - error: `Failed to complete upload: ${error.message}` + error: error.response?.data?.message || error.response?.data?.error || error.message || 'Failed to complete upload', + isUploading: false, + isRetrying: false }); throw error; } } catch (error: any) { updateFileState(file.name, { status: 'error', - error: `Upload failed: ${error.message}` + error: error.response?.data?.message || error.response?.data?.error || error.message || 'Upload failed', + isUploading: false, + isRetrying: false }); } - }, [state.fileStates, updateFileState, initializeFile]); + }, [state.fileStates, updateFileState, initializeFile, initiateUploadMutation, uploadChunkMutation, completeUploadMutation, getUploadedPartsMutation]); const abortUpload = useCallback(async (fileName: string) => { try { const fileState = state.fileStates[fileName]; if (fileState?.uploadId && fileState?.fileKey) { - await apiClient.post(`/upload/abort/file/${fileState.uploadId}`, { + updateFileState(fileName, { isAborting: true }); + await abortUploadMutation.mutateAsync({ + uploadId: fileState.uploadId, key: fileState.fileKey }); + updateFileState(fileName, { isAborting: false }); } removeFile(fileName); } catch (error: any) { updateFileState(fileName, { status: 'error', - error: `Failed to abort upload: ${error.message}` + error: error.response?.data?.message || error.response?.data?.error || error.message || 'Failed to abort upload', + isAborting: false }); } - }, [state.fileStates, updateFileState, removeFile]); + }, [state.fileStates, updateFileState, removeFile, abortUploadMutation]); const uploadFiles = async (files: File[]) => { dispatch({ type: 'UPLOAD_START' }); try { - const formData = new FormData(); - files.forEach((file) => formData.append('files', file)); + const uploadedData = await directUploadMutation.mutateAsync(files); - const response = await apiClient.post('/upload/file', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }); - if (response.data?.success && response.data?.data) { - // The API returns an array of file objects with storage_path - const uploadedFiles = response.data.data.map((fileData: any, index: number) => ({ - fileName: files[index]?.name || 'unknown', - url: fileData.storage_path - })); - - dispatch({ type: 'UPLOAD_SUCCESS', payload: uploadedFiles }); - return uploadedFiles; - } else { - throw new Error(response.data?.message || 'Upload failed - invalid response'); - } + const uploadedFiles = uploadedData.map((fileData, index) => ({ + fileName: files[index]?.name || 'unknown', + url: fileData.storage_path + })); + + dispatch({ type: 'UPLOAD_SUCCESS', payload: uploadedFiles }); + return uploadedFiles; } catch (error: any) { - dispatch({ type: 'ERROR', payload: error?.response?.data?.message || 'Network error' }); + console.log('uploadFiles error', error); + dispatch({ type: 'ERROR', payload: error?.response?.data?.message || error?.message || 'Network error' }); return []; } }; @@ -428,6 +460,7 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) autoClearCompleted, uploadFiles, deleteFile, + setButtonLoading, reset, }; diff --git a/src/hooks/useUploadMutations.ts b/src/hooks/useUploadMutations.ts new file mode 100644 index 0000000..331c579 --- /dev/null +++ b/src/hooks/useUploadMutations.ts @@ -0,0 +1,67 @@ +import { useMutation } from "@tanstack/react-query"; +import * as uploadApi from "@/api/upload.api"; +import fileApi from "@/api/file.api"; +import type { AccessLevel } from "@/types/file.types"; + +// Hook for initiating multipart upload +export const useInitiateUpload = () => { + return useMutation({ + mutationFn: uploadApi.initiateMultipartUpload, + }); +}; + +// Hook for uploading a chunk +export const useUploadChunk = () => { + return useMutation({ + mutationFn: uploadApi.uploadChunk, + }); +}; + +// Hook for completing multipart upload +export const useCompleteUpload = () => { + return useMutation({ + mutationFn: uploadApi.completeMultipartUpload, + }); +}; + +// Hook for aborting upload +export const useAbortUpload = () => { + return useMutation({ + mutationFn: uploadApi.abortMultipartUpload, + }); +}; + +// Hook for direct file upload (non-video files) +export const useDirectUpload = () => { + return useMutation({ + mutationFn: (files: File[]) => uploadApi.directUpload(files), + }); +}; + +// Hook for creating file metadata in database +export interface CreateFileMetadataPayload { + name: string; + parent_id?: string | null; + access_level?: AccessLevel; + file_info: { + file_type: string; + file_size: number; + storage_path: string; + thumbnail_path?: string; + duration?: number; + }; +} + +export const useCreateFileMetadata = () => { + return useMutation({ + mutationFn: (data: CreateFileMetadataPayload) => fileApi.createFile(data), + }); +}; + +// Hook for getting uploaded parts (for resume functionality) +export const useGetUploadedParts = () => { + return useMutation({ + mutationFn: uploadApi.getUploadedParts, + }); +}; + diff --git a/src/providers/AppProviders.tsx b/src/providers/AppProviders.tsx index f397463..a588c33 100644 --- a/src/providers/AppProviders.tsx +++ b/src/providers/AppProviders.tsx @@ -14,15 +14,29 @@ import { SocketProvider } from "@/contexts/SocketContext"; import { ApiTokenProvider } from "@/contexts/ApiTokenContext"; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + retry: 0, // Don't auto-retry upload mutations + onError: (error) => { + console.error('Mutation error:', error); + } + }, + queries: { + retry: 1, + staleTime: 5 * 60 * 1000, // 5 minutes + refetchOnWindowFocus: false, + } + } +}); const AppProviders = ({ children }: { children: ReactNode }) => { const providers = [ (children: ReactNode) => {children}, (children: ReactNode) => {children}, (children: ReactNode) => {children}, - (children: ReactNode) => {children}, (children: ReactNode) => {children}, + (children: ReactNode) => {children}, (children: ReactNode) => {children}, (children: ReactNode) => ( <> @@ -35,6 +49,7 @@ const AppProviders = ({ children }: { children: ReactNode }) => { (children: ReactNode) => {children}, (children: ReactNode) => {children}, (children: ReactNode) => {children}, + ]; return providers.reduceRight((acc, Provider) => Provider(acc), children); From fc120d7cf4764b6e98cb7bddbe39aa68c7359f33 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sun, 28 Dec 2025 23:34:29 +0530 Subject: [PATCH 19/22] notifications modified to the new cursor technique to improve performance --- src/api/notification.api.ts | 2 +- src/components/upload/FIleUploader.tsx | 48 ++++++++++++++++++++++-- src/contexts/NotificationContext.tsx | 52 +++++++++++++------------- src/contexts/UploadContext.tsx | 14 ++++++- src/pages/notifications-page.tsx | 3 +- 5 files changed, 85 insertions(+), 34 deletions(-) diff --git a/src/api/notification.api.ts b/src/api/notification.api.ts index 86d1b15..16ed4b2 100644 --- a/src/api/notification.api.ts +++ b/src/api/notification.api.ts @@ -1,7 +1,7 @@ // src/api/notification.ts import apiClient from "@/api/axios"; -const getUserNotifications = async (params?: { limit?: number; offset?: number; unreadOnly?: boolean; }) => { +const getUserNotifications = async (params?: { limit?: number; cursor?: string; unreadOnly?: boolean; }) => { const response = await apiClient.get("/notification", { params }); return response.data; }; diff --git a/src/components/upload/FIleUploader.tsx b/src/components/upload/FIleUploader.tsx index 269a170..595db58 100644 --- a/src/components/upload/FIleUploader.tsx +++ b/src/components/upload/FIleUploader.tsx @@ -177,6 +177,27 @@ const FileUploader: React.FC = ({ const handleFileUpload = async (file: File) => { const fileType = getFileType(file); + // Initialize file state first if it doesn't exist + if (!fileStates[file.name]) { + updateFileState(file.name, { + uploadId: null, + url: null, + fileKey: null, + progress: 0, + status: 'idle', + error: null, + lastUploadedChunk: 0, + totalChunks: Math.ceil(file.size / (5 * 1024 * 1024)), + fileName: file.name, + fileSize: file.size, + fileType: file.type, + isUploading: false, + isRetrying: false, + isAborting: false, + isRemoving: false + }); + } + // Set button loading state setButtonLoading(file.name, 'upload', true); @@ -539,18 +560,37 @@ const FileUploader: React.FC = ({
{selectedFiles.map((file) => { const fileState = fileStates[file.name] || (completedFiles.has(file.name) ? { + uploadId: null, + url: null, + fileKey: null, progress: 100, - status: 'completed', + status: 'completed' as const, error: null, totalChunks: Math.ceil(file.size / (5 * 1024 * 1024)), lastUploadedChunk: Math.ceil(file.size / (5 * 1024 * 1024)), - url: null + fileName: file.name, + fileSize: file.size, + fileType: file.type, + isUploading: false, + isRetrying: false, + isAborting: false, + isRemoving: false } : { + uploadId: null, + url: null, + fileKey: null, progress: 0, - status: 'idle', + status: 'idle' as const, error: null, totalChunks: Math.ceil(file.size / (5 * 1024 * 1024)), - lastUploadedChunk: 0 + lastUploadedChunk: 0, + fileName: file.name, + fileSize: file.size, + fileType: file.type, + isUploading: false, + isRetrying: false, + isAborting: false, + isRemoving: false }); return ( diff --git a/src/contexts/NotificationContext.tsx b/src/contexts/NotificationContext.tsx index 859196a..6c0908c 100644 --- a/src/contexts/NotificationContext.tsx +++ b/src/contexts/NotificationContext.tsx @@ -43,14 +43,14 @@ interface NotificationState { unreadCount: number; loading: boolean; hasMore: boolean; - totalCount: number; + nextCursor: string | null; loadingMore: boolean; } type NotificationAction = - | { type: 'SET_NOTIFICATIONS'; notifications: NotificationAttributes[]; totalCount: number; hasMore: boolean } + | { type: 'SET_NOTIFICATIONS'; notifications: NotificationAttributes[]; nextCursor: string | null; hasMore: boolean } | { type: 'ADD_NOTIFICATION'; notification: NotificationAttributes } - | { type: 'APPEND_NOTIFICATIONS'; notifications: NotificationAttributes[]; hasMore: boolean; totalCount?: number } + | { type: 'APPEND_NOTIFICATIONS'; notifications: NotificationAttributes[]; hasMore: boolean; nextCursor: string | null } | { type: 'MARK_AS_READ'; id: string } | { type: 'MARK_ALL_AS_READ' } | { type: 'SET_LOADING'; loading: boolean } @@ -62,7 +62,7 @@ const initialState: NotificationState = { unreadCount: 0, loading: true, hasMore: false, - totalCount: 0, + nextCursor: null, loadingMore: false, }; @@ -83,7 +83,7 @@ const notificationReducer = (state: NotificationState, action: NotificationActio ...state, notifications, unreadCount, - totalCount: action.totalCount, + nextCursor: action.nextCursor, hasMore: action.hasMore, loading: false, }; @@ -104,7 +104,7 @@ const notificationReducer = (state: NotificationState, action: NotificationActio notifications: combinedNotifications, unreadCount, hasMore: action.hasMore, - totalCount: action.totalCount ?? state.totalCount, + nextCursor: action.nextCursor, }; } case 'ADD_NOTIFICATION': { @@ -117,7 +117,6 @@ const notificationReducer = (state: NotificationState, action: NotificationActio ...state, notifications: newNotifications, unreadCount: state.unreadCount + (action.notification.is_read ? 0 : 1), - totalCount: state.totalCount + 1, }; } case 'MARK_AS_READ': { @@ -150,7 +149,7 @@ const notificationReducer = (state: NotificationState, action: NotificationActio }; interface NotificationContextType extends NotificationState { - fetchNotifications: (params?: { limit?: number; offset?: number; unreadOnly?: boolean }) => Promise; + fetchNotifications: (params?: { limit?: number; cursor?: string; unreadOnly?: boolean }) => Promise; loadMoreNotifications: () => Promise; markAsRead: (id: string) => Promise<{ success: boolean; error?: string }>; markAllAsRead: () => Promise<{ success: boolean; error?: string }>; @@ -168,7 +167,7 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr const { showNotification } = useNotificationUI(); // Memoized query key - const notificationsQueryKey = useMemo(() => ['notifications', { limit: 20, offset: 0 }], []); + const notificationsQueryKey = useMemo(() => ['notifications', { limit: 20 }], []); useEffect(() => { @@ -198,7 +197,6 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr return { ...oldData, notifications: [notification, ...oldData.notifications], - totalCount: oldData.totalCount + 1, }; }); }; @@ -216,14 +214,14 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr }; }, [socket, queryClient, notificationsQueryKey]); - // 🔹 Optimized query with better caching + // 🔹 Optimized query with better caching (cursor-based) const { data: notificationsData, isLoading: notificationsLoading, isRefetching, refetch } = useQuery({ queryKey: notificationsQueryKey, queryFn: async () => { - const result = await notificationApi.getUserNotifications({ limit: 20, offset: 0 }); + const result = await notificationApi.getUserNotifications({ limit: 20 }); return { notifications: result.data?.notifications || result.notifications || [], - totalCount: result.data?.totalCount || 0, + nextCursor: result.data?.nextCursor || null, hasMore: result.data?.hasMore || false, }; }, @@ -242,7 +240,7 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr dispatch({ type: 'SET_NOTIFICATIONS', notifications: notificationsData.notifications, - totalCount: notificationsData.totalCount, + nextCursor: notificationsData.nextCursor, hasMore: notificationsData.hasMore, }); }, [notificationsData]); @@ -327,22 +325,22 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr } }); - // 🔹 Optimized actions with better error handling - const fetchNotifications = useCallback(async (params?: { limit?: number; offset?: number; unreadOnly?: boolean }) => { + // 🔹 Optimized actions with better error handling (cursor-based) + const fetchNotifications = useCallback(async (params?: { limit?: number; cursor?: string; unreadOnly?: boolean }) => { try { dispatch({ type: 'SET_LOADING', loading: true }); - const result = await notificationApi.getUserNotifications(params || { limit: 20, offset: 0 }); + const result = await notificationApi.getUserNotifications(params || { limit: 20 }); const data = { notifications: result.data?.notifications || result.notifications || [], - totalCount: result.data?.totalCount || 0, + nextCursor: result.data?.nextCursor || null, hasMore: result.data?.hasMore || false, }; dispatch({ type: 'SET_NOTIFICATIONS', notifications: data.notifications, - totalCount: data.totalCount, + nextCursor: data.nextCursor, hasMore: data.hasMore, }); @@ -359,23 +357,25 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr }, [queryClient, notificationsQueryKey]); const loadMoreNotifications = useCallback(async () => { - if (state.loadingMore || !state.hasMore) return; + if (state.loadingMore || !state.hasMore || !state.nextCursor) return; try { dispatch({ type: 'SET_LOADING_MORE', loadingMore: true }); - const offset = state.notifications.length; - const result = await notificationApi.getUserNotifications({ limit: 20, offset }); + const result = await notificationApi.getUserNotifications({ + limit: 20, + cursor: state.nextCursor + }); const newNotifications = result.data?.notifications || result.notifications || []; const hasMore = result.data?.hasMore || false; - const totalCount = result.data?.totalCount || state.totalCount; + const nextCursor = result.data?.nextCursor || null; // Immediate UI update dispatch({ type: 'APPEND_NOTIFICATIONS', notifications: newNotifications, hasMore, - totalCount, + nextCursor, }); // Update query cache in background @@ -385,7 +385,7 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr ...oldData, notifications: [...oldData.notifications, ...newNotifications], hasMore, - totalCount, + nextCursor, }; }); } catch (error: any) { @@ -394,7 +394,7 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr // Ensure spinner hides only after state/cache are updated dispatch({ type: 'SET_LOADING_MORE', loadingMore: false }); } - }, [state.loadingMore, state.hasMore, state.notifications.length, state.totalCount, queryClient, notificationsQueryKey]); + }, [state.loadingMore, state.hasMore, state.nextCursor, queryClient, notificationsQueryKey]); const markAsRead = useCallback(async (id: string) => { try { diff --git a/src/contexts/UploadContext.tsx b/src/contexts/UploadContext.tsx index 494640e..15283d3 100644 --- a/src/contexts/UploadContext.tsx +++ b/src/contexts/UploadContext.tsx @@ -109,6 +109,10 @@ const uploadReducer = (state: UploadState, action: UploadAction): UploadState => }; case 'UPDATE_FILE_STATE': + // Don't update if file doesn't exist in state (prevent partial states) + if (!state.fileStates[action.payload.fileName]) { + return state; + } return { ...state, fileStates: { @@ -164,6 +168,10 @@ const uploadReducer = (state: UploadState, action: UploadAction): UploadState => case 'ERROR': return { ...state, loading: false, error: action.payload, success: false }; case 'SET_BUTTON_LOADING': + // Don't update if file doesn't exist in state (prevent partial states) + if (!state.fileStates[action.payload.fileName]) { + return state; + } return { ...state, fileStates: { @@ -261,10 +269,14 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) }, [state.fileStates]); const handleUpload = useCallback(async (file: File, folderId?: string) => { + // Always initialize file first to ensure complete state if (!state.fileStates[file.name]) { initializeFile(file.name, file.size, file.type); } + // Wait for next tick to ensure state is updated + await new Promise(resolve => setTimeout(resolve, 0)); + try { let uploadId = state.fileStates[file.name]?.uploadId; let key = state.fileStates[file.name]?.fileKey; @@ -295,7 +307,7 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) key: key! }); parts = uploadedPartsResponse.parts || []; - + // Fix: Calculate correct startChunk from already uploaded parts startChunk = parts.length > 0 ? Math.max(...parts.map(part => part.PartNumber)) : 0; diff --git a/src/pages/notifications-page.tsx b/src/pages/notifications-page.tsx index 56dc72d..faa9519 100644 --- a/src/pages/notifications-page.tsx +++ b/src/pages/notifications-page.tsx @@ -250,7 +250,6 @@ export default function NotificationsPage() { loading, loadingMore, hasMore, - totalCount, loadMoreNotifications, markAsRead, markAllAsRead, @@ -400,7 +399,7 @@ export default function NotificationsPage() {

Notifications

- {unreadCount} unread • {totalCount} total + {unreadCount} unread • {notifications.length} loaded

From 8f7f8d0941071026ad7e8dc31a43173b9df51392 Mon Sep 17 00:00:00 2001 From: venu123143 Date: Sun, 28 Dec 2025 23:47:46 +0530 Subject: [PATCH 20/22] sorting added --- src/components/file-manager/Toolbar.tsx | 20 ++++--- src/pages/all-files-page.tsx | 49 ++++++++++++++- src/pages/deleted-files-page.tsx | 79 +++++++++++++++++++++++-- src/pages/private-files-page.tsx | 68 +++++++++++++++++++-- 4 files changed, 196 insertions(+), 20 deletions(-) diff --git a/src/components/file-manager/Toolbar.tsx b/src/components/file-manager/Toolbar.tsx index 9f75b6d..de16cad 100644 --- a/src/components/file-manager/Toolbar.tsx +++ b/src/components/file-manager/Toolbar.tsx @@ -3,7 +3,6 @@ import { Search, SortDesc, SortAsc, Grid3X3, List } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; -import { useState } from "react"; interface ToolbarProps { searchQuery: string; @@ -11,10 +10,11 @@ interface ToolbarProps { selectedFilesCount: number; viewMode: "grid" | "list"; onViewModeChange: (mode: "grid" | "list") => void; + sortDirection: "ASC" | "DESC"; + onSortChange: (direction: "ASC" | "DESC") => void; } -export function Toolbar({ searchQuery, onSearchChange, selectedFilesCount, viewMode, onViewModeChange }: ToolbarProps) { - const [sort, setSort] = useState<"ASC" | "DESC">("ASC") +export function Toolbar({ searchQuery, onSearchChange, selectedFilesCount, viewMode, onViewModeChange, sortDirection, onSortChange }: ToolbarProps) { return (
- diff --git a/src/pages/all-files-page.tsx b/src/pages/all-files-page.tsx index 281dbc9..45538ce 100644 --- a/src/pages/all-files-page.tsx +++ b/src/pages/all-files-page.tsx @@ -21,6 +21,7 @@ export default function AllFilesPage() { const { socket } = useSocket(); const [viewMode, setViewMode] = useState<"grid" | "list">("grid") const [searchQuery, setSearchQuery] = useState("") + const [sortDirection, setSortDirection] = useState<"ASC" | "DESC">("ASC") const [selectedFiles, setSelectedFiles] = useState([]) const [currentPath, setCurrentPath] = useState>([]) // Rename popup state @@ -51,10 +52,52 @@ export default function AllFilesPage() { return items }, [currentPath, transformedFileSystem]) + // Helper function to convert size string to bytes for proper sorting + const sizeToBytes = (sizeStr: string): number => { + if (!sizeStr || sizeStr === '-') return 0; + + const units: { [key: string]: number } = { + 'B': 1, + 'KB': 1024, + 'MB': 1024 * 1024, + 'GB': 1024 * 1024 * 1024, + 'TB': 1024 * 1024 * 1024 * 1024, + }; + + const match = sizeStr.trim().match(/^([\d.]+)\s*([A-Z]+)$/i); + if (!match) return 0; + + const value = parseFloat(match[1]); + const unit = match[2].toUpperCase(); + + return value * (units[unit] || 0); + }; + const filteredFiles = useMemo(() => { // search filter - return currentItems.filter((file) => file.name.toLowerCase().includes(searchQuery.toLowerCase())) - }, [currentItems, searchQuery]) + let filtered = currentItems.filter((file) => + file.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // Sort by size + filtered.sort((a, b) => { + // Folders always come first + if (a.type === 'folder' && b.type !== 'folder') return -1; + if (a.type !== 'folder' && b.type === 'folder') return 1; + + // If both are folders or both are files, sort by size + const sizeA = sizeToBytes(a.size); + const sizeB = sizeToBytes(b.size); + + if (sortDirection === "ASC") { + return sizeA - sizeB; + } else { + return sizeB - sizeA; + } + }); + + return filtered; + }, [currentItems, searchQuery, sortDirection]) const toggleFileSelection = (fileId: string) => { setSelectedFiles((prev) => (prev.includes(fileId) ? prev.filter((id) => id !== fileId) : [...prev, fileId])) @@ -270,6 +313,8 @@ export default function AllFilesPage() { selectedFilesCount={selectedFiles.length} viewMode={viewMode} onViewModeChange={setViewMode} + sortDirection={sortDirection} + onSortChange={setSortDirection} /> ("grid") const [searchQuery, setSearchQuery] = useState("") + const [sortDirection, setSortDirection] = useState<"ASC" | "DESC">("ASC") const [selectedFiles, setSelectedFiles] = useState([]) const { deleteFileOrFolder, trash, restoreFileOrFolder, emptyTrash } = useFile() @@ -37,7 +39,52 @@ export function DeletedFilesPage() { return transformFileSystemNodesToDeletedFileItems(trash) }, [trash]) - const filteredFiles = transformedTrash.filter((file) => file.name.toLowerCase().includes(searchQuery.toLowerCase())) + // Helper function to convert size string to bytes for proper sorting + const sizeToBytes = (sizeStr: string): number => { + if (!sizeStr || sizeStr === '-') return 0; + + const units: { [key: string]: number } = { + 'B': 1, + 'KB': 1024, + 'MB': 1024 * 1024, + 'GB': 1024 * 1024 * 1024, + 'TB': 1024 * 1024 * 1024 * 1024, + }; + + const match = sizeStr.trim().match(/^([\d.]+)\s*([A-Z]+)$/i); + if (!match) return 0; + + const value = parseFloat(match[1]); + const unit = match[2].toUpperCase(); + + return value * (units[unit] || 0); + }; + + const filteredFiles = useMemo(() => { + // Search filter + let filtered = transformedTrash.filter((file) => + file.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + // Sort by size + filtered.sort((a, b) => { + // Folders always come first + if (a.type === 'folder' && b.type !== 'folder') return -1; + if (a.type !== 'folder' && b.type === 'folder') return 1; + + // If both are folders or both are files, sort by size + const sizeA = sizeToBytes(a.size); + const sizeB = sizeToBytes(b.size); + + if (sortDirection === "ASC") { + return sizeA - sizeB; + } else { + return sizeB - sizeA; + } + }); + + return filtered; + }, [transformedTrash, searchQuery, sortDirection]) const toggleFileSelection = (fileId: string) => { setSelectedFiles((prev) => (prev.includes(fileId) ? prev.filter((id) => id !== fileId) : [...prev, fileId])) @@ -138,15 +185,35 @@ export function DeletedFilesPage() { Filter - -
diff --git a/src/pages/private-files-page.tsx b/src/pages/private-files-page.tsx index 9ca2d3f..421e5db 100644 --- a/src/pages/private-files-page.tsx +++ b/src/pages/private-files-page.tsx @@ -8,6 +8,7 @@ import { Search, Filter, SortAsc, + SortDesc, Download, Trash2, Upload, @@ -38,6 +39,7 @@ export function PrivateFilesPage() { const { socket } = useSocket() const [viewMode, setViewMode] = useState<"grid" | "list">("grid") const [searchQuery, setSearchQuery] = useState("") + const [sortDirection, setSortDirection] = useState<"ASC" | "DESC">("ASC") const [selectedFiles, setSelectedFiles] = useState([]) const [currentPath, setCurrentPath] = useState>([]) const [showSensitiveOnly, setShowSensitiveOnly] = useState(false) @@ -132,11 +134,52 @@ export function PrivateFilesPage() { return items }, [currentPath, transformedPrivateFiles]) + // Helper function to convert size string to bytes for proper sorting + const sizeToBytes = (sizeStr: string): number => { + if (!sizeStr || sizeStr === '-') return 0; + + const units: { [key: string]: number } = { + 'B': 1, + 'KB': 1024, + 'MB': 1024 * 1024, + 'GB': 1024 * 1024 * 1024, + 'TB': 1024 * 1024 * 1024 * 1024, + }; + + const match = sizeStr.trim().match(/^([\d.]+)\s*([A-Z]+)$/i); + if (!match) return 0; + + const value = parseFloat(match[1]); + const unit = match[2].toUpperCase(); + + return value * (units[unit] || 0); + }; + const filteredFiles = useMemo(() => { const matchesSearch = (file: PrivateFileItem) => file.name.toLowerCase().includes(searchQuery.toLowerCase()) const matchesSensitive = (file: PrivateFileItem) => !showSensitiveOnly || file.sensitive - return currentItems.filter((file) => matchesSearch(file) && matchesSensitive(file)) - }, [currentItems, searchQuery, showSensitiveOnly]) + + let filtered = currentItems.filter((file) => matchesSearch(file) && matchesSensitive(file)); + + // Sort by size + filtered.sort((a, b) => { + // Folders always come first + if (a.type === 'folder' && b.type !== 'folder') return -1; + if (a.type !== 'folder' && b.type === 'folder') return 1; + + // If both are folders or both are files, sort by size + const sizeA = sizeToBytes(a.size); + const sizeB = sizeToBytes(b.size); + + if (sortDirection === "ASC") { + return sizeA - sizeB; + } else { + return sizeB - sizeA; + } + }); + + return filtered; + }, [currentItems, searchQuery, showSensitiveOnly, sortDirection]) const toggleFileSelection = (fileId: string) => { setSelectedFiles((prev) => (prev.includes(fileId) ? prev.filter((id) => id !== fileId) : [...prev, fileId])) @@ -387,9 +430,24 @@ export function PrivateFilesPage() { Filter -
From 3571a2a7b60a5108d71c4e78e14353d13354497b Mon Sep 17 00:00:00 2001 From: venu123143 Date: Mon, 29 Dec 2025 00:34:15 +0530 Subject: [PATCH 21/22] download file code added --- src/components/custom/ImageViewer.tsx | 40 +++- .../file-manager/ShareFileModal.tsx | 1 - src/components/player/VideoPlayer.tsx | 1 - src/components/player/VideoPlayerModal.tsx | 1 - src/contexts/ApiTokenContext.tsx | 14 +- src/contexts/UploadContext.tsx | 1 - src/contexts/fileContext.tsx | 27 +-- src/contexts/useAuth.tsx | 15 +- src/hooks/useFileDownload.tsx | 180 ++++++++++++++++++ src/lib/utils.ts | 1 + src/pages/all-files-page.tsx | 32 ++-- src/pages/deleted-files-page.tsx | 1 - src/pages/notifications-page.tsx | 1 - src/pages/private-files-page.tsx | 26 ++- src/pages/shared-files-page.tsx | 2 - src/providers/AppProviders.tsx | 6 +- 16 files changed, 265 insertions(+), 84 deletions(-) create mode 100644 src/hooks/useFileDownload.tsx diff --git a/src/components/custom/ImageViewer.tsx b/src/components/custom/ImageViewer.tsx index 616e4b9..7d1c3b0 100644 --- a/src/components/custom/ImageViewer.tsx +++ b/src/components/custom/ImageViewer.tsx @@ -107,13 +107,39 @@ export const ImageViewer: React.FC = ({ setPosition({ x: 0, y: 0 }); }; - const handleDownload = () => { - const link = document.createElement('a'); - link.href = imageUrl; - link.download = imageName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const handleDownload = async () => { + try { + const timestamp = Date.now().toString().slice(0, 5); + const lastDotIndex = imageName.lastIndexOf('.'); + + let nameWithoutExt: string; + let extension: string; + + if (lastDotIndex !== -1) { + nameWithoutExt = imageName.substring(0, lastDotIndex); + extension = imageName.substring(lastDotIndex); + } else { + nameWithoutExt = imageName; + extension = ''; + } + + const downloadFileName = `${nameWithoutExt}_${timestamp}${extension}`; + + // Fetch and download + const response = await fetch(imageUrl); + const blob = await response.blob(); + const blobUrl = window.URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = blobUrl; + link.download = downloadFileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(blobUrl); + } catch (error) { + console.error('Download failed:', error); + } }; const toggleFullscreen = () => { diff --git a/src/components/file-manager/ShareFileModal.tsx b/src/components/file-manager/ShareFileModal.tsx index 6558ed7..a854268 100644 --- a/src/components/file-manager/ShareFileModal.tsx +++ b/src/components/file-manager/ShareFileModal.tsx @@ -56,7 +56,6 @@ export function ShareFileModal({ setUsers(Array.isArray(result) ? result : []) setShowDropdown(true) } catch (error) { - console.error("Error searching users:", error) setUsers([]) } finally { setIsSearching(false) diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index 83a291b..9ed2595 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -262,7 +262,6 @@ export function VideoPlayer({ }); player.on('error', (error: any) => { - console.error('Video error:', error); if (onError) onError(error); }); diff --git a/src/components/player/VideoPlayerModal.tsx b/src/components/player/VideoPlayerModal.tsx index 2e9cfdd..8e2faca 100644 --- a/src/components/player/VideoPlayerModal.tsx +++ b/src/components/player/VideoPlayerModal.tsx @@ -28,7 +28,6 @@ export function VideoPlayerModal({ const handleError = (err: any) => { setIsLoading(false); setError('Failed to load video. Please try again.'); - console.error('Video error:', err); }; if (!isOpen) return null; diff --git a/src/contexts/ApiTokenContext.tsx b/src/contexts/ApiTokenContext.tsx index f87b769..afc3e1f 100644 --- a/src/contexts/ApiTokenContext.tsx +++ b/src/contexts/ApiTokenContext.tsx @@ -17,16 +17,10 @@ export const ApiTokenProvider: React.FC<{ children: ReactNode }> = ({ children } const [loading, setLoading] = useState(false); const fetchTokens = useCallback(async () => { - try { - setLoading(true); - const response = await apiTokenApi.listTokens(); - setTokens(response.data); - } catch (error: any) { - console.error("Failed to fetch tokens:", error); - // toast.error("Failed to load API tokens."); - } finally { - setLoading(false); - } + setLoading(true); + const response = await apiTokenApi.listTokens(); + setTokens(response.data); + setLoading(false); }, []); const generateToken = async (data: GenerateTokenDto) => { diff --git a/src/contexts/UploadContext.tsx b/src/contexts/UploadContext.tsx index 15283d3..6d7682d 100644 --- a/src/contexts/UploadContext.tsx +++ b/src/contexts/UploadContext.tsx @@ -428,7 +428,6 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) dispatch({ type: 'UPLOAD_SUCCESS', payload: uploadedFiles }); return uploadedFiles; } catch (error: any) { - console.log('uploadFiles error', error); dispatch({ type: 'ERROR', payload: error?.response?.data?.message || error?.message || 'Network error' }); return []; } diff --git a/src/contexts/fileContext.tsx b/src/contexts/fileContext.tsx index dcd2da7..9bd9f9f 100644 --- a/src/contexts/fileContext.tsx +++ b/src/contexts/fileContext.tsx @@ -2,15 +2,16 @@ import React, { useReducer, useContext, createContext, type ReactNode } from 're import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import fileApi from '@/api/file.api'; import { useAuth } from './useAuth'; -import type { - CreateFolderInput, - RenameFolderInput, - MoveFileOrFolderInput, - CreateFileInput, - ShareFileOrFolderInput, - FileSystemNode, - SharedFileSystemNode, - AccessLevel, +import { + type CreateFolderInput, + type RenameFolderInput, + type MoveFileOrFolderInput, + type CreateFileInput, + type ShareFileOrFolderInput, + type FileSystemNode, + type SharedFileSystemNode, + type AccessLevel, + ACCESS_LEVEL, } from '@/types/file.types'; interface FileState { @@ -159,10 +160,14 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => return result.data; }, retry: false, - onSuccess: () => { + onSuccess: (data: FileSystemNode) => { dispatch({ type: 'SET_LOADING', loading: false }); // Invalidate and refetch file system tree - queryClient.invalidateQueries({ queryKey: ['fileSystemTree'] }); + if (data.access_level === ACCESS_LEVEL.PRIVATE) { + queryClient.invalidateQueries({ queryKey: ['privateFiles'] }); + } else { + queryClient.invalidateQueries({ queryKey: ['fileSystemTree'] }); + } }, onError: () => { dispatch({ type: 'SET_LOADING', loading: false }); diff --git a/src/contexts/useAuth.tsx b/src/contexts/useAuth.tsx index 0bd0886..c232e8b 100644 --- a/src/contexts/useAuth.tsx +++ b/src/contexts/useAuth.tsx @@ -345,21 +345,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => }); const logoutAll = async () => { - try { - await logoutAllMutationFn(); - } catch (error) { - console.error('Logout all error:', error); - } + await logoutAllMutationFn(); }; const getActiveSessions = async () => { - try { - const result = await authApi.getActiveSessions(); - return result.data?.sessions || []; - } catch (error) { - console.error('Get active sessions error:', error); - return []; - } + const result = await authApi.getActiveSessions(); + return result.data?.sessions || []; }; const revokeToken = async (refreshToken: string) => { diff --git a/src/hooks/useFileDownload.tsx b/src/hooks/useFileDownload.tsx new file mode 100644 index 0000000..33ef6a7 --- /dev/null +++ b/src/hooks/useFileDownload.tsx @@ -0,0 +1,180 @@ +import { useCallback } from 'react'; +import { toast } from 'sonner'; +import type { FileItem } from '@/types/file-manager'; + +/** + * Custom hook for downloading files with proper naming and toast notifications + * + * Features: + * - Downloads files with timestamp-based naming (originalName_12345.ext) + * - Shows toast notifications for folders (cannot be downloaded) + * - Handles file download errors gracefully + * - Works with CDN URLs from environment variables + */ +export const useFileDownload = () => { + /** + * Downloads a file with a timestamped filename + * @param file - FileItem to download + * @returns Promise + */ + const downloadFile = useCallback(async (file: FileItem): Promise => { + try { + // Check if it's a folder + if (file.type === 'folder') { + toast.info('Cannot download folders', { + description: 'Please download files individually from within the folder.', + duration: 4000, + }); + return; + } + + // Check if file has storage path + if (!file.file_info?.storage_path) { + toast.error('Download failed', { + description: 'File path not found. Please try again.', + duration: 4000, + }); + return; + } + + // Generate timestamp (first 5 digits) + const timestamp = Date.now().toString().slice(0, 5); + + // Extract file name and extension + const fileName = file.name; + const lastDotIndex = fileName.lastIndexOf('.'); + + let nameWithoutExt: string; + let extension: string; + + if (lastDotIndex !== -1) { + nameWithoutExt = fileName.substring(0, lastDotIndex); + extension = fileName.substring(lastDotIndex); + } else { + nameWithoutExt = fileName; + extension = ''; + } + + // Create download filename with timestamp + const downloadFileName = `${nameWithoutExt}_${timestamp}${extension}`; + + // Construct file URL + const fileUrl = `${import.meta.env.VITE_API_CDN_URL}/${file.file_info.storage_path}`; + + // Show loading toast + const loadingToast = toast.loading('Downloading...', { + description: `Preparing ${file.name}`, + }); + + try { + // Try to fetch the file (this works if CORS is properly configured) + const response = await fetch(fileUrl); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + // Get blob from response + const blob = await response.blob(); + + // Create temporary download link + const blobUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = downloadFileName; + + // Trigger download + document.body.appendChild(link); + link.click(); + + // Cleanup + document.body.removeChild(link); + window.URL.revokeObjectURL(blobUrl); + + // Dismiss loading toast and show success + toast.dismiss(loadingToast); + toast.success('Download started', { + description: `${downloadFileName}`, + duration: 3000, + }); + + } catch (fetchError: any) { + // Dismiss loading toast + toast.dismiss(loadingToast); + + // If fetch fails (likely due to CORS), use direct download method + console.warn('Fetch download failed, using direct download method:', fetchError); + + // Create a link element for direct download + const link = document.createElement('a'); + link.href = fileUrl; + link.download = downloadFileName; + link.target = '_blank'; // Fallback: open in new tab if download attribute doesn't work + + // Trigger download + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Show success message (with note about browser download) + toast.success('Download started', { + description: `${file.name} - Check your browser's download folder`, + duration: 4000, + }); + } + + } catch (error: any) { + console.error('Download error:', error); + toast.error('Download failed', { + description: error?.message || 'An error occurred while downloading the file. Please try again.', + duration: 4000, + }); + } + }, []); + + /** + * Downloads multiple files sequentially + * @param files - Array of FileItems to download + */ + const downloadMultipleFiles = useCallback(async (files: FileItem[]): Promise => { + // Filter out folders + const downloadableFiles = files.filter(file => file.type === 'file'); + const folderCount = files.length - downloadableFiles.length; + + if (folderCount > 0) { + toast.info(`Skipping ${folderCount} folder${folderCount > 1 ? 's' : ''}`, { + description: 'Only files can be downloaded. Folders will be skipped.', + duration: 4000, + }); + } + + if (downloadableFiles.length === 0) { + toast.error('No files to download', { + description: 'Please select at least one file.', + duration: 3000, + }); + return; + } + + toast.info(`Downloading ${downloadableFiles.length} file${downloadableFiles.length > 1 ? 's' : ''}`, { + description: 'Your downloads will start shortly.', + duration: 3000, + }); + + // Download files sequentially with a small delay to avoid overwhelming the browser + for (let i = 0; i < downloadableFiles.length; i++) { + await downloadFile(downloadableFiles[i]); + + // Add small delay between downloads (except for the last one) + if (i < downloadableFiles.length - 1) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + }, [downloadFile]); + + return { + downloadFile, + downloadMultipleFiles, + }; +}; + diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 69f2a0e..fdb15f8 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -185,6 +185,7 @@ export function transformFileSystemNodeToPrivateFileItem(node: FileSystemNode, p variant: "private", encrypted, sensitive, + access_level: node.access_level, children: node.children ? node.children.map(child => transformFileSystemNodeToPrivateFileItem(child, [...parentPath, node.name])) : undefined, } } diff --git a/src/pages/all-files-page.tsx b/src/pages/all-files-page.tsx index 45538ce..8de6ad9 100644 --- a/src/pages/all-files-page.tsx +++ b/src/pages/all-files-page.tsx @@ -16,6 +16,7 @@ import { useFile } from "@/contexts/fileContext" import { transformFileSystemNodesToFileItems } from "@/lib/utils" import { useNavigate } from "react-router-dom" import { useSocket } from "@/contexts/SocketContext"; +import { useFileDownload } from "@/hooks/useFileDownload"; export default function AllFilesPage() { const { socket } = useSocket(); @@ -34,6 +35,7 @@ export default function AllFilesPage() { const [isShareModalOpen, setIsShareModalOpen] = useState(false) const [fileToShare, setFileToShare] = useState(null) const { createFolder, fileSystemTree, deleteFileOrFolder, renameFolder, moveFileOrFolder, shareFileOrFolder, updateFileAccessLevel } = useFile(); + const { downloadFile } = useFileDownload(); const navigate = useNavigate(); // Transform dynamic data to FileItem format @@ -55,7 +57,7 @@ export default function AllFilesPage() { // Helper function to convert size string to bytes for proper sorting const sizeToBytes = (sizeStr: string): number => { if (!sizeStr || sizeStr === '-') return 0; - + const units: { [key: string]: number } = { 'B': 1, 'KB': 1024, @@ -63,39 +65,39 @@ export default function AllFilesPage() { 'GB': 1024 * 1024 * 1024, 'TB': 1024 * 1024 * 1024 * 1024, }; - + const match = sizeStr.trim().match(/^([\d.]+)\s*([A-Z]+)$/i); if (!match) return 0; - + const value = parseFloat(match[1]); const unit = match[2].toUpperCase(); - + return value * (units[unit] || 0); }; const filteredFiles = useMemo(() => { // search filter - let filtered = currentItems.filter((file) => + let filtered = currentItems.filter((file) => file.name.toLowerCase().includes(searchQuery.toLowerCase()) ); - + // Sort by size filtered.sort((a, b) => { // Folders always come first if (a.type === 'folder' && b.type !== 'folder') return -1; if (a.type !== 'folder' && b.type === 'folder') return 1; - + // If both are folders or both are files, sort by size const sizeA = sizeToBytes(a.size); const sizeB = sizeToBytes(b.size); - + if (sortDirection === "ASC") { return sizeA - sizeB; } else { return sizeB - sizeA; } }); - + return filtered; }, [currentItems, searchQuery, sortDirection]) @@ -270,21 +272,13 @@ export default function AllFilesPage() { } const handleChangeAccessLevel = async (file: FileItem, accessLevel: string) => { - try { - const result = await updateFileAccessLevel(file.id, { access_level: accessLevel as AccessLevel }); - if (!result.success) { - // You could show a toast notification here - console.error(result.error); - } - } catch (error: any) { - console.error("Failed to update access level:", error); - } + await updateFileAccessLevel(file.id, { access_level: accessLevel as AccessLevel }); } const actionHandlers: FileActionHandlers = { onFileSelect: toggleFileSelection, onItemClick: handleItemClick, - onDownload: (file) => console.log("Download", file.name), + onDownload: downloadFile, onShare: handleShareFile, onMove: handleMoveFile, onRename: handleRenameFile, diff --git a/src/pages/deleted-files-page.tsx b/src/pages/deleted-files-page.tsx index f7973ae..7cab08c 100644 --- a/src/pages/deleted-files-page.tsx +++ b/src/pages/deleted-files-page.tsx @@ -106,7 +106,6 @@ export function DeletedFilesPage() { const handleDeleteFile = async (file: FileItem) => { const result = await deleteFileOrFolder(file.id); - console.log(result, "delete..."); if (result.success) { // Remove from selected files if it was selected setSelectedFiles(prev => prev.filter(id => id !== file.id)); diff --git a/src/pages/notifications-page.tsx b/src/pages/notifications-page.tsx index faa9519..4f6edef 100644 --- a/src/pages/notifications-page.tsx +++ b/src/pages/notifications-page.tsx @@ -303,7 +303,6 @@ export default function NotificationsPage() { return filtered; }, [notifications, activeFilter, searchQuery]); - console.log(loading, "loading") // Memoized notification categories with optimized counting const notificationCategories = useMemo(() => { const categoryCounts = notifications.reduce((acc, n) => { diff --git a/src/pages/private-files-page.tsx b/src/pages/private-files-page.tsx index 421e5db..a64179b 100644 --- a/src/pages/private-files-page.tsx +++ b/src/pages/private-files-page.tsx @@ -137,7 +137,7 @@ export function PrivateFilesPage() { // Helper function to convert size string to bytes for proper sorting const sizeToBytes = (sizeStr: string): number => { if (!sizeStr || sizeStr === '-') return 0; - + const units: { [key: string]: number } = { 'B': 1, 'KB': 1024, @@ -145,39 +145,39 @@ export function PrivateFilesPage() { 'GB': 1024 * 1024 * 1024, 'TB': 1024 * 1024 * 1024 * 1024, }; - + const match = sizeStr.trim().match(/^([\d.]+)\s*([A-Z]+)$/i); if (!match) return 0; - + const value = parseFloat(match[1]); const unit = match[2].toUpperCase(); - + return value * (units[unit] || 0); }; const filteredFiles = useMemo(() => { const matchesSearch = (file: PrivateFileItem) => file.name.toLowerCase().includes(searchQuery.toLowerCase()) const matchesSensitive = (file: PrivateFileItem) => !showSensitiveOnly || file.sensitive - + let filtered = currentItems.filter((file) => matchesSearch(file) && matchesSensitive(file)); - + // Sort by size filtered.sort((a, b) => { // Folders always come first if (a.type === 'folder' && b.type !== 'folder') return -1; if (a.type !== 'folder' && b.type === 'folder') return 1; - + // If both are folders or both are files, sort by size const sizeA = sizeToBytes(a.size); const sizeB = sizeToBytes(b.size); - + if (sortDirection === "ASC") { return sizeA - sizeB; } else { return sizeB - sizeA; } }); - + return filtered; }, [currentItems, searchQuery, showSensitiveOnly, sortDirection]) @@ -261,7 +261,6 @@ export function PrivateFilesPage() { toast.error(result.error || 'Failed to delete item'); } } catch (error) { - console.error('Delete error:', error); toast.error('An error occurred while deleting the item'); } } @@ -275,7 +274,6 @@ export function PrivateFilesPage() { toast.error(result.error || "Failed to update access level"); } } catch (error: any) { - console.error("Failed to update access level:", error); toast.error("An error occurred while updating access level"); } } @@ -430,10 +428,10 @@ export function PrivateFilesPage() { Filter -

- + {/* Video Container */}
{isLoading && ( @@ -65,7 +65,7 @@ export function VideoPlayerModal({
)} - + {error && (
@@ -85,7 +85,7 @@ export function VideoPlayerModal({
)} - +