diff --git a/packages/backend-server/app/api/v1/web/api.py b/packages/backend-server/app/api/v1/web/api.py index a2db900f..2992df45 100644 --- a/packages/backend-server/app/api/v1/web/api.py +++ b/packages/backend-server/app/api/v1/web/api.py @@ -6,7 +6,7 @@ from app.api.v1.web.user import api as user_router from app.api.v1.web.secrets import api as secrets_router from app.api.v1.web.dashboard import api as dashboard_router - +from app.api.v1.web.drive import api as drive_router api_router = APIRouter() api_router.prefix = "/web" @@ -18,3 +18,5 @@ api_router.include_router(user_router.router, tags=["User"]) api_router.include_router(workspace_router.router, tags=["Workspace"]) api_router.include_router(dashboard_router.router, tags=["Dashboard"]) +api_router.include_router(drive_router.router) + diff --git a/packages/backend-server/app/api/v1/web/drive/api.py b/packages/backend-server/app/api/v1/web/drive/api.py new file mode 100644 index 00000000..33d81166 --- /dev/null +++ b/packages/backend-server/app/api/v1/web/drive/api.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter +from app.api.v1.web.drive.folders import api as folders_router +from app.api.v1.web.drive.files import api as files_router + + +router = APIRouter() + +router.prefix = "/drive" + +router.include_router(folders_router.router, tags=["Drive: Folders"]) +router.include_router(files_router.router, tags=["Drive: Files"]) \ No newline at end of file diff --git a/packages/backend-server/app/api/v1/web/drive/files/__init__.py b/packages/backend-server/app/api/v1/web/drive/files/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/backend-server/app/api/v1/web/drive/files/api.py b/packages/backend-server/app/api/v1/web/drive/files/api.py new file mode 100644 index 00000000..fa6a23ab --- /dev/null +++ b/packages/backend-server/app/api/v1/web/drive/files/api.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter,Request,Depends,UploadFile +from app.api.v1.web.drive.files.schema import RenameFile, MoveFile, DeleteFiles, GetPresignedUrl +from app.api.v1.web.auth.schema import UserDetails +from app.framework.permission_services.service import get_current_user +from app.api.v1.web.drive.files.services import rename_file, get_presigned_url, delete_files, move_files + +router = APIRouter() + +FILE_URL ="/file" + +@router.post(FILE_URL+"/get-presigned-url") +async def get_presigned_url_api(payload: GetPresignedUrl, user: UserDetails = Depends(get_current_user)): + return await get_presigned_url(user, payload.model_dump()) + +@router.post(FILE_URL+"/rename") +async def rename_file_api(payload: RenameFile, user: UserDetails = Depends(get_current_user)): + return await rename_file(user, payload.model_dump()) + + +@router.delete(FILE_URL+"/delete") +async def delete_files_api(payload: DeleteFiles, user: UserDetails = Depends(get_current_user)): + return await delete_files(user, payload.model_dump()) + + +@router.post(FILE_URL+"/move") +async def move_files_api(payload: MoveFile, user: UserDetails = Depends(get_current_user)): + return await move_files(user, payload.model_dump()) diff --git a/packages/backend-server/app/api/v1/web/drive/files/schema.py b/packages/backend-server/app/api/v1/web/drive/files/schema.py new file mode 100644 index 00000000..e454b7cf --- /dev/null +++ b/packages/backend-server/app/api/v1/web/drive/files/schema.py @@ -0,0 +1,24 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + + +class RenameFile(BaseModel): + name: str = Field(..., description="File name") + file_id: str = Field(..., description="File ID") + parent_id: Optional[str] = Field(None, description="Parent folder ID, None for root") + +class MoveFile(BaseModel): + file_ids: List[str] = Field(..., description="File IDs") + parent_id: str = Field(..., description="Parent folder ID") + +class DeleteFiles(BaseModel): + file_ids: List[str] = Field(..., description="File IDs") + +class GetPresignedUrl(BaseModel): + file_type: str = Field(..., description="File type") + name: str = Field(..., description="File name") + size: int = Field(..., description="File size") + file_path: str = Field(..., description="File path") + parent_id: Optional[str] = Field(None, description="Parent folder ID") + iv: str = Field(..., description="IV") \ No newline at end of file diff --git a/packages/backend-server/app/api/v1/web/drive/files/services.py b/packages/backend-server/app/api/v1/web/drive/files/services.py new file mode 100644 index 00000000..336efa3b --- /dev/null +++ b/packages/backend-server/app/api/v1/web/drive/files/services.py @@ -0,0 +1,130 @@ +from app.framework.mongo_db import base_manager as db_manager +from app.managers.collection_names import FILES, FOLDERS +from app.utils.utils import response_helper, create_uuid, get_file_extension, get_folders_from_path, create_timestamp +from app.utils.i8ns import translate +from app.utils.s3_utils import generate_upload_url, generate_download_url + + + +def get_files_list(user, parent_id=None): + db = user.get("db") + query = {"created_by": user.get("user_id")} + if parent_id: + query["parent_id"] = parent_id + files = db_manager.find(db, FILES, query) + for file in files: + file["file_url"]= generate_download_url(file.get("key"), 360000) + return files + +async def get_presigned_url(user, payload): + db = user.get("db") + user_id = user.get("user_id") + file_name = payload.get("name") + + # Check if file already exists for this user + query = { + "lower_name": file_name.strip().lower(), + "created_by": user_id + } + if payload.get("parent_id"): + query["parent_id"] = payload.get("parent_id") + + file = db_manager.find_one(db, FILES, query) + if file: + return response_helper(400, translate("drive.files.already_exists")) + + # Generate file paths + file_id = create_uuid() + file_extension = get_file_extension(file_name) + final_file_path = f"{user_id}/files/{file_id}.{file_extension}" + + # Get folders from path + folders = get_folders_from_path(payload.get("file_path")) + parent_id = payload.get("parent_id") + + # Create folder hierarchy if folders exist + for folder_name in folders: + query = { + "created_by": user_id, + "lower_name": folder_name.strip().lower() + } + if parent_id: + query["parent_id"] = parent_id + + folder_details = db_manager.find_one(db, FOLDERS, query) + if not folder_details: + # Create new folder + folder_data = { + "doc_id": create_uuid(), + "name": folder_name.strip(), + "parent_id": parent_id, + "created_by": user_id, + "lower_name": folder_name.strip().lower(), + } + db_manager.insert_one(db, FOLDERS, folder_data) + parent_id = folder_data.get("doc_id") + else: + # Use existing folder + parent_id = folder_details.get("doc_id") + + # Insert file record (always, regardless of whether folders exist) + file_data = { + "doc_id": file_id, + "type": payload.get("file_type"), + "name": file_name, + "lower_name": file_name.strip().lower(), + "size": payload.get("size"), + "path": payload.get("file_path"), + "key": final_file_path, + "parent_id": parent_id, + "created_by": user_id, + "iv": payload.get("iv"), + } + db_manager.insert_one(db, FILES, file_data) + + # Generate presigned upload URL + upload_url = generate_upload_url(final_file_path) + + return response_helper(200, translate("file.get_presigned_url"), data=upload_url) + + +async def rename_file(user, payload): + db = user.get("db") + name = payload.get("name").strip() + file_id = payload.get("file_id") + lower_name = name.lower() + query = {"created_by": user.get("user_id"), "lower_name": lower_name, "doc_id": {"$ne": file_id}} + + if payload.get("parent_id"): + query["parent_id"] = payload.get("parent_id") + + file = db_manager.find_one(db, FILES, query) + if file: + return response_helper(400, translate("drive.files.already_exists")) + + db_manager.update_one(db, FILES, {"doc_id": file_id, "created_by": user.get("user_id")}, {"$set": {"name": name, "lower_name": lower_name}}) + return response_helper(200, translate("drive.files.renamed")) + + +async def delete_files(user, payload): + db = user.get("db") + file_ids = payload.get("file_ids") + user_id = user.get("user_id") + db_manager.update_many(db, FILES, {"doc_id": {"$in": file_ids}, "created_by": user_id}, {"$set": {"access": False, "delete_by": user_id, "deleted_at": create_timestamp()}}) + return response_helper(200, translate("drive.files.deleted")) + + +async def move_files(user, payload): + db = user.get("db") + file_ids = payload.get("file_ids") + parent_id = payload.get("parent_id") + query = {"created_by": user.get("user_id"), "doc_id": {"$in": file_ids}} + files = db_manager.find(db, FILES, query) + files_moved = [] + for item in files: + individual_query = {"created_by": user.get("user_id"), "parent_id": parent_id, "lower_name": item.get("lower_name")} + if not db_manager.find_one(db, FILES, individual_query): + db_manager.update_one(db, FILES, {"doc_id": item.get("doc_id")}, {"$set": {"parent_id": parent_id}}) + files_moved.append(item.get("name")) + + return response_helper(200, translate("drive.files.moved"), files_moved=files_moved) diff --git a/packages/backend-server/app/api/v1/web/drive/folders/api.py b/packages/backend-server/app/api/v1/web/drive/folders/api.py new file mode 100644 index 00000000..76c144f3 --- /dev/null +++ b/packages/backend-server/app/api/v1/web/drive/folders/api.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter,Depends,Query +from app.api.v1.web.drive.folders.schema import CreateFolder, RenameFolder, MoveFolder, DeleteFolders +from app.api.v1.web.auth.schema import UserDetails +from app.framework.permission_services.service import get_current_user +from app.api.v1.web.drive.folders.services import create_folder,delete_folders,rename_folder, move_folders, get_folders_list + +FOLDER_URL ="/folder" +router = APIRouter() + +@router.post(FOLDER_URL+"/create") +async def create_folder_api(payload: CreateFolder, user: UserDetails = Depends(get_current_user)): + return await create_folder(user, payload.model_dump()) + + +@router.delete(FOLDER_URL+"/delete") +async def delete_folder_api(payload: DeleteFolders, user: UserDetails = Depends(get_current_user)): + return await delete_folders(user, payload.model_dump()) + + +@router.post(FOLDER_URL+"/rename") +async def rename_folder_api(payload: RenameFolder, user: UserDetails = Depends(get_current_user)): + return await rename_folder(user, payload.model_dump()) + + +@router.post(FOLDER_URL+"/move") +async def move_folder_api(payload: MoveFolder, user: UserDetails = Depends(get_current_user)): + return await move_folders(user, payload.model_dump()) + + +@router.get(FOLDER_URL+"/list") +async def get_all_folders_api(parent_id: str = Query(None, description="Parent folder ID", ge=1), user: UserDetails = Depends(get_current_user)): + return await get_folders_list(user, parent_id) \ No newline at end of file diff --git a/packages/backend-server/app/api/v1/web/drive/folders/schema.py b/packages/backend-server/app/api/v1/web/drive/folders/schema.py new file mode 100644 index 00000000..b12de497 --- /dev/null +++ b/packages/backend-server/app/api/v1/web/drive/folders/schema.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + +class CreateFolder(BaseModel): + name: str + parent_id: Optional[str]= None + +class RenameFolder(BaseModel): + name: str + folder_id: str + parent_id: Optional[str] = None + +class MoveFolder(BaseModel): + folder_ids: List[str] + parent_id: str + +class DeleteFolders(BaseModel): + folder_ids: List[str] \ No newline at end of file diff --git a/packages/backend-server/app/api/v1/web/drive/folders/services.py b/packages/backend-server/app/api/v1/web/drive/folders/services.py new file mode 100644 index 00000000..3c9dd280 --- /dev/null +++ b/packages/backend-server/app/api/v1/web/drive/folders/services.py @@ -0,0 +1,102 @@ + +from app.framework.mongo_db import base_manager as db_manager +from app.managers.collection_names import FILES, FOLDERS +from app.utils.utils import response_helper, create_uuid, create_timestamp +from app.utils.i8ns import translate +from app.api.v1.web.drive.files.services import get_files_list + +async def create_folder(user, payload): + db = user.get("db") + folder_id = create_uuid() + query={ + "created_by": user.get("user_id"), + "lower_name": payload.get("name").strip().lower(), + } + if payload.get("parent_id"): + query["parent_id"] = payload.get("parent_id") + folder = db_manager.find_one(db, FOLDERS, query) + if folder: + return response_helper(400, translate("drive.folders.already_exists")) + + folder = { + "doc_id": folder_id, + "name": payload.get("name"), + "parent_id": payload.get("parent_id"), + "created_by": user.get("user_id"), + "lower_name": payload.get("name").strip().lower(), + "created_at": create_timestamp(), + + } + db_manager.insert_one(db, FOLDERS, folder) + return response_helper(200, translate("drive.folders.created")) + + +async def delete_folders(user, payload): + db = user.get("db") + folder_ids = payload.get("folder_ids") + user_id = user.get("user_id") + db_manager.update_many(db, FOLDERS, {"doc_id": {"$in":folder_ids}, "created_by": user_id}, {"$set": {"access": False,"delete_by":user_id, "deleted_at": create_timestamp()}}) + return response_helper(200, translate("drive.folders.deleted")) + + +async def rename_folder(user, payload): + db = user.get("db") + name = payload.get("name").strip() + folder_id = payload.get("folder_id") + lower_name= name.lower() + query={"created_by": user.get("user_id"), "lower_name": lower_name} + + if payload.get("parent_id"): + query["parent_id"] = payload.get("parent_id") + folder = db_manager.find_one(db, FOLDERS, query) + + if folder: + return response_helper(400, translate("drive.folders.already_exists")) + + db_manager.update_one(db, FOLDERS, {"doc_id": folder_id, "created_by": user.get("user_id")}, {"$set": {"name": name, "lower_name": lower_name}}) + return response_helper(200, translate("drive.folders.renamed")) + + +async def move_folders(user, payload): + db = user.get("db") + folder_ids = payload.get("folder_ids") + parent_id = payload.get("parent_id") + query={"created_by": user.get("user_id"), "doc_id": {"$in":folder_ids}} + folders = db_manager.find(db, FOLDERS, query) + folders_moved=[] + for item in folders: + individual_query={"created_by": user.get("user_id"), "parent_id": parent_id, "lower_name": item.get("lower_name")} + if not db_manager.find_one(db, FOLDERS, individual_query): + db_manager.update_one(db, FOLDERS, {"doc_id": item.get("doc_id")}, {"$set": {"parent_id": parent_id}}) + folders_moved.append(item.get("name")) + + + db_manager.update_many(db, FOLDERS, {"doc_id": {"$in":folder_ids}, "created_by": user.get("user_id")}, {"$set": {"parent_id": parent_id}}) + return response_helper(200, translate("drive.folders.moved"), folders_moved=folders_moved) + +async def get_folders_list(user, parent_id): + query = {"created_by": user.get("user_id")} + if parent_id: + query["parent_id"] = parent_id + + folders = get_folders(user, parent_id) + + for folder in folders: + folder["files"] = get_files_list(user, folder.get("doc_id")) + folder["sub_folders"] = get_folders(user, folder.get("doc_id")) + + data= { + "folders": folders, + "files": get_files_list(user, parent_id) + } + return response_helper(200, translate("drive.folders.list"), data=data) + + + +def get_folders(user, parent_id=None): + db = user.get("db") + query = {"created_by": user.get("user_id")} + if parent_id: + query["parent_id"] = parent_id + folders = db_manager.find(db, FOLDERS, query) + return folders \ No newline at end of file diff --git a/packages/backend-server/app/core/config.py b/packages/backend-server/app/core/config.py index 6d49ab9e..39f27e54 100644 --- a/packages/backend-server/app/core/config.py +++ b/packages/backend-server/app/core/config.py @@ -15,6 +15,11 @@ class Settings(BaseSettings): VALKEY_URL: str + DO_SPACES_KEY:str + DO_SPACES_SECRET:str + DO_SPACES_REGION:str + DO_SPACES_BUCKET:str + DO_SPACES_ENDPOINT:str class Config: case_sensitive = True env_file = ".env" diff --git a/packages/backend-server/app/locales/en.json b/packages/backend-server/app/locales/en.json index b57c3018..ecdbc480 100644 --- a/packages/backend-server/app/locales/en.json +++ b/packages/backend-server/app/locales/en.json @@ -145,6 +145,24 @@ "dashboard":{ "overview": "Dashboard overview loaded successfully", "recent_activity": "Recent activity loaded successfully" + }, + "drive":{ + "folders":{ + "created": "Folder created successfully", + "deleted": "Folder deleted successfully", + "renamed": "Folder renamed successfully", + "moved": "Folder moved successfully", + "already_exists": "Folder already exists" + }, + "files":{ + "deleted": "File deleted successfully", + "renamed": "File renamed successfully", + "moved": "File moved successfully", + "already_exists": "File already exists" + } + }, + "file":{ + "get_presigned_url": "Presigned URL generated successfully" } } diff --git a/packages/backend-server/app/managers/collection_names.py b/packages/backend-server/app/managers/collection_names.py index 90e20578..5d6803cf 100644 --- a/packages/backend-server/app/managers/collection_names.py +++ b/packages/backend-server/app/managers/collection_names.py @@ -12,3 +12,7 @@ PROJECT_KEYS = "project_keys" PASSWORD_HISTORY = "password_history" PROJECT_ACTIVITY = "project_activity" + + +FOLDERS = "folders" +FILES = "files" \ No newline at end of file diff --git a/packages/backend-server/app/utils/jwt_utils.py b/packages/backend-server/app/utils/jwt_utils.py index 299eeea4..b62006eb 100644 --- a/packages/backend-server/app/utils/jwt_utils.py +++ b/packages/backend-server/app/utils/jwt_utils.py @@ -22,7 +22,7 @@ def create_jwt_token( return encoded_jwt -def encode_token(userid, days=30, ttl=None, device_id=None): +def encode_token(userid, days=60, ttl=None, device_id=None): user_id = userid payload = { "user": str(user_id), diff --git a/packages/backend-server/app/utils/s3_utils.py b/packages/backend-server/app/utils/s3_utils.py new file mode 100644 index 00000000..d115d051 --- /dev/null +++ b/packages/backend-server/app/utils/s3_utils.py @@ -0,0 +1,38 @@ +import boto3 +from app.core.config import settings + + +# DigitalOcean Spaces config +session = boto3.session.Session() +client = session.client( + "s3", + region_name=settings.DO_SPACES_REGION, + endpoint_url=f"https://{settings.DO_SPACES_REGION}.digitaloceanspaces.com", + aws_access_key_id=settings.DO_SPACES_KEY, + aws_secret_access_key=settings.DO_SPACES_SECRET, +) + +BUCKET_NAME = settings.DO_SPACES_BUCKET + + +def generate_upload_url(file_name,expires_in=3600): + try: + url = client.generate_presigned_url( + "put_object", + Params={"Bucket": BUCKET_NAME, "Key": file_name}, + ExpiresIn=expires_in, + ) + return {"upload_url": url} + except Exception as e: + return {"error": str(e)} + +def generate_download_url(file_name,expires_in=3600): + try: + url = client.generate_presigned_url( + "get_object", + Params={"Bucket": BUCKET_NAME, "Key": file_name}, + ExpiresIn=expires_in, + ) + return url + except Exception as e: + return None \ No newline at end of file diff --git a/packages/backend-server/app/utils/utils.py b/packages/backend-server/app/utils/utils.py index a08e2b02..c71f2345 100644 --- a/packages/backend-server/app/utils/utils.py +++ b/packages/backend-server/app/utils/utils.py @@ -1,9 +1,14 @@ from uuid_extensions import uuid7 - +from datetime import datetime from fastapi.encoders import jsonable_encoder from starlette.responses import JSONResponse import hashlib import json +from datetime import datetime +import pytz +import os + +from pathlib import Path def response_helper( @@ -56,3 +61,16 @@ def generate_query_hash(query, projection=None): combined_str = query_string + projection_str return hashlib.md5(combined_str.encode()).hexdigest() + + +def create_timestamp(): + return datetime.now(pytz.utc) + +def get_file_extension(filename): + _, ext = os.path.splitext(filename) + return ext.lstrip(".") + + + +def get_folders_from_path(path: str): + return [part for part in Path(path).parent.parts] \ No newline at end of file diff --git a/packages/backend-server/requirements.txt b/packages/backend-server/requirements.txt index 46a5bc4b..c86e669e 100644 --- a/packages/backend-server/requirements.txt +++ b/packages/backend-server/requirements.txt @@ -17,3 +17,4 @@ cryptography fastapi-limiter pyotp valkey +boto3 diff --git a/packages/frontend-web/DRIVE_IMPLEMENTATION.md b/packages/frontend-web/DRIVE_IMPLEMENTATION.md new file mode 100644 index 00000000..946da8f4 --- /dev/null +++ b/packages/frontend-web/DRIVE_IMPLEMENTATION.md @@ -0,0 +1,1590 @@ +# Drive Feature Implementation Plan + +## Overview +This document outlines the phase-wise implementation of the Drive feature in the Zecrypt web application. The Drive section will allow users to manage folders with create, rename, move, and delete operations. + +## Tech Stack +- **Framework**: Next.js 15 (App Router) +- **Language**: TypeScript +- **UI Components**: Shadcn UI +- **Styling**: Tailwind CSS +- **State Management**: React Hooks + Redux (for workspace/project selection) +- **HTTP Client**: Axios (with automatic access-token header injection) + +## Backend API Reference + +All APIs require the `access-token` header (automatically added by `axiosInstance`): + +### 1. Create Folder +- **Endpoint**: `POST /api/v1/web/drive/folder/create` +- **Request Body**: + ```json + { + "name": "string", + "parent_id": "string" (optional) + } + ``` +- **Response**: `200 OK` with folder ID string + +### 2. Delete Folder(s) +- **Endpoint**: `DELETE /api/v1/web/drive/folder/delete` +- **Request Body**: + ```json + { + "folder_ids": ["string"] + } + ``` +- **Response**: `200 OK` + +### 3. Rename Folder +- **Endpoint**: `POST /api/v1/web/drive/folder/rename` +- **Request Body**: + ```json + { + "name": "string", + "folder_id": "string", + "parent_id": "string" (optional) + } + ``` +- **Response**: `200 OK` + +### 4. Move Folder(s) +- **Endpoint**: `POST /api/v1/web/drive/folder/move` +- **Request Body**: + ```json + { + "folder_ids": ["string"], + "parent_id": "string" + } + ``` +- **Response**: `200 OK` + +--- + +## Phase 1: Core Data Structure & Hook Setup + +### 1.1 Create TypeScript Interfaces +**File**: `hooks/use-drive-management.ts` (start of file) + +Define the core data structures: + +```typescript +interface Folder { + folder_id: string; + name: string; + parent_id: string | null; + created_at: string; + updated_at: string | null; + created_by: string; + workspace_id: string; + project_id: string; +} + +interface UseDriveManagementProps { + selectedWorkspaceId: string | null; + selectedProjectId: string | null; +} + +interface UseDriveManagementReturn { + folders: Folder[]; + isLoading: boolean; + createFolder: (name: string, parentId?: string | null) => Promise; + renameFolder: (folderId: string, newName: string, parentId?: string | null) => Promise; + moveFolder: (folderIds: string[], parentId: string) => Promise; + deleteFolders: (folderIds: string[]) => Promise; + fetchFolders: () => Promise; + currentFolder: Folder | null; + setCurrentFolder: (folder: Folder | null) => void; + getFolderPath: (folderId: string | null) => Folder[]; + getSubfolders: (parentId: string | null) => Folder[]; +} +``` + +### 1.2 Implement Base Hook Structure +**File**: `hooks/use-drive-management.ts` + +Follow the pattern from `use-email-management.ts`: + +```typescript +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { useSelector } from "react-redux"; +import { RootState } from "@/libs/Redux/store"; +import axiosInstance from "@/libs/Middleware/axiosInstace"; +import { toast } from "@/components/ui/use-toast"; +import { useTranslator } from "@/hooks/use-translations"; + +export function useDriveManagement({ + selectedWorkspaceId, + selectedProjectId, +}: UseDriveManagementProps): UseDriveManagementReturn { + const { translate } = useTranslator(); + const [folders, setFolders] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [currentFolder, setCurrentFolder] = useState(null); + + // TODO: Implement fetchFolders + // TODO: Implement createFolder + // TODO: Implement renameFolder + // TODO: Implement moveFolder + // TODO: Implement deleteFolders + // TODO: Implement helper functions (getFolderPath, getSubfolders) + + return { + folders, + isLoading, + createFolder, + renameFolder, + moveFolder, + deleteFolders, + fetchFolders, + currentFolder, + setCurrentFolder, + getFolderPath, + getSubfolders, + }; +} +``` + +### 1.3 Implement Folder Fetching +**File**: `hooks/use-drive-management.ts` + +**Note**: Since no GET endpoint is provided yet, we'll use an empty array for now and add fetch logic when the API is ready. + +```typescript +const fetchFolders = useCallback(async () => { + if (!selectedWorkspaceId || !selectedProjectId) { + setIsLoading(false); + return; + } + + setIsLoading(true); + + try { + // TODO: Replace with actual GET endpoint when available + // const response = await axiosInstance.get( + // `/api/v1/web/drive/${selectedWorkspaceId}/${selectedProjectId}/folders` + // ); + // setFolders(response.data.data || []); + + // For now, use empty array or mock data + setFolders([]); + } catch (error) { + console.error("Error fetching folders:", error); + toast({ + title: translate("error", "drive", { default: "Error" }), + description: translate("error_fetching_folders", "drive", { + default: "Failed to fetch folders", + }), + variant: "destructive", + }); + setFolders([]); + } finally { + setIsLoading(false); + } +}, [selectedWorkspaceId, selectedProjectId, translate]); + +useEffect(() => { + if (selectedWorkspaceId && selectedProjectId) { + fetchFolders(); + } +}, [selectedWorkspaceId, selectedProjectId, fetchFolders]); +``` + +### 1.4 Implement Helper Functions + +```typescript +// Get breadcrumb path for a folder +const getFolderPath = useCallback((folderId: string | null): Folder[] => { + if (!folderId) return []; + + const path: Folder[] = []; + let currentId: string | null = folderId; + + while (currentId) { + const folder = folders.find(f => f.folder_id === currentId); + if (!folder) break; + path.unshift(folder); + currentId = folder.parent_id; + } + + return path; +}, [folders]); + +// Get subfolders of a parent +const getSubfolders = useCallback((parentId: string | null): Folder[] => { + return folders.filter(f => f.parent_id === parentId); +}, [folders]); +``` + +**Deliverables**: +- ✅ `hooks/use-drive-management.ts` with TypeScript interfaces +- ✅ Base hook structure with state management +- ✅ Fetch folders logic (ready for API integration) +- ✅ Helper functions for folder hierarchy + +--- + +## Phase 2: CRUD Operations Implementation + +### 2.1 Create Folder Function +**File**: `hooks/use-drive-management.ts` + +```typescript +const createFolder = useCallback( + async (name: string, parentId: string | null = null) => { + if (!selectedWorkspaceId || !selectedProjectId) { + toast({ + title: translate("error", "drive", { default: "Error" }), + description: translate("missing_workspace_project", "drive", { + default: "Missing workspace or project", + }), + variant: "destructive", + }); + return; + } + + try { + const payload: any = { name }; + if (parentId) { + payload.parent_id = parentId; + } + + await axiosInstance.post( + `/api/v1/web/drive/folder/create`, + payload + ); + + toast({ + title: translate("success", "drive", { default: "Success" }), + description: translate("folder_created", "drive", { + default: "Folder created successfully", + }), + }); + + // Refresh folders list + await fetchFolders(); + } catch (error) { + console.error("Error creating folder:", error); + toast({ + title: translate("error", "drive", { default: "Error" }), + description: translate("error_creating_folder", "drive", { + default: "Failed to create folder", + }), + variant: "destructive", + }); + throw error; + } + }, + [selectedWorkspaceId, selectedProjectId, fetchFolders, translate] +); +``` + +### 2.2 Rename Folder Function + +```typescript +const renameFolder = useCallback( + async (folderId: string, newName: string, parentId: string | null = null) => { + try { + const payload: any = { + name: newName, + folder_id: folderId, + }; + if (parentId) { + payload.parent_id = parentId; + } + + await axiosInstance.post( + `/api/v1/web/drive/folder/rename`, + payload + ); + + toast({ + title: translate("success", "drive", { default: "Success" }), + description: translate("folder_renamed", "drive", { + default: "Folder renamed successfully", + }), + }); + + await fetchFolders(); + } catch (error) { + console.error("Error renaming folder:", error); + toast({ + title: translate("error", "drive", { default: "Error" }), + description: translate("error_renaming_folder", "drive", { + default: "Failed to rename folder", + }), + variant: "destructive", + }); + throw error; + } + }, + [fetchFolders, translate] +); +``` + +### 2.3 Move Folder Function + +```typescript +const moveFolder = useCallback( + async (folderIds: string[], parentId: string) => { + try { + await axiosInstance.post( + `/api/v1/web/drive/folder/move`, + { + folder_ids: folderIds, + parent_id: parentId, + } + ); + + toast({ + title: translate("success", "drive", { default: "Success" }), + description: translate("folder_moved", "drive", { + default: `${folderIds.length} folder(s) moved successfully`, + }), + }); + + await fetchFolders(); + } catch (error) { + console.error("Error moving folder:", error); + toast({ + title: translate("error", "drive", { default: "Error" }), + description: translate("error_moving_folder", "drive", { + default: "Failed to move folder(s)", + }), + variant: "destructive", + }); + throw error; + } + }, + [fetchFolders, translate] +); +``` + +### 2.4 Delete Folder Function + +```typescript +const deleteFolders = useCallback( + async (folderIds: string[]) => { + try { + await axiosInstance.delete( + `/api/v1/web/drive/folder/delete`, + { + data: { folder_ids: folderIds } + } + ); + + toast({ + title: translate("success", "drive", { default: "Success" }), + description: translate("folder_deleted", "drive", { + default: `${folderIds.length} folder(s) deleted successfully`, + }), + }); + + await fetchFolders(); + } catch (error) { + console.error("Error deleting folder:", error); + toast({ + title: translate("error", "drive", { default: "Error" }), + description: translate("error_deleting_folder", "drive", { + default: "Failed to delete folder(s)", + }), + variant: "destructive", + }); + throw error; + } + }, + [fetchFolders, translate] +); +``` + +**Deliverables**: +- ✅ Create folder API integration +- ✅ Rename folder API integration +- ✅ Move folder API integration +- ✅ Delete folder API integration +- ✅ Toast notifications for all operations +- ✅ Error handling + +--- + +## Phase 3: Dialog Components + +### 3.1 Create Folder Dialog +**File**: `components/add-folder-dialog.tsx` + +Follow the pattern from `add-email-dialog.tsx`: + +```typescript +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { AlertCircle } from "lucide-react"; +import { useTranslator } from "@/hooks/use-translations"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface AddFolderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onFolderCreated: () => void; + parentFolder: { folder_id: string; name: string } | null; + onCreateFolder: (name: string, parentId: string | null) => Promise; +} + +export function AddFolderDialog({ + open, + onOpenChange, + onFolderCreated, + parentFolder, + onCreateFolder, +}: AddFolderDialogProps) { + const { translate } = useTranslator(); + const [folderName, setFolderName] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const validateForm = () => { + if (!folderName.trim()) { + setError( + translate("folder_name_required", "drive", { + default: "Folder name is required", + }) + ); + return false; + } + setError(""); + return true; + }; + + const handleSubmit = async () => { + if (!validateForm()) return; + + setIsSubmitting(true); + try { + await onCreateFolder(folderName, parentFolder?.folder_id || null); + resetForm(); + onFolderCreated(); + onOpenChange(false); + } catch (error) { + console.error("Error creating folder:", error); + setError( + translate("error_creating_folder", "drive", { + default: "Error creating folder. Please try again.", + }) + ); + } finally { + setIsSubmitting(false); + } + }; + + const resetForm = () => { + setFolderName(""); + setError(""); + }; + + useEffect(() => { + if (open) { + resetForm(); + } + }, [open]); + + return ( + + + + + {translate("create_folder", "drive", { default: "Create Folder" })} + + + {parentFolder + ? translate("create_subfolder_in", "drive", { + default: `Create a new folder in "${parentFolder.name}"`, + folderName: parentFolder.name, + }) + : translate("create_new_folder", "drive", { + default: "Create a new folder in root", + })} + + + +
+ {error && ( +
+ +
{error}
+
+ )} + +
+ + setFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !isSubmitting) { + handleSubmit(); + } + }} + autoFocus + /> +
+
+ + + + + +
+
+ ); +} +``` + +### 3.2 Rename Folder Dialog +**File**: `components/rename-folder-dialog.tsx` + +```typescript +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { AlertCircle } from "lucide-react"; +import { useTranslator } from "@/hooks/use-translations"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface Folder { + folder_id: string; + name: string; + parent_id: string | null; +} + +interface RenameFolderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onFolderRenamed: () => void; + folder: Folder; + onRenameFolder: (folderId: string, newName: string, parentId: string | null) => Promise; +} + +export function RenameFolderDialog({ + open, + onOpenChange, + onFolderRenamed, + folder, + onRenameFolder, +}: RenameFolderDialogProps) { + const { translate } = useTranslator(); + const [folderName, setFolderName] = useState(folder.name); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + setFolderName(folder.name); + }, [folder]); + + const validateForm = () => { + if (!folderName.trim()) { + setError( + translate("folder_name_required", "drive", { + default: "Folder name is required", + }) + ); + return false; + } + setError(""); + return true; + }; + + const handleSubmit = async () => { + if (!validateForm()) return; + + setIsSubmitting(true); + try { + await onRenameFolder(folder.folder_id, folderName, folder.parent_id); + onFolderRenamed(); + onOpenChange(false); + } catch (error) { + console.error("Error renaming folder:", error); + setError( + translate("error_renaming_folder", "drive", { + default: "Error renaming folder. Please try again.", + }) + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + {translate("rename_folder", "drive", { default: "Rename Folder" })} + + + {translate("rename_folder_description", "drive", { + default: `Rename "${folder.name}"`, + folderName: folder.name, + })} + + + +
+ {error && ( +
+ +
{error}
+
+ )} + +
+ + setFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !isSubmitting) { + handleSubmit(); + } + }} + autoFocus + /> +
+
+ + + + + +
+
+ ); +} +``` + +### 3.3 Move Folder Dialog +**File**: `components/move-folder-dialog.tsx` + +```typescript +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { AlertCircle, Folder as FolderIcon, ChevronRight } from "lucide-react"; +import { useTranslator } from "@/hooks/use-translations"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/libs/utils"; + +interface Folder { + folder_id: string; + name: string; + parent_id: string | null; +} + +interface MoveFolderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onFolderMoved: () => void; + selectedFolders: Folder[]; + allFolders: Folder[]; + onMoveFolder: (folderIds: string[], parentId: string) => Promise; +} + +export function MoveFolderDialog({ + open, + onOpenChange, + onFolderMoved, + selectedFolders, + allFolders, + onMoveFolder, +}: MoveFolderDialogProps) { + const { translate } = useTranslator(); + const [selectedDestination, setSelectedDestination] = useState(null); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Filter out folders being moved and their descendants + const availableDestinations = allFolders.filter(folder => { + const selectedIds = selectedFolders.map(f => f.folder_id); + if (selectedIds.includes(folder.folder_id)) return false; + + // Check if this folder is a descendant of any selected folder + let current = folder; + while (current.parent_id) { + if (selectedIds.includes(current.parent_id)) return false; + const parent = allFolders.find(f => f.folder_id === current.parent_id); + if (!parent) break; + current = parent; + } + return true; + }); + + const handleSubmit = async () => { + if (!selectedDestination) { + setError( + translate("select_destination", "drive", { + default: "Please select a destination folder", + }) + ); + return; + } + + setIsSubmitting(true); + try { + await onMoveFolder( + selectedFolders.map(f => f.folder_id), + selectedDestination + ); + onFolderMoved(); + onOpenChange(false); + } catch (error) { + console.error("Error moving folder:", error); + setError( + translate("error_moving_folder", "drive", { + default: "Error moving folder(s). Please try again.", + }) + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + + {translate("move_folder", "drive", { default: "Move Folder" })} + + + {translate("move_folder_description", "drive", { + default: `Moving ${selectedFolders.length} folder(s)`, + count: selectedFolders.length, + })} + + + +
+ {error && ( +
+ +
{error}
+
+ )} + +
+ + +
+ + + {availableDestinations.map((folder) => ( + + ))} +
+
+
+
+ + + + + +
+
+ ); +} +``` + +**Deliverables**: +- ✅ `components/add-folder-dialog.tsx` - Create folder UI +- ✅ `components/rename-folder-dialog.tsx` - Rename folder UI +- ✅ `components/move-folder-dialog.tsx` - Move folder UI with folder tree selection +- ✅ All dialogs follow Shadcn UI patterns +- ✅ Proper validation and error handling +- ✅ Translation support + +--- + +## Phase 4: Main Drive Content Component + +### 4.1 Create Drive Content Component +**File**: `components/drive-content.tsx` + +This is the main UI component that displays the folder grid/list: + +```typescript +"use client"; + +import { useState, useCallback } from "react"; +import { useSelector } from "react-redux"; +import { RootState } from "@/libs/Redux/store"; +import { Button } from "@/components/ui/button"; +import { + Folder as FolderIcon, + Plus, + MoreHorizontal, + ArrowLeft, + Home, + Pencil, + Move, + Trash2, +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useTranslator } from "@/hooks/use-translations"; +import { useDriveManagement } from "@/hooks/use-drive-management"; +import { AddFolderDialog } from "./add-folder-dialog"; +import { RenameFolderDialog } from "./rename-folder-dialog"; +import { MoveFolderDialog } from "./move-folder-dialog"; +import { cn } from "@/libs/utils"; + +interface Folder { + folder_id: string; + name: string; + parent_id: string | null; + created_at: string; + updated_at: string | null; +} + +export function DriveContent() { + const { translate } = useTranslator(); + const selectedWorkspaceId = useSelector( + (state: RootState) => state.workspace.selectedWorkspaceId + ); + const selectedProjectId = useSelector( + (state: RootState) => state.workspace.selectedProjectId + ); + + const { + folders, + isLoading, + createFolder, + renameFolder, + moveFolder, + deleteFolders, + fetchFolders, + currentFolder, + setCurrentFolder, + getFolderPath, + getSubfolders, + } = useDriveManagement({ + selectedWorkspaceId, + selectedProjectId, + }); + + const [showAddFolder, setShowAddFolder] = useState(false); + const [showRenameFolder, setShowRenameFolder] = useState(false); + const [showMoveFolder, setShowMoveFolder] = useState(false); + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + const [selectedFolder, setSelectedFolder] = useState(null); + const [isProcessingDelete, setIsProcessingDelete] = useState(false); + + // Get current subfolders to display + const currentSubfolders = getSubfolders(currentFolder?.folder_id || null); + + // Breadcrumb path + const breadcrumbPath = getFolderPath(currentFolder?.folder_id || null); + + const handleFolderClick = (folder: Folder) => { + setCurrentFolder(folder); + }; + + const handleBackClick = () => { + if (!currentFolder) return; + + if (currentFolder.parent_id) { + const parentFolder = folders.find(f => f.folder_id === currentFolder.parent_id); + setCurrentFolder(parentFolder || null); + } else { + setCurrentFolder(null); + } + }; + + const handleHomeClick = () => { + setCurrentFolder(null); + }; + + const handleCreateFolder = () => { + setShowAddFolder(true); + }; + + const handleRenameFolder = (folder: Folder) => { + setSelectedFolder(folder); + setShowRenameFolder(true); + }; + + const handleMoveFolder = (folder: Folder) => { + setSelectedFolder(folder); + setShowMoveFolder(true); + }; + + const confirmDelete = (folder: Folder) => { + setSelectedFolder(folder); + setShowDeleteConfirmation(true); + }; + + const handleDeleteFolder = async () => { + if (!selectedFolder) return; + + setIsProcessingDelete(true); + try { + await deleteFolders([selectedFolder.folder_id]); + setShowDeleteConfirmation(false); + setSelectedFolder(null); + } finally { + setIsProcessingDelete(false); + } + }; + + return ( +
+ {/* Header */} +
+
+

+ {translate("drive", "drive", { default: "Drive" })} +

+

+ {translate("manage_your_folders", "drive", { + default: "Organize and manage your folders", + })} +

+
+
+ + {/* Breadcrumb and Actions */} +
+
+ + + {breadcrumbPath.map((folder, index) => ( +
+ / + +
+ ))} +
+ +
+ {currentFolder && ( + + )} + +
+
+ + {/* Folders Grid */} +
+ {isLoading ? ( +
+

+ {translate("loading_folders", "drive", { default: "Loading folders..." })} +

+
+ ) : currentSubfolders.length > 0 ? ( +
+ {currentSubfolders.map((folder) => ( +
+
handleFolderClick(folder)} + > + + + {folder.name} + +
+ + {/* Context Menu */} + + + + + + handleFolderClick(folder)}> + {translate("open", "drive", { default: "Open" })} + + handleRenameFolder(folder)}> + + {translate("rename", "drive", { default: "Rename" })} + + handleMoveFolder(folder)}> + + {translate("move", "drive", { default: "Move" })} + + confirmDelete(folder)} + > + + {translate("delete", "drive", { default: "Delete" })} + + + +
+ ))} +
+ ) : ( +
+ +

+ {translate("no_folders", "drive", { + default: "No folders in this location", + })} +

+ +
+ )} +
+ + {/* Dialogs */} + + + {selectedFolder && ( + <> + + + + + )} + + {/* Delete Confirmation */} + + + + + {translate("confirm_delete", "drive", { default: "Confirm deletion" })} + + + {translate("delete_folder_confirmation", "drive", { + default: `Are you sure you want to delete "${selectedFolder?.name}"? This action cannot be undone.`, + folderName: selectedFolder?.name, + })} + + + + + {translate("cancel", "drive", { default: "Cancel" })} + + { + e.preventDefault(); + handleDeleteFolder(); + }} + disabled={isProcessingDelete} + className="bg-destructive hover:bg-destructive/90" + > + {isProcessingDelete + ? translate("deleting", "drive", { default: "Deleting..." }) + : translate("delete", "drive", { default: "Delete" })} + + + + +
+ ); +} +``` + +**Deliverables**: +- ✅ `components/drive-content.tsx` - Main drive UI component +- ✅ Folder grid/list view +- ✅ Breadcrumb navigation +- ✅ Context menu for folder actions +- ✅ Double-click to open folders +- ✅ Empty state handling +- ✅ Loading state + +--- + +## Phase 5: Dashboard Page Integration + +### 5.1 Create Drive Dashboard Page +**File**: `app/[locale]/dashboard/drive/page.tsx` + +Follow the exact pattern from `emails/page.tsx`: + +```typescript +import { DashboardLayout } from "@/components/dashboard-layout"; +import { DriveContent } from "@/components/drive-content"; + +export default async function DrivePage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + + return ( + + + + ); +} +``` + +### 5.2 Update Navigation (if needed) + +Check if `components/sidebar-nav.tsx` needs to be updated to include the Drive link: + +```typescript +// Add this to the navigation items +{ + name: translate("drive", "navigation", { default: "Drive" }), + href: "/dashboard/drive", + icon: , +} +``` + +**Deliverables**: +- ✅ `app/[locale]/dashboard/drive/page.tsx` - Drive dashboard page +- ✅ Integration with existing navigation (if applicable) +- ✅ Proper locale handling + +--- + +## Phase 6: Polish & Enhancements + +### 6.1 Add Keyboard Shortcuts + +Update `components/drive-content.tsx` to add keyboard shortcuts: + +```typescript +useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Cmd/Ctrl + N to create new folder + if ((e.metaKey || e.ctrlKey) && e.key === "n") { + e.preventDefault(); + setShowAddFolder(true); + } + + // Backspace to go back + if (e.key === "Backspace" && currentFolder && !isDialogOpen()) { + e.preventDefault(); + handleBackClick(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); +}, [currentFolder]); +``` + +### 6.2 Add Animations (Optional) + +Install and use Framer Motion for smooth transitions: + +```typescript +import { motion } from "framer-motion"; + +// Wrap folder grid items + + {/* Folder content */} + +``` + +### 6.3 Add Folder Count & Metadata + +Show folder metadata (subfolder count, created date): + +```typescript +
+ {getSubfolders(folder.folder_id).length} items +
+``` + +### 6.4 Add Search/Filter (Optional) + +Add search functionality similar to emails: + +```typescript +const [searchQuery, setSearchQuery] = useState(""); + +const filteredFolders = currentSubfolders.filter(folder => + folder.name.toLowerCase().includes(searchQuery.toLowerCase()) +); +``` + +**Deliverables**: +- ✅ Keyboard shortcuts for common actions +- ✅ Smooth animations (optional) +- ✅ Folder metadata display +- ✅ Search/filter capability (optional) + +--- + +## Testing Checklist + +### Functional Testing +- [ ] Create folder in root +- [ ] Create subfolder inside another folder +- [ ] Rename folder +- [ ] Move folder to different parent +- [ ] Move folder to root +- [ ] Delete folder +- [ ] Navigate folder hierarchy with breadcrumbs +- [ ] Navigate back button +- [ ] Double-click to open folder +- [ ] Context menu actions work correctly + +### Error Handling +- [ ] Network errors show proper toast notifications +- [ ] Validation errors prevent form submission +- [ ] Cannot move folder into itself +- [ ] Cannot move folder into its descendant +- [ ] Loading states display correctly + +### UI/UX Testing +- [ ] Responsive design works on mobile +- [ ] Dialogs are accessible (keyboard navigation) +- [ ] Empty states display properly +- [ ] Loading states don't flicker +- [ ] Breadcrumb navigation is clear +- [ ] Folder grid scales properly + +### Integration Testing +- [ ] Works with workspace/project selection from Redux +- [ ] Access token is sent with all requests +- [ ] Translations work in different locales +- [ ] Toast notifications appear correctly +- [ ] Navigation integrates with sidebar (if applicable) + +--- + +## File Structure Summary + +``` +packages/frontend-web/ +├── app/ +│ └── [locale]/ +│ └── dashboard/ +│ └── drive/ +│ └── page.tsx # Main page route +├── components/ +│ ├── add-folder-dialog.tsx # Create folder dialog +│ ├── rename-folder-dialog.tsx # Rename folder dialog +│ ├── move-folder-dialog.tsx # Move folder dialog +│ └── drive-content.tsx # Main drive UI component +└── hooks/ + └── use-drive-management.ts # Drive state & API logic +``` + +--- + +## Translation Keys to Add + +Add these keys to `messages/en.json` (and other locales): + +```json +{ + "drive": { + "drive": "Drive", + "manage_your_folders": "Organize and manage your folders", + "create_folder": "Create Folder", + "new_folder": "New Folder", + "folder_name": "Folder Name", + "folder_name_placeholder": "My Folder", + "folder_name_required": "Folder name is required", + "rename_folder": "Rename Folder", + "move_folder": "Move Folder", + "delete_folder": "Delete Folder", + "no_folders": "No folders in this location", + "loading_folders": "Loading folders...", + "root": "Root", + "back": "Back", + "open": "Open", + "rename": "Rename", + "move": "Move", + "delete": "Delete", + "cancel": "Cancel", + "create": "Create", + "creating": "Creating...", + "renaming": "Renaming...", + "moving": "Moving...", + "deleting": "Deleting...", + "success": "Success", + "error": "Error", + "folder_created": "Folder created successfully", + "folder_renamed": "Folder renamed successfully", + "folder_moved": "Folder(s) moved successfully", + "folder_deleted": "Folder(s) deleted successfully", + "error_creating_folder": "Failed to create folder", + "error_renaming_folder": "Failed to rename folder", + "error_moving_folder": "Failed to move folder(s)", + "error_deleting_folder": "Failed to delete folder(s)", + "error_fetching_folders": "Failed to fetch folders", + "confirm_delete": "Confirm deletion", + "delete_folder_confirmation": "Are you sure you want to delete this folder? This action cannot be undone.", + "select_destination": "Please select a destination folder", + "destination_folder": "Select destination folder", + "root_folder": "Root", + "create_new_folder": "Create a new folder in root", + "create_subfolder_in": "Create a new folder in {folderName}", + "rename_folder_description": "Rename {folderName}", + "move_folder_description": "Moving {count} folder(s)", + "missing_workspace_project": "Missing workspace or project" + } +} +``` + +--- + +## API Integration Notes + +### Missing GET Endpoint +Currently, there's no GET endpoint for fetching folders. The implementation assumes: + +**Recommended endpoint**: `GET /api/v1/web/drive/folder/list` + +**Query parameters**: +- `workspace_id`: string +- `project_id`: string + +**Response**: +```json +{ + "data": [ + { + "folder_id": "string", + "name": "string", + "parent_id": "string | null", + "created_at": "string", + "updated_at": "string", + "created_by": "string", + "workspace_id": "string", + "project_id": "string" + } + ] +} +``` + +Once this endpoint is available, update `hooks/use-drive-management.ts` in the `fetchFolders` function. + +--- + +## Next Steps & Future Enhancements + +### Immediate Next Steps (After Phase 6) +1. Add file upload/download capabilities +2. Add file listing within folders +3. Add folder sharing/permissions +4. Add bulk operations (multi-select) + +### Future Enhancements +1. **Drag & Drop**: Drag folders to move them +2. **Grid/List Toggle**: Switch between grid and list views +3. **Sorting**: Sort by name, date, size +4. **Favorites**: Star/favorite folders for quick access +5. **Recent Items**: Show recently accessed folders +6. **Folder Colors/Icons**: Customize folder appearance +7. **Breadcrumb Dropdown**: Click breadcrumb to see siblings +8. **Quick Actions**: Right-click context menu +9. **Keyboard Navigation**: Arrow keys to navigate grid +10. **Undo/Redo**: Undo delete/move operations + +--- + +## Development Tips + +1. **Start with Phase 1-2**: Get the hook working with API integration first +2. **Test with Mock Data**: Use mock folder data while developing UI +3. **Progressive Enhancement**: Build basic functionality first, add polish later +4. **Follow Patterns**: Stick closely to existing component patterns (emails, cards) +5. **Use Existing Components**: Leverage Shadcn UI components already in the project +6. **Translations**: Add translation keys as you build each component +7. **Error Handling**: Always handle loading and error states +8. **Responsive Design**: Test on different screen sizes as you build + +--- + +## Known Limitations + +1. **No File Support Yet**: This implementation only handles folders +2. **No Permissions**: No folder-level permissions or sharing +3. **No Search**: Search functionality is optional in Phase 6 +4. **No Bulk Operations**: Can only operate on one folder at a time (except delete) +5. **No Drag & Drop**: Manual move operation required +6. **No Sorting**: Folders displayed in API order +7. **Single Selection**: Cannot select multiple folders for bulk operations + +--- + +## Conclusion + +This implementation plan provides a comprehensive, phase-by-phase approach to building the Drive feature. Each phase builds upon the previous one, following the established patterns in the codebase. The implementation prioritizes: + +- **Consistency**: Follows existing code patterns +- **Maintainability**: Clean separation of concerns +- **User Experience**: Intuitive navigation and actions +- **Scalability**: Ready for future enhancements + +Follow each phase sequentially, test thoroughly, and refer back to this document as your single source of truth for the Drive implementation. + diff --git a/packages/frontend-web/app/[locale]/dashboard/drive/page.tsx b/packages/frontend-web/app/[locale]/dashboard/drive/page.tsx new file mode 100644 index 00000000..474ae595 --- /dev/null +++ b/packages/frontend-web/app/[locale]/dashboard/drive/page.tsx @@ -0,0 +1,13 @@ +import { DashboardLayout } from "@/components/dashboard-layout"; +import { DriveContent } from "@/components/drive-content"; + +export default async function DrivePage({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params; + + return ( + + + + ); +} + diff --git a/packages/frontend-web/components/add-folder-dialog.tsx b/packages/frontend-web/components/add-folder-dialog.tsx new file mode 100644 index 00000000..7f8cac0b --- /dev/null +++ b/packages/frontend-web/components/add-folder-dialog.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { AlertCircle } from "lucide-react"; +import { useTranslator } from "@/hooks/use-translations"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +interface AddFolderDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onFolderCreated: () => void; + parentFolder: { folder_id: string; name: string } | null; + onCreateFolder: (name: string, parentId: string | null) => Promise; +} + +export function AddFolderDialog({ + open, + onOpenChange, + onFolderCreated, + parentFolder, + onCreateFolder, +}: AddFolderDialogProps) { + const { translate } = useTranslator(); + const [folderName, setFolderName] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + const validateForm = () => { + if (!folderName.trim()) { + setError( + translate("folder_name_required", "drive", { + default: "Folder name is required", + }) + ); + return false; + } + setError(""); + return true; + }; + + const handleSubmit = async () => { + if (!validateForm()) return; + + setIsSubmitting(true); + try { + await onCreateFolder(folderName, parentFolder?.folder_id || null); + resetForm(); + onFolderCreated(); + onOpenChange(false); + } catch (error) { + console.error("Error creating folder:", error); + setError( + translate("error_creating_folder", "drive", { + default: "Error creating folder. Please try again.", + }) + ); + } finally { + setIsSubmitting(false); + } + }; + + const resetForm = () => { + setFolderName(""); + setError(""); + }; + + useEffect(() => { + if (open) { + resetForm(); + } + }, [open]); + + return ( + + + + + {translate("create_folder", "drive", { default: "Create Folder" })} + + + {parentFolder + ? translate("create_subfolder_in", "drive", { + default: `Create a new folder in "${parentFolder.name}"`, + folderName: parentFolder.name, + }) + : translate("create_new_folder", "drive", { + default: "Create a new folder in root", + })} + + + +
+ {error && ( +
+ +
{error}
+
+ )} + +
+ + setFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !isSubmitting) { + handleSubmit(); + } + }} + autoFocus + /> +
+
+ + + + + +
+
+ ); +} + diff --git a/packages/frontend-web/components/dashboard-layout.tsx b/packages/frontend-web/components/dashboard-layout.tsx index 745a765a..6a071dfb 100644 --- a/packages/frontend-web/components/dashboard-layout.tsx +++ b/packages/frontend-web/components/dashboard-layout.tsx @@ -260,7 +260,7 @@ export function DashboardLayout({ children, locale = "en" }: DashboardLayoutProp const [currentLocale, setCurrentLocale] = useState(locale); const languageLabels: Record = { - af: "Afrikaans", + af: "Afrikaans (Afrikaans)", ar: "Arabic (عربى)", ca: "Catalan (Català)", cs: "Czech (Čeština)", diff --git a/packages/frontend-web/components/delete-file-dialog.tsx b/packages/frontend-web/components/delete-file-dialog.tsx new file mode 100644 index 00000000..75fbf6ba --- /dev/null +++ b/packages/frontend-web/components/delete-file-dialog.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useTranslator } from "@/hooks/use-translations"; + +interface DriveFile { + file_id: string; + name: string; +} + +interface DeleteFileDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + file: DriveFile | null; + onDelete: (fileIds: string[]) => Promise; +} + +export function DeleteFileDialog({ + open, + onOpenChange, + file, + onDelete, +}: DeleteFileDialogProps) { + const { translate } = useTranslator(); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async () => { + if (!file) return; + + setIsDeleting(true); + + try { + await onDelete([file.file_id]); + onOpenChange(false); + } catch (error) { + console.error("Error deleting file:", error); + } finally { + setIsDeleting(false); + } + }; + + return ( + + + + + {translate("delete_file", "drive", { default: "Delete File" })} + + + {file && ( + <> + {translate("delete_file_confirmation", "drive", { + default: "Are you sure you want to delete this file? This action cannot be undone.", + })} +
+
+ {file.name} + + )} +
+
+ + + {translate("cancel", "actions", { default: "Cancel" })} + + { + e.preventDefault(); + handleDelete(); + }} + disabled={isDeleting} + className="bg-destructive hover:bg-destructive/90" + > + {isDeleting + ? translate("deleting", "drive", { default: "Deleting..." }) + : translate("delete", "actions", { default: "Delete" })} + + +
+
+ ); +} + diff --git a/packages/frontend-web/components/drive-content.tsx b/packages/frontend-web/components/drive-content.tsx new file mode 100644 index 00000000..e5b68d10 --- /dev/null +++ b/packages/frontend-web/components/drive-content.tsx @@ -0,0 +1,549 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { useSelector } from "react-redux"; +import { RootState } from "@/libs/Redux/store"; +import { Button } from "@/components/ui/button"; +import { + Folder as FolderIcon, + Plus, + MoreHorizontal, + ArrowLeft, + Home, + Pencil, + Move, + Trash2, + Upload as UploadIcon, + Download, +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useTranslator } from "@/hooks/use-translations"; +import { useDriveManagement } from "@/hooks/use-drive-management"; +import { AddFolderDialog } from "./add-folder-dialog"; +import { RenameFolderDialog } from "./rename-folder-dialog"; +import { MoveFolderDialog } from "./move-folder-dialog"; +import { UploadFileDialog } from "./upload-file-dialog"; +import { FileCard } from "./file-card"; +import { RenameFileDialog } from "./rename-file-dialog"; +import { MoveFileDialog } from "./move-file-dialog"; +import { DeleteFileDialog } from "./delete-file-dialog"; +import { FilePreviewModal } from "./file-preview-modal"; + +interface Folder { + folder_id: string; + name: string; + parent_id: string | null; + created_at: string; + updated_at: string | null; +} + +interface DriveFile { + file_id: string; + name: string; + size: number; + original_size: number; + type: string; + iv: string; + parent_id: string | null; + created_at: string; + updated_at: string | null; + created_by: string; + workspace_id: string; + project_id: string; +} + +export function DriveContent() { + const { translate } = useTranslator(); + const selectedWorkspaceId = useSelector( + (state: RootState) => state.workspace.selectedWorkspaceId + ); + const selectedProjectId = useSelector( + (state: RootState) => state.workspace.selectedProjectId + ); + + const { + folders, + files, + isLoading, + createFolder, + renameFolder, + moveFolder, + deleteFolders, + fetchFolders, + currentFolder, + setCurrentFolder, + getFolderPath, + getSubfolders, + uploadFile, + renameFile, + moveFile, + deleteFiles, + downloadFile, + downloadFolder, + previewFile, + isUploading, + uploadProgress, + isDownloading, + downloadProgress, + } = useDriveManagement({ + selectedWorkspaceId, + selectedProjectId, + }); + + const [showAddFolder, setShowAddFolder] = useState(false); + const [showRenameFolder, setShowRenameFolder] = useState(false); + const [showMoveFolder, setShowMoveFolder] = useState(false); + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + const [selectedFolder, setSelectedFolder] = useState(null); + const [isProcessingDelete, setIsProcessingDelete] = useState(false); + + // File dialog states + const [showUploadFile, setShowUploadFile] = useState(false); + const [showRenameFile, setShowRenameFile] = useState(false); + const [showMoveFile, setShowMoveFile] = useState(false); + const [showDeleteFile, setShowDeleteFile] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + + // Preview states + const [showPreviewFile, setShowPreviewFile] = useState(false); + const [previewBlobUrl, setPreviewBlobUrl] = useState(null); + const [previewingFile, setPreviewingFile] = useState(null); + + // Get current subfolders to display + const currentSubfolders = getSubfolders(currentFolder?.folder_id || null); + + // Get current files to display + const currentFiles = files.filter(f => f.parent_id === (currentFolder?.folder_id || null)); + + // Breadcrumb path + const breadcrumbPath = getFolderPath(currentFolder?.folder_id || null); + + const handleFolderClick = (folder: Folder) => { + setCurrentFolder(folder); + }; + + const handleBackClick = () => { + if (!currentFolder) return; + + if (currentFolder.parent_id) { + const parentFolder = folders.find(f => f.folder_id === currentFolder.parent_id); + setCurrentFolder(parentFolder || null); + } else { + setCurrentFolder(null); + } + }; + + const handleHomeClick = () => { + setCurrentFolder(null); + }; + + const handleCreateFolder = () => { + setShowAddFolder(true); + }; + + const handleRenameFolder = (folder: Folder) => { + setSelectedFolder(folder); + setShowRenameFolder(true); + }; + + const handleMoveFolder = (folder: Folder) => { + setSelectedFolder(folder); + setShowMoveFolder(true); + }; + + const confirmDelete = (folder: Folder) => { + setSelectedFolder(folder); + setShowDeleteConfirmation(true); + }; + + const handleDeleteFolder = async () => { + if (!selectedFolder) return; + + setIsProcessingDelete(true); + try { + await deleteFolders([selectedFolder.folder_id]); + setShowDeleteConfirmation(false); + setSelectedFolder(null); + } finally { + setIsProcessingDelete(false); + } + }; + + // File handlers + const handleUploadFile = () => { + setShowUploadFile(true); + }; + + const handleRenameFile = (file: DriveFile) => { + setSelectedFile(file); + setShowRenameFile(true); + }; + + const handleMoveFile = (file: DriveFile) => { + setSelectedFile(file); + setShowMoveFile(true); + }; + + const handleDeleteFile = (file: DriveFile) => { + setSelectedFile(file); + setShowDeleteFile(true); + }; + + const handleDownloadFile = async (file: DriveFile) => { + await downloadFile(file.file_id); + }; + + const handleDownloadFolder = async (folder: Folder) => { + await downloadFolder(folder.folder_id); + }; + + const handlePreviewFile = async (file: DriveFile) => { + setPreviewingFile(file); + setShowPreviewFile(true); + const result = await previewFile(file.file_id); + if (result) { + setPreviewBlobUrl(result.blobUrl); + } + }; + + const handlePreviewClose = (open: boolean) => { + if (!open && previewBlobUrl) { + URL.revokeObjectURL(previewBlobUrl); + setPreviewBlobUrl(null); + } + setShowPreviewFile(open); + if (!open) { + setPreviewingFile(null); + } + }; + + const handleDownloadFromPreview = () => { + if (previewingFile) { + downloadFile(previewingFile.file_id); + } + }; + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Check if any dialog is open or input is focused + const isDialogOpen = showAddFolder || showRenameFolder || showMoveFolder || showDeleteConfirmation; + const isInputFocused = document.activeElement?.tagName === 'INPUT' || + document.activeElement?.tagName === 'TEXTAREA'; + + if (isDialogOpen || isInputFocused) return; + + // Cmd/Ctrl + N to create new folder + if ((e.metaKey || e.ctrlKey) && e.key === "n") { + e.preventDefault(); + setShowAddFolder(true); + } + + // Backspace to go back + if (e.key === "Backspace" && currentFolder) { + e.preventDefault(); + handleBackClick(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [currentFolder, showAddFolder, showRenameFolder, showMoveFolder, showDeleteConfirmation]); + + return ( +
+ {/* Header */} +
+
+

+ {translate("drive", "drive", { default: "Drive" })} +

+

+ {translate("manage_your_folders", "drive", { + default: "Organize and manage your folders", + })} +

+
+
+ + {/* Breadcrumb and Actions */} +
+
+ + + {breadcrumbPath.map((folder) => ( +
+ / + +
+ ))} +
+ +
+ {currentFolder && ( + + )} + + +
+
+ + {/* Folders and Files Grid */} +
+ {isLoading ? ( +
+

+ {translate("loading", "drive", { default: "Loading..." })} +

+
+ ) : currentSubfolders.length > 0 || currentFiles.length > 0 ? ( +
+ {/* Folders */} + {currentSubfolders.map((folder) => ( +
+
handleFolderClick(folder)} + > + + + {folder.name} + +
+ + {/* Context Menu */} + + + + + + handleFolderClick(folder)}> + {translate("open", "drive", { default: "Open" })} + + handleRenameFolder(folder)}> + + {translate("rename", "drive", { default: "Rename" })} + + handleMoveFolder(folder)}> + + {translate("move", "drive", { default: "Move" })} + + handleDownloadFolder(folder)}> + + {translate("download", "drive", { default: "Download" })} + + confirmDelete(folder)} + > + + {translate("delete", "drive", { default: "Delete" })} + + + +
+ ))} + + {/* Files */} + {currentFiles.map((file) => ( + + ))} +
+ ) : ( +
+ +

+ {translate("no_items", "drive", { + default: "No items in this location", + })} +

+
+ + +
+
+ )} +
+ + {/* Folder Dialogs */} + + + {selectedFolder && ( + <> + + + + + )} + + {/* File Dialogs */} + + + {selectedFile && ( + <> + + + + + + + )} + + {/* File Preview Modal */} + + + {/* Delete Confirmation */} + + + + + {translate("confirm_delete", "drive", { default: "Confirm deletion" })} + + + {translate("delete_folder_confirmation", "drive", { + default: `Are you sure you want to delete "${selectedFolder?.name}"? This action cannot be undone.`, + folderName: selectedFolder?.name, + })} + + + + + {translate("cancel", "drive", { default: "Cancel" })} + + { + e.preventDefault(); + handleDeleteFolder(); + }} + disabled={isProcessingDelete} + className="bg-destructive hover:bg-destructive/90" + > + {isProcessingDelete + ? translate("deleting", "drive", { default: "Deleting..." }) + : translate("delete", "drive", { default: "Delete" })} + + + + +
+ ); +} + diff --git a/packages/frontend-web/components/file-card.tsx b/packages/frontend-web/components/file-card.tsx new file mode 100644 index 00000000..4b2a5a9e --- /dev/null +++ b/packages/frontend-web/components/file-card.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { useState } from "react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { MoreVertical, Edit2, Move, Trash2, Download, Eye } from "lucide-react"; +import { getFileIcon, getFileIconColor } from "@/libs/file-icon-mappings"; +import { formatBytes } from "@/libs/utils"; +import { formatDate } from "@/libs/utils"; +import { useTranslator } from "@/hooks/use-translations"; + +interface DriveFile { + file_id: string; + name: string; + size: number; + original_size: number; + type: string; + created_at: string; + updated_at: string | null; +} + +interface FileCardProps { + file: DriveFile; + onRename: (file: DriveFile) => void; + onMove: (file: DriveFile) => void; + onDelete: (file: DriveFile) => void; + onDownload: (file: DriveFile) => void; + onPreview: (file: DriveFile) => void; +} + +export function FileCard({ file, onRename, onMove, onDelete, onDownload, onPreview }: FileCardProps) { + const { translate } = useTranslator(); + const [showMenu, setShowMenu] = useState(false); + + const FileIcon = getFileIcon(file.type); + const iconColor = getFileIconColor(file.type); + + return ( +
onPreview(file)} + > +
+ {/* File icon */} +
+ +
+ + {/* File name */} +
+

+ {file.name} +

+

+ {formatBytes(file.original_size)} +

+

+ {formatDate(file.created_at)} +

+
+ + {/* Actions menu */} +
+ + + + + + { + onRename(file); + setShowMenu(false); + }} + > + + {translate("rename", "actions", { default: "Rename" })} + + { + onMove(file); + setShowMenu(false); + }} + > + + {translate("move", "actions", { default: "Move" })} + + { + onPreview(file); + setShowMenu(false); + }} + > + + {translate("preview", "actions", { default: "Preview" })} + + { + onDownload(file); + setShowMenu(false); + }} + > + + {translate("download", "actions", { default: "Download" })} + + + { + onDelete(file); + setShowMenu(false); + }} + className="text-destructive focus:text-destructive" + > + + {translate("delete", "actions", { default: "Delete" })} + + + +
+
+
+ ); +} + diff --git a/packages/frontend-web/components/file-preview-modal.tsx b/packages/frontend-web/components/file-preview-modal.tsx new file mode 100644 index 00000000..f8abe59c --- /dev/null +++ b/packages/frontend-web/components/file-preview-modal.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Download, FileX, Loader2 } from "lucide-react"; +import { useTranslator } from "@/hooks/use-translations"; +import { getPreviewType, getModalSizeClass } from "@/libs/file-type-utils"; +import { formatBytes } from "@/libs/utils"; +import { cn } from "@/libs/utils"; + +interface DriveFile { + file_id: string; + name: string; + size: number; + original_size: number; + type: string; + iv: string; + parent_id: string | null; + created_at: string; + updated_at: string | null; +} + +interface FilePreviewModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + file: DriveFile | null; + blobUrl: string | null; + onDownload: () => void; +} + +export function FilePreviewModal({ + open, + onOpenChange, + file, + blobUrl, + onDownload, +}: FilePreviewModalProps) { + const { translate } = useTranslator(); + const [textContent, setTextContent] = useState(""); + const [isLoadingText, setIsLoadingText] = useState(false); + + const previewType = file ? getPreviewType(file.type, file.name) : 'unsupported'; + const modalSizeClass = getModalSizeClass(previewType); + + // Load text content for text files + useEffect(() => { + if (previewType === 'text' && blobUrl && open) { + setIsLoadingText(true); + fetch(blobUrl) + .then(response => response.text()) + .then(text => { + setTextContent(text); + setIsLoadingText(false); + }) + .catch(error => { + console.error("Error loading text content:", error); + setTextContent("Error loading file content"); + setIsLoadingText(false); + }); + } + }, [previewType, blobUrl, open]); + + // Cleanup: revoke blob URL when modal closes + useEffect(() => { + return () => { + if (blobUrl) { + URL.revokeObjectURL(blobUrl); + } + }; + }, [blobUrl]); + + const renderPreview = () => { + if (!blobUrl || !file) { + return ( +
+ +
+ ); + } + + switch (previewType) { + case 'image': + return ( +
+ {file.name} +
+ ); + + case 'pdf': + return ( +
+