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/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/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 = () => (
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/api/auth.api.ts b/src/api/auth.api.ts index 69dae1a..7811251 100644 --- a/src/api/auth.api.ts +++ b/src/api/auth.api.ts @@ -46,5 +46,103 @@ 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; + } +}; + +const getSession = async () => { + try { + const response = await apiClient.get('/auth/user/get-session', { withCredentials: true }); + return response.data; + } catch (error) { + throw error; + } +}; + +/** + * 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 }; \ 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 655ee93..dbc96c2 100644 --- a/src/api/axios.ts +++ b/src/api/axios.ts @@ -1,46 +1,126 @@ +import axios, { AxiosError, type InternalAxiosRequestConfig } from "axios"; +import { getAuthState, useAuthStore } from "@/store/auth.store"; + +export const API_BASE_URL = + import.meta.env.VITE_API_BACKEND_URL || "http://localhost:3000/api/v1"; -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'; -// Initialize the Axios client const apiClient = axios.create({ - baseURL: API_BASE_URL, // Replace with your API base URL - headers: { 'Content-Type': 'application/json' }, + baseURL: API_BASE_URL, + headers: { "Content-Type": "application/json" }, timeout: 100000, + withCredentials: true, }); -apiClient.interceptors.request.use( - (config) => { - const { token } = getAuthState(); - if (token?.jwt_token) { - config.headers.Authorization = `Bearer ${token?.jwt_token}`; - } - return config; - }, - (error) => { - return Promise.reject(error); - } -); +// ---------------------------- +// Refresh Token Queue Handling +// ---------------------------- +let isRefreshing = false; +interface IFailedQueueItem { + resolve: (token: string) => void; + reject: (error: any) => void; +} +let failedQueue: IFailedQueueItem[] = []; -// Add response interceptor -apiClient.interceptors.response.use( - async (response) => { - return response; - }, - async (error) => { - if (error.response?.status === 401) { - await logout(); - } - return Promise.reject(error); // Ensure error propagates +const processQueue = (error: any, token: string | null = null) => { + failedQueue.forEach(({ resolve, reject }) => { + error ? reject(error) : resolve(token as string); + }); + failedQueue = []; +}; + +// ---------------------------- +// 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) ); +// ---------------------------- +// Response Interceptor +// ---------------------------- +apiClient.interceptors.response.use((response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as any; + // If unauthorized and token can be refreshed + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; -const logout = async () => { - delete apiClient.defaults.headers.common['Authorization']; - localStorage.clear(); - window.location.href = "/login"; -} + // If refresh is already running -> queue this request + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ + resolve: (newToken) => { + originalRequest.headers.Authorization = `Bearer ${newToken}`; + resolve(apiClient(originalRequest)); + }, + reject, + }); + }); + } + + // Start refresh + isRefreshing = true; + + try { + const newToken = await refreshAccessToken(); + + // Update queued requests + processQueue(null, newToken); + isRefreshing = false; + + // Retry original request with new token + originalRequest.headers.Authorization = `Bearer ${newToken}`; + return apiClient(originalRequest); + } catch (refreshError: any) { + processQueue(refreshError, null); + isRefreshing = false; + logout(); + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + } +); export default apiClient; diff --git a/src/api/file.api.ts b/src/api/file.api.ts index fb521b2..3baeafd 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; }; @@ -65,11 +71,24 @@ 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 }, withCredentials: true + }); + 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, moveFileOrFolder, createFile, + getPrivateFiles, shareFileOrFolder, getAllSharedFiles, getAllSharedFilesByMe, @@ -79,4 +98,6 @@ export default { deleteFileOrFolder, restoreFileOrFolder, emptyTrash, + getRecents, + updateFileAccessLevel, }; 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/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/custom/ImageViewer.tsx b/src/components/custom/ImageViewer.tsx index a64f913..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 = () => { @@ -323,10 +349,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/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/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/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/components/file-manager/ShareFileModal.tsx b/src/components/file-manager/ShareFileModal.tsx index 0a2cd61..a854268 100644 --- a/src/components/file-manager/ShareFileModal.tsx +++ b/src/components/file-manager/ShareFileModal.tsx @@ -51,12 +51,11 @@ export function ShareFileModal({ page: 1, limit: 20, is_active: true, - email_verified: true + email_verified: false }) setUsers(Array.isArray(result) ? result : []) setShowDropdown(true) } catch (error) { - console.error("Error searching users:", error) setUsers([]) } finally { setIsSearching(false) 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/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/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index f3efc75..9ed2595 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,19 @@ export function VideoPlayer({ const videoRef = useRef(null); 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 @@ -204,10 +262,112 @@ export function VideoPlayer({ }); player.on('error', (error: any) => { - console.error('Video error:', error); if (onError) onError(error); }); + // Skip handler function (shared for both mouse and touch) + const handleSkip = (x: number, playerEl: HTMLElement) => { + const rect = playerEl.getBoundingClientRect(); + const clickX = x - 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); + } + }; + + // 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 (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 player.on('keydown', (e: any) => { const event = e as KeyboardEvent; @@ -268,13 +428,71 @@ export function VideoPlayer({ } return () => { + // Clean up event listeners if (playerRef.current) { + const playerEl = playerRef.current.el() as HTMLElement | null; + if (playerEl) { + // 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; + } playerRef.current.dispose(); playerRef.current = null; } }; }, [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/player/VideoPlayerModal.tsx b/src/components/player/VideoPlayerModal.tsx index 2e9cfdd..19f71fa 100644 --- a/src/components/player/VideoPlayerModal.tsx +++ b/src/components/player/VideoPlayerModal.tsx @@ -27,8 +27,7 @@ export function VideoPlayerModal({ const handleError = (err: any) => { setIsLoading(false); - setError('Failed to load video. Please try again.'); - console.error('Video error:', err); + setError(err?.message ?? 'Failed to load video. Please try again.'); }; if (!isOpen) return null; @@ -36,11 +35,11 @@ export function VideoPlayerModal({ return (
{/* Backdrop */} -
- + {/* Modal Content */}
{/* Header */} @@ -58,7 +57,7 @@ export function VideoPlayerModal({
- + {/* Video Container */}
{isLoading && ( @@ -66,7 +65,7 @@ export function VideoPlayerModal({
)} - + {error && (
@@ -86,7 +85,7 @@ export function VideoPlayerModal({
)} - + void + onClose?: () => void // Callback when modal is closed without verification + title?: string + description?: string + required?: boolean // If true, modal cannot be closed until verified +} + +export function VerifyPinModal({ + isOpen, + onVerified, + onClose, + 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("") + const [isVerified, setIsVerified] = useState(false) + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + setPin("") + setError("") + setIsVerified(false) + } + }, [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("") + setIsVerified(true) + onVerified() + } else { + setError(result.error || "Invalid PIN. Please try again.") + } + } + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && pin.length === 4) { + handleVerify() + } + } + + // Handle modal close + const handleOpenChange = (open: boolean) => { + if (!open && !isVerified) { + // If modal is closing without verification, call onClose callback (e.g., navigate back) + onClose?.() + } + } + + const hasPin = user?.pin_hash + + return ( + + + +
+
+ +
+ {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/active-sessions.tsx b/src/components/settings/active-sessions.tsx new file mode 100644 index 0000000..78b934b --- /dev/null +++ b/src/components/settings/active-sessions.tsx @@ -0,0 +1,387 @@ +"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/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 d25e744..1b2f657 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,26 +8,118 @@ 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 { - User, - Shield, - Bell, - Smartphone, - HardDrive, - Globe, - Lock, - Eye, - Download, - Upload, - Settings, - Palette +import { useAuth } from "@/contexts/useAuth" +import { useTheme } from "@/contexts/ThemeContext" +import { + User, + Shield, + HardDrive, + Lock, + Eye, + Palette, + Key, + CheckCircle2, + Sun, + Moon, + Monitor, + Mail, + UserCircle, + Calendar, + CheckCircle, + XCircle, } from "lucide-react" +import { ApiTokenSettings } from "./api-token-settings" +import { ActiveSessions } from "./active-sessions" interface SettingsContentProps { activeTab: string } 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("") + 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 = () => ( - 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"} +

+
+
+
- @@ -85,9 +263,9 @@ export function SettingsContent({ activeTab }: SettingsContentProps) {
-
+
Free Plan - + 100 GB total storage
@@ -104,20 +282,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 +

+ )}
@@ -173,7 +377,7 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { -
+

Use an authenticator app for additional security

@@ -196,14 +400,14 @@ export function SettingsContent({ activeTab }: SettingsContentProps) { -
+

Allow others to see your profile

-
+

Help improve the app with usage data

@@ -215,172 +419,244 @@ 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

-
- -
-
-
+ const renderPinTab = () => { + const hasPin = user?.pin_hash - {/* Push Notifications */} - - - - - Push Notifications - - - Manage notifications on your devices - - - -
-
- -

When someone shares files with you

-
- -
-
-
- -

When approaching storage limits

-
- -
-
-
-
- ) + 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 = () => ( + 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 -

-
-
+
) + const renderSessionsTab = () => ( + + ) + const renderContent = () => { switch (activeTab) { case "general": return renderGeneralTab() case "security": return renderSecurityTab() - case "notifications": - return renderNotificationsTab() - case "apps": - return renderAppsTab() + case "pin": + return renderPinTab() + case "api-token": + return renderApiTokenTab() + case "sessions": + return renderSessionsTab() default: return renderGeneralTab() } 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..c2ebbe7 100644 --- a/src/components/settings/settings-page.tsx +++ b/src/components/settings/settings-page.tsx @@ -1,16 +1,17 @@ "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 6a58207..1c3a7a4 100644 --- a/src/components/settings/settings-tabs.tsx +++ b/src/components/settings/settings-tabs.tsx @@ -6,8 +6,9 @@ import { cn } from "@/lib/utils" const tabs = [ { id: "general", label: "General" }, { id: "security", label: "Security" }, - { id: "notifications", label: "Notifications" }, - { id: "apps", label: "Apps" }, + { id: "pin", label: "PIN" }, + { id: "api-token", label: "API Token" }, + { id: "sessions", label: "Sessions" }, ] interface SettingsTabsProps { @@ -17,31 +18,33 @@ interface SettingsTabsProps { export function SettingsTabs({ activeTab, onTabChange }: SettingsTabsProps) { return ( -
- +
+
+ +
) } 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 +} 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/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/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 ( = ({ allowedTypes = DEFAULT_FILE_CONFIGS, maxFiles = 10, folderId, + accessLevel, }) => { const { createFile } = useFile(); const navigate = useNavigate(); const [selectedFiles, setSelectedFiles] = useState([]); const [isDragOver, setIsDragOver] = useState(false); const [showWarning, setShowWarning] = useState(false); + const [completedFiles, setCompletedFiles] = useState>(new Set()); const { state, @@ -60,10 +65,11 @@ const FileUploader: React.FC = ({ abortUpload, removeFile, updateFileState, + setButtonLoading, autoClearCompleted } = useUpload(); - const { fileStates } = state; + const { fileStates, error: uploadError } = state; // Check if any uploads are in progress useEffect(() => { @@ -171,99 +177,162 @@ 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); + 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)) - }); + 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({ + 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)), + isUploading: false + }); + 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]) { + 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, + isUploading: true, + isRetrying: false, + isAborting: false, + isRemoving: false + }); + } else { + updateFileState(file.name, { + status: 'uploading', + progress: 0, + error: null, + isUploading: true + }); + } + + // Update progress to show upload started 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, { - 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 - }); - } 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 + isUploading: false + }); + setCompletedFiles(prev => new Set([...prev, file.name])); + } else { + throw new Error(uploadError || 'Upload failed - no file returned'); + } + break; } } } 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)); }; const formatFileSize = (bytes: number): string => { @@ -274,23 +343,23 @@ const FileUploader: React.FC = ({ 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)' }; } }; @@ -314,7 +383,7 @@ const FileUploader: React.FC = ({ const timer = setTimeout(() => { autoClearCompleted(); }, 3000); // Clear after 3 seconds - + return () => clearTimeout(timer); } }, [allFilesCompleted, autoClearCompleted]); @@ -328,10 +397,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(); @@ -350,22 +432,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) @@ -376,14 +474,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.

@@ -393,18 +506,40 @@ const FileUploader: React.FC = ({ )} {/* Close Button - Show when all files are completed */} {allFilesCompleted && ( -
-
- - +
+
+ + All files uploaded successfully!
@@ -412,42 +547,96 @@ const FileUploader: React.FC = ({ {/* File List */} {selectedFiles.length > 0 && ( -
-

+
+

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

{selectedFiles.map((file) => { - const fileState = fileStates[file.name] || { + const fileState = fileStates[file.name] || (completedFiles.has(file.name) ? { + uploadId: null, + url: null, + fileKey: null, + progress: 100, + status: 'completed' as const, + error: null, + totalChunks: Math.ceil(file.size / (5 * 1024 * 1024)), + lastUploadedChunk: Math.ceil(file.size / (5 * 1024 * 1024)), + 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 (
-
-
+
+
{getFileIcon(getFileType(file))}
-

+

{file.name}

-

+

{formatFileSize(file.size)}

@@ -456,7 +645,7 @@ const FileUploader: React.FC = ({ {/* Progress Bar */}
- + {fileState.status === 'processing' ? 'Processing...' : fileState.status === 'error' ? 'Failed' : fileState.status === 'completed' ? 'Completed' @@ -464,53 +653,126 @@ const FileUploader: React.FC = ({ : 'Ready to upload'} {fileState.status === 'uploading' && fileState.totalChunks > 1 && ( - + Chunk {fileState.lastUploadedChunk}/{fileState.totalChunks} )}
-
+
{/* Action Buttons */} -
-
+
+
{fileState.status === 'idle' && ( )} {fileState.status === 'error' && ( )} {fileState.status === 'uploading' && fileState.totalChunks > 1 && ( )} {fileState.status === 'completed' && fileState.url && (
{fileState.error && ( -

+

{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/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/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/ApiTokenContext.tsx b/src/contexts/ApiTokenContext.tsx new file mode 100644 index 0000000..afc3e1f --- /dev/null +++ b/src/contexts/ApiTokenContext.tsx @@ -0,0 +1,90 @@ +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 () => { + setLoading(true); + const response = await apiTokenApi.listTokens(); + setTokens(response.data); + 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/contexts/NotificationContext.tsx b/src/contexts/NotificationContext.tsx index 8f13028..6c0908c 100644 --- a/src/contexts/NotificationContext.tsx +++ b/src/contexts/NotificationContext.tsx @@ -4,6 +4,8 @@ import notificationApi from '@/api/notification.api'; 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 = { @@ -41,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 } @@ -60,7 +62,7 @@ const initialState: NotificationState = { unreadCount: 0, loading: true, hasMore: false, - totalCount: 0, + nextCursor: null, loadingMore: false, }; @@ -81,7 +83,7 @@ const notificationReducer = (state: NotificationState, action: NotificationActio ...state, notifications, unreadCount, - totalCount: action.totalCount, + nextCursor: action.nextCursor, hasMore: action.hasMore, loading: false, }; @@ -102,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': { @@ -115,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': { @@ -148,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 }>; @@ -161,30 +162,29 @@ 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(); // Memoized query key - const notificationsQueryKey = useMemo(() => ['notifications', { limit: 20, offset: 0 }], []); + const notificationsQueryKey = useMemo(() => ['notifications', { limit: 20 }], []); 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) => { - 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) => { @@ -197,7 +197,6 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr return { ...oldData, notifications: [notification, ...oldData.notifications], - totalCount: oldData.totalCount + 1, }; }); }; @@ -215,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, }; }, @@ -241,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]); @@ -326,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, }); @@ -358,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 @@ -384,17 +385,16 @@ export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ childr ...oldData, notifications: [...oldData.notifications, ...newNotifications], hasMore, - totalCount, + nextCursor, }; }); } 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 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/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/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/UploadContext.tsx b/src/contexts/UploadContext.tsx index a370d99..6d7682d 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,12 +99,20 @@ 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, }; 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: { @@ -153,6 +167,24 @@ 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': + // Don't update if file doesn't exist in state (prevent partial states) + if (!state.fileStates[action.payload.fileName]) { + return state; + } + 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 +207,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 +218,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,42 +250,47 @@ 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(() => { dispatch({ type: 'CLEAR_ALL_COMPLETED' }); }, 5000); // 5 seconds delay - + // Return cleanup function return () => clearTimeout(timeoutId); } }, [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; 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 +302,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 +319,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,125 +333,113 @@ 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) { - 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?.message || 'Network error' }); + dispatch({ type: 'ERROR', payload: error?.response?.data?.message || error?.message || 'Network error' }); return []; } }; 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, }, }); @@ -427,6 +471,7 @@ export const UploadProvider: React.FC<{ children: ReactNode }> = ({ children }) autoClearCompleted, uploadFiles, deleteFile, + setButtonLoading, reset, }; diff --git a/src/contexts/fileContext.tsx b/src/contexts/fileContext.tsx index 6cbffb8..9bd9f9f 100644 --- a/src/contexts/fileContext.tsx +++ b/src/contexts/fileContext.tsx @@ -1,41 +1,48 @@ 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, - RenameFolderInput, - MoveFileOrFolderInput, - CreateFileInput, - ShareFileOrFolderInput, - FileSystemNode, - SharedFileSystemNode, +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 { fileSystemTree: FileSystemNode[]; + privateFiles: FileSystemNode[]; trash: FileSystemNode[]; sharedFiles: SharedFileSystemNode[]; sharedFilesByMe: SharedFileSystemNode[]; sharedFilesWithMe: SharedFileSystemNode[]; + recents: { files: Array>; metadata: { total: number; page: number; limit: number; totalPages: number } } | null; loading: boolean; } 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[] } | { type: 'SET_SHARED_FILES_WITH_ME'; sharedFilesWithMe: SharedFileSystemNode[] } + | { type: 'SET_RECENTS'; recents: FileState['recents'] } | { type: 'SET_LOADING'; loading: boolean }; const initialState: FileState = { fileSystemTree: [], + privateFiles: [], trash: [], sharedFiles: [], sharedFilesByMe: [], sharedFilesWithMe: [], + recents: null, loading: false, }; @@ -43,6 +50,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': @@ -51,6 +60,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,10 +81,13 @@ interface FileContextType extends FileState { getAllSharedFilesByMe: () => Promise; getAllSharedFilesWithMe: () => Promise; getFileSystemTree: () => Promise; + getPrivateFiles: () => Promise; getTrash: () => Promise; + getRecents: (page?: number, limit?: number) => Promise; deleteFileOrFolder: (id: string) => MutationResult; restoreFileOrFolder: (id: string) => MutationResult; emptyTrash: () => MutationResult; + updateFileAccessLevel: (id: string, data: { access_level: AccessLevel }) => MutationResult; } const FileContext = createContext(undefined); @@ -96,6 +110,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 () => { @@ -129,11 +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 }); - toast.success('Folder created successfully!'); // 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 }); @@ -148,7 +182,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 +198,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 +214,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 +230,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 +246,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 +262,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 +277,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 }); @@ -427,6 +454,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 }); @@ -465,9 +514,61 @@ 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 { 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, + loading: state.loading || fileSystemTreeLoading || trashLoading || privateFilesLoading, createFolder, renameFolder, moveFileOrFolder, @@ -477,10 +578,13 @@ export const FileProvider: React.FC<{ children: ReactNode }> = ({ children }) => getAllSharedFilesByMe, getAllSharedFilesWithMe, getFileSystemTree, + getPrivateFiles, getTrash, + getRecents, deleteFileOrFolder, restoreFileOrFolder, emptyTrash, + updateFileAccessLevel, }; return {children}; diff --git a/src/contexts/useAuth.tsx b/src/contexts/useAuth.tsx index d649a2d..c232e8b 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; } @@ -49,9 +59,21 @@ 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 }>; + verifyPin: (pin: string) => Promise<{ success: boolean; error?: string }>; + changePin: (oldPin: string, newPin: string) => Promise<{ success: boolean; error?: string }>; + setPinLoading: boolean; + verifyPinLoading: boolean; + 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); @@ -86,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."); } @@ -162,6 +189,51 @@ 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: (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 }); + } + }, + 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,18 +289,130 @@ 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 { 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 () => { + await logoutAllMutationFn(); + }; + + const getActiveSessions = async () => { + const result = await authApi.getActiveSessions(); + return result.data?.sessions || []; + }; + + 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 }); + 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, login, register, saveUser, logout, + logoutAll, logoutLoading, + logoutAllLoading, getAllUsers, VerifyEmail, + setPin, + verifyPin, + changePin, + setPinLoading, + verifyPinLoading, + changePinLoading, + getPinSession, + isPinSessionValid, + getActiveSessions, + revokeToken, }; - return {children},; + return {children}; }; export const useAuth = () => { 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/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/hooks/useQueryState.tsx b/src/hooks/useQueryState.tsx new file mode 100644 index 0000000..7efe284 --- /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/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/lib/utils.ts b/src/lib/utils.ts index cff9ffc..fdb15f8 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 { FileSystemNode, SharedFileSystemNode } from "@/types/file.types" +import type { StandardFileItem, DeletedFileItem, PrivateFileItem, SharedFileItem } from "@/types/file-manager" import { FileText, FolderIcon, @@ -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, } } @@ -151,3 +152,93 @@ 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, + access_level: node.access_level, + 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)) +} + +// 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/all-files-page.tsx b/src/pages/all-files-page.tsx index 92b2dfb..8de6ad9 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" @@ -16,11 +16,13 @@ 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(); 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 @@ -32,8 +34,8 @@ 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 { downloadFile } = useFileDownload(); const navigate = useNavigate(); // Transform dynamic data to FileItem format @@ -52,10 +54,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])) @@ -124,7 +168,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,14 +271,19 @@ export default function AllFilesPage() { setFileToShare(null); } + const handleChangeAccessLevel = async (file: FileItem, accessLevel: string) => { + 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, onDelete: handleDeleteFile, + onChangeAccessLevel: handleChangeAccessLevel, } return ( @@ -258,6 +307,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])) @@ -59,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)); @@ -138,15 +184,35 @@ export function DeletedFilesPage() { Filter - -
diff --git a/src/pages/home-dashboard.tsx b/src/pages/home-dashboard.tsx index 328f6af..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,24 +13,21 @@ import { Star, Upload, FolderPlus, - Search, Activity, + Lock, } from "lucide-react" 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" +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" }, @@ -50,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 (
@@ -69,7 +114,7 @@ export function HomeDashboard() { >

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

Here's what's happening with your files today. @@ -92,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)} > -

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

- {file.name} -

-

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

-
- - {file.type} - -
- ))} -
+ @@ -303,7 +319,15 @@ export function HomeDashboard() {
- + {/* Create Folder Modal */} + {isCreateFolderModalOpen && setIsCreateFolderModalOpen(false)} + />}
) diff --git a/src/pages/notifications-page.tsx b/src/pages/notifications-page.tsx index 240aa79..4f6edef 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)}
@@ -250,7 +250,6 @@ export default function NotificationsPage() { loading, loadingMore, hasMore, - totalCount, loadMoreNotifications, markAsRead, markAllAsRead, @@ -304,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) => { @@ -394,13 +392,13 @@ 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

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

@@ -436,7 +434,7 @@ export default function NotificationsPage() { transition={{ duration: 0.3, delay: 0.1 }} className="mb-4 sm:mb-6" > -
+
{notificationCategories.map((category) => ( - -
+ // Show loading state while checking session + if (isCheckingSession) { + return ( +
+
+ +

Checking session...

- - - - - {/* Toolbar */} - -
-
- - setSearchQuery(e.target.value)} - className="pl-10" - /> +
+ ) + } + + // 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

- - -
+ + ) + } -
- {selectedFiles.length > 0 && {selectedFiles.length} selected} -
- - + return ( + <> + +
+ {/* Header */} + +
+
+ {currentPath.length > 0 && ( + + )} +
+
+ +

Private Files

+
+

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

+
+
+
+ + +
-
- + + + {currentPath.length > 0 && ( + + )} + + - {/* Bulk Actions */} - {selectedFiles.length > 0 && ( + {/* Toolbar */} - - {selectedFiles.length} private items selected -
- - - +
+
+ + setSearchQuery(e.target.value)} + className="pl-10 w-full" + /> +
+
+ + + +
+
+ +
+ {selectedFiles.length > 0 && ( + + {selectedFiles.length} + + )} + {selectedFiles.length > 0 && ( + + {selectedFiles.length} selected + + )} +
+ + +
- )} - - {/* Unified File Manager */} - -
+ + {/* Bulk Actions */} + {selectedFiles.length > 0 && ( + +
+ + + {selectedFiles.length} private items selected + {selectedFiles.length} selected + +
+
+ + +
+
+ )} + + {/* Unified File Manager */} + +
+ ) } diff --git a/src/pages/shared-files-page.tsx b/src/pages/shared-files-page.tsx index 74b3dba..62fca04 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,48 @@ 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("") + } + } + + 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 +160,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/providers/AppProviders.tsx b/src/providers/AppProviders.tsx index 593f600..b7506f2 100644 --- a/src/providers/AppProviders.tsx +++ b/src/providers/AppProviders.tsx @@ -4,21 +4,39 @@ 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"; +import { FileProvider } from "@/contexts/fileContext"; import { NotificationProvider } from "@/contexts/NotificationContext"; +import { NotificationUIProvider } from "@/contexts/NotificationUIContext"; import { SocketProvider } from "@/contexts/SocketContext"; -const queryClient = new QueryClient(); +import { ApiTokenProvider } from "@/contexts/ApiTokenContext"; + +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) => ( <> @@ -27,8 +45,11 @@ const AppProviders = ({ children }: { children: ReactNode }) => { ), (children: ReactNode) => {children}, + (children: ReactNode) => {children}, (children: ReactNode) => {children}, + (children: ReactNode) => {children}, (children: ReactNode) => {children}, + ]; return providers.reduceRight((acc, Provider) => Provider(acc), children); diff --git a/src/routes/Upload.tsx b/src/routes/Upload.tsx index 47a06d4..93680f8 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[] = [ { @@ -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

@@ -56,6 +56,7 @@ const Upload = () => { allowedTypes={allowedTypes} maxFiles={10} folderId={folder_id} + accessLevel={access_level} />
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/store/connect-socket.ts b/src/store/connect-socket.ts index 0c2a04a..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,36 +10,31 @@ 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 } }); 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); }); 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/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; } 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 diff --git a/src/types/user.types.ts b/src/types/user.types.ts index 38a660d..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]; @@ -15,6 +17,7 @@ export interface IUser { email: string; role: UserRole; password_hash: string; + pin_hash?: string; display_name?: string; avatar_url?: string; storage_quota: number;