diff --git a/apps/Backend/src/routes/cloud-storage.ts b/apps/Backend/src/routes/cloud-storage.ts index 097a001..171dfb9 100644 --- a/apps/Backend/src/routes/cloud-storage.ts +++ b/apps/Backend/src/routes/cloud-storage.ts @@ -1,7 +1,450 @@ -import { Router } from "express"; +// src/routes/cloudStorage.routes.ts +import express, { Request, Response } from "express"; +// import storage from "../storage"; +import { cloudStorage as storage } from "../storage/cloudStorage-storage"; +import { serializeFile } from "../utils/prismaFileUtils"; +import { CloudFolder } from "@repo/db/types"; -const router = Router(); +const router = express.Router(); +/* ---------- Helpers ---------- */ +function parsePositiveInt(v: unknown, fallback: number) { + const n = Number(v); + if (!Number.isFinite(n) || n < 0) return fallback; + return Math.floor(n); +} + +function sendError( + res: Response, + status: number, + message: string, + details?: any +) { + return res.status(status).json({ error: true, message, details }); +} + +/* ---------- Paginated child FOLDERS for a parent ---------- + GET /items/folders?parentId=&limit=&offset= + parentId may be "null" or numeric or absent (means root) +*/ +router.get( + "/items/folders", + async (req: Request, res: Response): Promise => { + const rawParent = req.query.parentId; + const parentId = + rawParent === undefined + ? null + : rawParent === "null" + ? null + : Number(rawParent); + + if (parentId !== null && (!Number.isInteger(parentId) || parentId <= 0)) { + return sendError(res, 400, "Invalid parentId"); + } + + const limit = parsePositiveInt(req.query.limit, 10); // default 10 folders/page + const offset = parsePositiveInt(req.query.offset, 0); + + try { + // Prefer a storage method that lists folders by parent, otherwise filter + let data: CloudFolder[] = []; + if (typeof (storage as any).listFoldersByParent === "function") { + data = await (storage as any).listFoldersByParent( + parentId, + limit, + offset + ); + const total = + (await (storage as any).countFoldersByParent?.(parentId)) ?? + data.length; + return res.json({ error: false, data, total, limit, offset }); + } + + // Fallback: use recent and filter (less efficient). Recommend implementing listFoldersByParent in storage. + const recent = await storage.listRecentFolders(1000, 0); + const folders = (recent || []).filter( + (f: any) => (f as any).parentId === parentId + ); + const paged = folders.slice(offset, offset + limit); + return res.json({ + error: false, + data: paged, + total: folders.length, + limit, + offset, + }); + } catch (err) { + return sendError(res, 500, "Failed to load child folders", err); + } + } +); + +/* ---------- Paginated files for a folder ---------- + GET /items/files?parentId=&limit=&offset= + parentId may be "null" or numeric or absent (means root) +*/ +router.get( + "/items/files", + async (req: Request, res: Response): Promise => { + const rawParent = req.query.parentId; + const parentId = + rawParent === undefined + ? null + : rawParent === "null" + ? null + : Number(rawParent); + + if (parentId !== null && (!Number.isInteger(parentId) || parentId <= 0)) { + return sendError(res, 400, "Invalid parentId"); + } + + const limit = parsePositiveInt(req.query.limit, 20); // default 20 files/page + const offset = parsePositiveInt(req.query.offset, 0); + + try { + const files = await storage.listFilesInFolder(parentId, limit, offset); + const total = await storage.countFilesInFolder(parentId); + const serialized = files.map(serializeFile); + return res.json({ error: false, data: serialized, total, limit, offset }); + } catch (err) { + return sendError(res, 500, "Failed to load files for folder", err); + } + } +); + +/* ---------- Recent folders (global) ---------- + GET /folders/recent?limit=&offset= +*/ +router.get( + "/folders/recent", + async (req: Request, res: Response): Promise => { + const limit = parsePositiveInt(req.query.limit, 50); + const offset = parsePositiveInt(req.query.offset, 0); + try { + const folders = await storage.listRecentFolders(limit, offset); + const total = await storage.countFolders(); + return res.json({ error: false, data: folders, total, limit, offset }); + } catch (err) { + return sendError(res, 500, "Failed to load recent folders"); + } + } +); + +/* ---------- Folder CRUD ---------- + POST /folders { userId, name, parentId? } + PUT /folders/:id { name?, parentId? } + DELETE /folders/:id +*/ +router.post("/folders", async (req: Request, res: Response): Promise => { + const { userId, name, parentId } = req.body; + if (!userId || typeof name !== "string" || !name.trim()) { + return sendError(res, 400, "Missing or invalid userId/name"); + } + try { + const created = await storage.createFolder( + userId, + name.trim(), + parentId ?? null + ); + return res.status(201).json({ error: false, data: created }); + } catch (err) { + return sendError(res, 500, "Failed to create folder"); + } +}); + +router.put( + "/folders/:id", + async (req: Request, res: Response): Promise => { + // coerce possibly-undefined param to string before parsing + const id = Number.parseInt(req.params.id ?? "", 10); + if (!Number.isInteger(id) || id <= 0) + return sendError(res, 400, "Invalid folder id"); + + const updates: any = {}; + if (typeof req.body.name === "string") updates.name = req.body.name.trim(); + if (req.body.parentId !== undefined) updates.parentId = req.body.parentId; + + try { + const updated = await storage.updateFolder(id, updates); + if (!updated) + return sendError(res, 404, "Folder not found or update failed"); + return res.json({ error: false, data: updated }); + } catch (err) { + return sendError(res, 500, "Failed to update folder"); + } + } +); + +router.delete( + "/folders/:id", + async (req: Request, res: Response): Promise => { + const id = Number.parseInt(req.params.id ?? "", 10); + if (!Number.isInteger(id) || id <= 0) + return sendError(res, 400, "Invalid folder id"); + try { + const ok = await storage.deleteFolder(id); + if (!ok) return sendError(res, 404, "Folder not found or delete failed"); + return res.json({ error: false, data: { id } }); + } catch (err) { + return sendError(res, 500, "Failed to delete folder"); + } + } +); + +/* ---------- Files inside folder (pagination) ---------- + GET /folders/:id/files?limit=&offset= + id = "null" lists files with folderId = null + responses serialized +*/ +router.get( + "/folders/:id/files", + async (req: Request, res: Response): Promise => { + const rawId = req.params.id; + const folderId = rawId === "null" ? null : Number.parseInt(rawId ?? "", 10); + if (folderId !== null && (!Number.isInteger(folderId) || folderId <= 0)) { + return sendError(res, 400, "Invalid folder id"); + } + + const limit = parsePositiveInt(req.query.limit, 50); + const offset = parsePositiveInt(req.query.offset, 0); + + try { + const files = await storage.listFilesInFolder(folderId, limit, offset); + const total = await storage.countFilesInFolder(folderId); + const serialized = files.map(serializeFile); + return res.json({ error: false, data: serialized, total, limit, offset }); + } catch (err) { + return sendError(res, 500, "Failed to list files for folder"); + } + } +); + +/* ---------- File CRUD (init, update metadata, delete) ---------- + POST /folders/:id/files { userId, name, mimeType?, expectedSize?, totalChunks? } + PUT /files/:id { name?, mimeType?, folderId? } + DELETE /files/:id +*/ +router.post( + "/folders/:id/files", + async (req: Request, res: Response): Promise => { + const rawId = req.params.id; + const folderId = rawId === "null" ? null : Number.parseInt(rawId ?? "", 10); + if (folderId !== null && (!Number.isInteger(folderId) || folderId <= 0)) { + return sendError(res, 400, "Invalid folder id"); + } + + const { userId, name, mimeType } = req.body; + if (!userId || typeof name !== "string" || !name.trim()) { + return sendError(res, 400, "Missing or invalid userId/name"); + } + + // coerce size & chunks + let expectedSize: bigint | null = null; + if (req.body.expectedSize != null) { + try { + expectedSize = BigInt(String(req.body.expectedSize)); + } catch { + return sendError(res, 400, "Invalid expectedSize"); + } + } + let totalChunks: number | null = null; + if (req.body.totalChunks != null) { + const tc = Number(req.body.totalChunks); + if (!Number.isFinite(tc) || tc <= 0) + return sendError(res, 400, "Invalid totalChunks"); + totalChunks = Math.floor(tc); + } + + try { + const created = await storage.initializeFileUpload( + userId, + name.trim(), + mimeType ?? null, + expectedSize, + totalChunks, + folderId + ); + return res + .status(201) + .json({ error: false, data: serializeFile(created as any) }); + } catch { + return sendError(res, 500, "Failed to create file"); + } + } +); + +/* ---------- 2. CHUNKS (raw upload) ---------- */ +router.post( + "/files/:id/chunks", + // only here: use express.raw so req.body is Buffer + express.raw({ type: () => true, limit: "100mb" }), + async (req: Request, res: Response): Promise => { + const id = Number.parseInt(req.params.id ?? "", 10); + const seq = Number.parseInt( + String(req.query.seq ?? req.body.seq ?? ""), + 10 + ); + if (!Number.isInteger(id) || id <= 0) + return sendError(res, 400, "Invalid file id"); + if (!Number.isInteger(seq) || seq < 0) + return sendError(res, 400, "Invalid seq"); + + const body = req.body as Buffer; + console.log( + `[chunk upload] fileId=${id} seq=${seq} contentType=${String(req.headers["content-type"])} bodyIsBuffer=${Buffer.isBuffer(body)} bodyLength=${body?.length ?? 0}` + ); + + if (!body || !(body instanceof Buffer)) { + return sendError(res, 400, "Expected raw binary body (Buffer)"); + } + + try { + await storage.appendFileChunk(id, seq, body); + return res.json({ error: false, data: { fileId: id, seq } }); + } catch (err: any) { + console.error( + "[chunk upload] appendFileChunk failed:", + err && (err.stack || err.message || err) + ); + + return sendError(res, 500, "Failed to add chunk"); + } + } +); + +/* ---------- 3. COMPLETE ---------- */ +router.post( + "/files/:id/complete", + async (req: Request, res: Response): Promise => { + const id = Number.parseInt(req.params.id ?? "", 10); + if (!Number.isInteger(id) || id <= 0) + return sendError(res, 400, "Invalid file id"); + + try { + const result = await storage.finalizeFileUpload(id); + return res.json({ error: false, data: result }); + } catch (err: any) { + return sendError(res, 500, err?.message || "Failed to complete file"); + } + } +); + +router.put("/files/:id", async (req: Request, res: Response): Promise => { + const id = Number.parseInt(req.params.id ?? "", 10); + if (!Number.isInteger(id) || id <= 0) + return sendError(res, 400, "Invalid file id"); + + const updates: any = {}; + if (typeof req.body.name === "string") updates.name = req.body.name.trim(); + if (typeof req.body.mimeType === "string") + updates.mimeType = req.body.mimeType; + if (req.body.folderId !== undefined) updates.folderId = req.body.folderId; + + try { + const updated = await storage.updateFile(id, updates); + if (!updated) return sendError(res, 404, "File not found or update failed"); + return res.json({ error: false, data: serializeFile(updated as any) }); + } catch (err) { + return sendError(res, 500, "Failed to update file metadata"); + } +}); + +router.delete( + "/files/:id", + async (req: Request, res: Response): Promise => { + const id = Number.parseInt(req.params.id ?? "", 10); + if (!Number.isInteger(id) || id <= 0) + return sendError(res, 400, "Invalid file id"); + + try { + const ok = await storage.deleteFile(id); + if (!ok) return sendError(res, 404, "File not found or delete failed"); + return res.json({ error: false, data: { id } }); + } catch (err) { + return sendError(res, 500, "Failed to delete file"); + } + } +); + +/* ---------- Download (stream) ---------- + GET /files/:id/download +*/ +router.get( + "/files/:id/download", + async (req: Request, res: Response): Promise => { + const id = Number.parseInt(req.params.id ?? "", 10); + if (!Number.isInteger(id) || id <= 0) + return sendError(res, 400, "Invalid file id"); + + try { + const file = await storage.getFile(id); + if (!file) return sendError(res, 404, "File not found"); + + const filename = (file.name ?? `file-${(file as any).id}`).replace( + /["\\]/g, + "" + ); + if ((file as any).mimeType) + res.setHeader("Content-Type", (file as any).mimeType); + res.setHeader( + "Content-Disposition", + `attachment; filename="${encodeURIComponent(filename)}"` + ); + + await storage.streamFileTo(res, id); + + if (!res.writableEnded) res.end(); + } catch (err) { + if (res.headersSent) return res.end(); + return sendError(res, 500, "Failed to stream file"); + } + } +); + +/* ---------- Search endpoints (separate) ---------- + GET /search/folders?q=&limit=&offset= + GET /search/files?q=&type=&limit=&offset= +*/ +router.get( + "/search/folders", + async (req: Request, res: Response): Promise => { + const q = String(req.query.q ?? "").trim(); + const limit = parsePositiveInt(req.query.limit, 20); + const offset = parsePositiveInt(req.query.offset, 0); + if (!q) return sendError(res, 400, "Missing search query parameter 'q'"); + + try { + const { data, total } = await storage.searchFolders(q, limit, offset); + return res.json({ error: false, data, total, limit, offset }); + } catch (err) { + return sendError(res, 500, "Folder search failed"); + } + } +); + +router.get( + "/search/files", + async (req: Request, res: Response): Promise => { + const q = String(req.query.q ?? "").trim(); + const type = + typeof req.query.type === "string" ? req.query.type.trim() : undefined; + const limit = parsePositiveInt(req.query.limit, 20); + const offset = parsePositiveInt(req.query.offset, 0); + if (!q && !type) + return sendError( + res, + 400, + "Provide at least one of 'q' or 'type' to search files" + ); + + try { + const { data, total } = await storage.searchFiles(q, type, limit, offset); + const serialized = data.map(serializeFile); + return res.json({ error: false, data: serialized, total, limit, offset }); + } catch (err) { + return sendError(res, 500, "File search failed"); + } + } +); export default router; - \ No newline at end of file diff --git a/apps/Backend/src/storage/cloudStorage-storage.ts b/apps/Backend/src/storage/cloudStorage-storage.ts index a5ef907..c86a755 100644 --- a/apps/Backend/src/storage/cloudStorage-storage.ts +++ b/apps/Backend/src/storage/cloudStorage-storage.ts @@ -1,17 +1,52 @@ import { prisma as db } from "@repo/db/client"; -import { CloudFile, CloudFolder } from "@repo/db/types"; +import { CloudFolder, CloudFile } from "@repo/db/types"; import { serializeFile } from "../utils/prismaFileUtils"; +/** + * Cloud storage implementation + * + * - Clear, self-describing method names + * - Folder timestamp propagation helper: updateFolderTimestampsRecursively + * - File upload lifecycle: initializeFileUpload -> appendFileChunk -> finalizeFileUpload + */ + +/* ------------------------------- Helpers ------------------------------- */ +async function updateFolderTimestampsRecursively(folderId: number | null) { + if (folderId == null) return; + let currentId: number | null = folderId; + const MAX_DEPTH = 50; + let depth = 0; + + while (currentId != null && depth < MAX_DEPTH) { + depth += 1; + try { + // touch updatedAt and fetch parentId + const row = (await db.cloudFolder.update({ + where: { id: currentId }, + data: { updatedAt: new Date() }, + select: { parentId: true }, + })) as { parentId: number | null }; + + currentId = row.parentId ?? null; + } catch (err: any) { + // Stop walking if folder removed concurrently (Prisma P2025) + if (err?.code === "P2025") break; + throw err; + } + } +} + +/* ------------------------------- IStorage ------------------------------- */ export interface IStorage { - // CloudFolder methods + // Folders getFolder(id: number): Promise; - getFoldersByUser( - userId: number, + listFoldersByParent( parentId: number | null, limit: number, offset: number ): Promise; - getRecentFolders(limit: number, offset: number): Promise; + countFoldersByParent(parentId: number | null): Promise; + listRecentFolders(limit: number, offset: number): Promise; createFolder( userId: number, name: string, @@ -22,23 +57,19 @@ export interface IStorage { updates: Partial<{ name?: string; parentId?: number | null }> ): Promise; deleteFolder(id: number): Promise; + countFolders(filter?: { + userId?: number; + nameContains?: string | null; + }): Promise; - // CloudFile methods + // Files getFile(id: number): Promise; - listFilesByFolderByUser( - userId: number, + listFilesInFolder( folderId: number | null, limit: number, offset: number ): Promise; - listFilesByFolder( - folderId: number | null, - limit: number, - offset: number - ): Promise; - - // chunked upload methods - createFileInit( + initializeFileUpload( userId: number, name: string, mimeType?: string | null, @@ -46,59 +77,74 @@ export interface IStorage { totalChunks?: number | null, folderId?: number | null ): Promise; - addChunk(fileId: number, seq: number, data: Buffer): Promise; - completeFile(fileId: number): Promise<{ ok: true; size: string }>; + appendFileChunk(fileId: number, seq: number, data: Buffer): Promise; + finalizeFileUpload(fileId: number): Promise<{ ok: true; size: string }>; deleteFile(fileId: number): Promise; + updateFile( + id: number, + updates: Partial> + ): Promise; + renameFile(id: number, name: string): Promise; + countFilesInFolder(folderId: number | null): Promise; + countFiles(filter?: { + userId?: number; + nameContains?: string | null; + mimeType?: string | null; + }): Promise; - // search - searchByName( - userId: number, + // Search + searchFolders( q: string, limit: number, offset: number - ): Promise<{ - folders: CloudFolder[]; - files: CloudFile[]; - foldersTotal: number; - filesTotal: number; - }>; + ): Promise<{ data: CloudFolder[]; total: number }>; + searchFiles( + q: string, + type: string | undefined, + limit: number, + offset: number + ): Promise<{ data: CloudFile[]; total: number }>; - // helper: stream file chunks via Node.js stream + // Streaming streamFileTo(resStream: NodeJS.WritableStream, fileId: number): Promise; } -export const cloudStorageStorage: IStorage = { - // --- Folders --- +/* ------------------------------- Implementation ------------------------------- */ +export const cloudStorage: IStorage = { + // --- FOLDERS --- async getFolder(id: number) { const folder = await db.cloudFolder.findUnique({ where: { id }, include: { files: false }, }); - return folder ?? null; + return (folder as unknown as CloudFolder) ?? null; }, - async getFoldersByUser( - userId: number, + async listFoldersByParent( parentId: number | null = null, limit = 50, offset = 0 ) { const folders = await db.cloudFolder.findMany({ - where: { userId, parentId }, + where: { parentId }, orderBy: { name: "asc" }, skip: offset, take: limit, }); - return folders; + return folders as unknown as CloudFolder[]; }, - async getRecentFolders(limit = 50, offset = 0) { + async countFoldersByParent(parentId: number | null = null) { + return db.cloudFolder.count({ where: { parentId } }); + }, + + async listRecentFolders(limit = 50, offset = 0) { const folders = await db.cloudFolder.findMany({ - orderBy: { name: "asc" }, + orderBy: { updatedAt: "desc" }, skip: offset, take: limit, }); - return folders; + return folders as unknown as CloudFolder[]; }, async createFolder( @@ -109,7 +155,9 @@ export const cloudStorageStorage: IStorage = { const created = await db.cloudFolder.create({ data: { userId, name, parentId }, }); - return created; + // mark parent(s) as updated + await updateFolderTimestampsRecursively(parentId); + return created as unknown as CloudFolder; }, async updateFolder( @@ -121,24 +169,51 @@ export const cloudStorageStorage: IStorage = { where: { id }, data: updates, }); - return updated; + if (updates.parentId !== undefined) { + await updateFolderTimestampsRecursively(updates.parentId ?? null); + } else { + // touch this folder's parent (to mark modification) + const f = await db.cloudFolder.findUnique({ + where: { id }, + select: { parentId: true }, + }); + await updateFolderTimestampsRecursively(f?.parentId ?? null); + } + return updated as unknown as CloudFolder; } catch (err) { - return null; + throw err; } }, async deleteFolder(id: number) { try { + const folder = await db.cloudFolder.findUnique({ + where: { id }, + select: { parentId: true }, + }); + const parentId = folder?.parentId ?? null; await db.cloudFolder.delete({ where: { id } }); + await updateFolderTimestampsRecursively(parentId); return true; - } catch (err) { - console.error("deleteFolder error", err); - return false; + } catch (err: any) { + if (err?.code === "P2025") return false; + throw err; } }, - // --- Files --- - async getFile(id: number): Promise { + async countFolders(filter?: { + userId?: number; + nameContains?: string | null; + }) { + const where: any = {}; + if (filter?.userId) where.userId = filter.userId; + if (filter?.nameContains) + where.name = { contains: filter.nameContains, mode: "insensitive" }; + return db.cloudFolder.count({ where }); + }, + + // --- FILES --- + async getFile(id: number) { const file = await db.cloudFile.findUnique({ where: { id }, include: { chunks: { orderBy: { seq: "asc" } } }, @@ -146,32 +221,7 @@ export const cloudStorageStorage: IStorage = { return (file as unknown as CloudFile) ?? null; }, - async listFilesByFolderByUser( - userId: number, - folderId: number | null = null, - limit = 50, - offset = 0 - ) { - const files = await db.cloudFile.findMany({ - where: { userId, folderId }, - orderBy: { createdAt: "desc" }, - skip: offset, - take: limit, - select: { - id: true, - name: true, - mimeType: true, - fileSize: true, - folderId: true, - isComplete: true, - createdAt: true, - updatedAt: true, - }, - }); - return files.map(serializeFile); - }, - - async listFilesByFolder( + async listFilesInFolder( folderId: number | null = null, limit = 50, offset = 0 @@ -192,17 +242,16 @@ export const cloudStorageStorage: IStorage = { updatedAt: true, }, }); - return files.map(serializeFile); + return files.map(serializeFile) as unknown as CloudFile[]; }, - // --- Chunked upload methods --- - async createFileInit( - userId, - name, - mimeType = null, - expectedSize = null, - totalChunks = null, - folderId = null + async initializeFileUpload( + userId: number, + name: string, + mimeType: string | null = null, + expectedSize: bigint | null = null, + totalChunks: number | null = null, + folderId: number | null = null ) { const created = await db.cloudFile.create({ data: { @@ -215,81 +264,178 @@ export const cloudStorageStorage: IStorage = { isComplete: false, }, }); - return serializeFile(created); + await updateFolderTimestampsRecursively(folderId); + return serializeFile(created) as unknown as CloudFile; }, - async addChunk(fileId: number, seq: number, data: Buffer) { - // Ensure file exists & belongs to owner will be done by caller (route) - // Attempt insert; if unique violation => ignore (idempotent) + async appendFileChunk(fileId: number, seq: number, data: Buffer) { try { - await db.cloudFileChunk.create({ - data: { - fileId, - seq, - data, - }, - }); + await db.cloudFileChunk.create({ data: { fileId, seq, data } }); } catch (err: any) { - // If unique constraint violation (duplicate chunk), ignore + // idempotent: ignore duplicate chunk constraint if ( err?.code === "P2002" || err?.message?.includes("Unique constraint failed") ) { - // duplicate chunk, ignore return; } throw err; } }, - async completeFile(fileId: number) { - // Compute total size from chunks and mark complete inside a transaction + async finalizeFileUpload(fileId: number) { const chunks = await db.cloudFileChunk.findMany({ where: { fileId } }); - if (!chunks.length) { - throw new Error("No chunks uploaded"); - } + if (!chunks.length) throw new Error("No chunks uploaded"); + + // compute total size let total = 0; for (const c of chunks) total += c.data.length; - // Update file - await db.cloudFile.update({ - where: { id: fileId }, - data: { - fileSize: BigInt(total), - isComplete: true, - }, + // transactionally update file and read folderId + const updated = await db.$transaction(async (tx) => { + await tx.cloudFile.update({ + where: { id: fileId }, + data: { fileSize: BigInt(total), isComplete: true }, + }); + return tx.cloudFile.findUnique({ + where: { id: fileId }, + select: { folderId: true }, + }); }); + + const folderId = (updated as any)?.folderId ?? null; + await updateFolderTimestampsRecursively(folderId); + return { ok: true, size: BigInt(total).toString() }; }, async deleteFile(fileId: number) { try { + const file = await db.cloudFile.findUnique({ + where: { id: fileId }, + select: { folderId: true }, + }); + if (!file) return false; + const folderId = file.folderId ?? null; await db.cloudFile.delete({ where: { id: fileId } }); - // chunks cascade-delete via Prisma relation onDelete: Cascade + await updateFolderTimestampsRecursively(folderId); return true; - } catch (err) { - console.error("deleteFile error", err); - return false; + } catch (err: any) { + if (err?.code === "P2025") return false; + throw err; } }, - // --- Search --- - async searchByName(userId: number, q: string, limit = 20, offset = 0) { - const [folders, files, foldersTotal, filesTotal] = await Promise.all([ + async updateFile( + id: number, + updates: Partial> + ) { + try { + let prevFolderId: number | null = null; + if (updates.folderId !== undefined) { + const f = await db.cloudFile.findUnique({ + where: { id }, + select: { folderId: true }, + }); + prevFolderId = f?.folderId ?? null; + } + + const updated = await db.cloudFile.update({ + where: { id }, + data: updates, + }); + + // touch affected folders + if (updates.folderId !== undefined) { + await updateFolderTimestampsRecursively(updates.folderId ?? null); + if ( + prevFolderId != null && + prevFolderId !== (updates.folderId ?? null) + ) { + await updateFolderTimestampsRecursively(prevFolderId); + } + } else { + const f = await db.cloudFile.findUnique({ + where: { id }, + select: { folderId: true }, + }); + await updateFolderTimestampsRecursively(f?.folderId ?? null); + } + + return serializeFile(updated) as unknown as CloudFile; + } catch (err) { + throw err; + } + }, + + async renameFile(id: number, name: string) { + try { + const updated = await db.cloudFile.update({ + where: { id }, + data: { name }, + }); + const f = await db.cloudFile.findUnique({ + where: { id }, + select: { folderId: true }, + }); + await updateFolderTimestampsRecursively(f?.folderId ?? null); + return serializeFile(updated) as unknown as CloudFile; + } catch (err) { + throw err; + } + }, + + async countFilesInFolder(folderId: number | null) { + return db.cloudFile.count({ where: { folderId } }); + }, + + async countFiles(filter?: { + userId?: number; + nameContains?: string | null; + mimeType?: string | null; + }) { + const where: any = {}; + if (filter?.userId) where.userId = filter.userId; + if (filter?.nameContains) + where.name = { contains: filter.nameContains, mode: "insensitive" }; + if (filter?.mimeType) + where.mimeType = { startsWith: filter.mimeType, mode: "insensitive" }; + return db.cloudFile.count({ where }); + }, + + // --- SEARCH --- + async searchFolders(q: string, limit = 20, offset = 0) { + const [folders, total] = await Promise.all([ db.cloudFolder.findMany({ - where: { - userId, - name: { contains: q, mode: "insensitive" }, - }, + where: { name: { contains: q, mode: "insensitive" } }, orderBy: { name: "asc" }, skip: offset, take: limit, }), + db.cloudFolder.count({ + where: { name: { contains: q, mode: "insensitive" } }, + }), + ]); + return { data: folders as unknown as CloudFolder[], total }; + }, + + async searchFiles( + q: string, + type: string | undefined, + limit = 20, + offset = 0 + ) { + const where: any = {}; + if (q) where.name = { contains: q, mode: "insensitive" }; + if (type) { + if (!type.includes("/")) + where.mimeType = { startsWith: `${type}/`, mode: "insensitive" }; + else where.mimeType = { startsWith: type, mode: "insensitive" }; + } + + const [files, total] = await Promise.all([ db.cloudFile.findMany({ - where: { - userId, - name: { contains: q, mode: "insensitive" }, - }, + where, orderBy: { createdAt: "desc" }, skip: offset, take: limit, @@ -304,30 +450,14 @@ export const cloudStorageStorage: IStorage = { updatedAt: true, }, }), - db.cloudFolder.count({ - where: { - userId, - name: { contains: q, mode: "insensitive" }, - }, - }), - db.cloudFile.count({ - where: { - userId, - name: { contains: q, mode: "insensitive" }, - }, - }), + db.cloudFile.count({ where }), ]); - return { - folders, - files: files.map(serializeFile), - foldersTotal, - filesTotal, - }; + + return { data: files.map(serializeFile) as unknown as CloudFile[], total }; }, - // --- Streaming helper --- + // --- STREAM --- async streamFileTo(resStream: NodeJS.WritableStream, fileId: number) { - // Stream chunks in batches to avoid loading everything at once. const batchSize = 100; let offset = 0; while (true) { @@ -338,12 +468,11 @@ export const cloudStorageStorage: IStorage = { skip: offset, }); if (!chunks.length) break; - for (const c of chunks) { - resStream.write(Buffer.from(c.data)); - } + for (const c of chunks) resStream.write(Buffer.from(c.data)); offset += chunks.length; if (chunks.length < batchSize) break; } - // caller will end the response stream }, }; + +export default cloudStorage; diff --git a/apps/Frontend/src/lib/queryClient.ts b/apps/Frontend/src/lib/queryClient.ts index e9b0748..6d65385 100644 --- a/apps/Frontend/src/lib/queryClient.ts +++ b/apps/Frontend/src/lib/queryClient.ts @@ -38,16 +38,52 @@ export async function apiRequest( const isFormData = typeof FormData !== "undefined" && data instanceof FormData; + const isFileLike = + (typeof File !== "undefined" && data instanceof File) || + (typeof Blob !== "undefined" && data instanceof Blob); + + const isArrayBufferLike = + (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) || + (typeof Uint8Array !== "undefined" && data instanceof Uint8Array) || + (data != null && (data as any)?.constructor?.name === "Buffer"); // Node Buffer + + // Decide Content-Type header appropriately: const headers: Record = { ...(token ? { Authorization: `Bearer ${token}` } : {}), - // Only set Content-Type if not using FormData - ...(isFormData ? {} : { "Content-Type": "application/json" }), }; + if (!isFormData) { + if (isFileLike) { + // File/Blob: use its own MIME type if present, otherwise fallback + const mime = (data as File | Blob).type || "application/octet-stream"; + headers["Content-Type"] = mime; + } else if (isArrayBufferLike) { + // ArrayBuffer / Buffer / Uint8Array: use generic octet-stream + headers["Content-Type"] = "application/octet-stream"; + } else { + // Normal JSON body + headers["Content-Type"] = "application/json"; + } + } + // If FormData, we must NOT set Content-Type (browser will set multipart boundary) + + // Build final body + const finalBody = isFormData + ? (data as FormData) + : isFileLike + ? // File/Blob can be passed directly as BodyInit + (data as BodyInit) + : isArrayBufferLike + ? // ArrayBuffer / Uint8Array / Buffer -> convert to Uint8Array if needed + (data as BodyInit) + : data !== undefined + ? JSON.stringify(data) + : undefined; + const res = await fetch(`${API_BASE_URL}${url}`, { method, headers, - body: isFormData ? (data as FormData) : JSON.stringify(data), + body: finalBody, credentials: "include", }); diff --git a/apps/Frontend/src/pages/cloud-storage-page.tsx b/apps/Frontend/src/pages/cloud-storage-page.tsx index c54e418..70655fd 100644 --- a/apps/Frontend/src/pages/cloud-storage-page.tsx +++ b/apps/Frontend/src/pages/cloud-storage-page.tsx @@ -1,7 +1,6 @@ -import { useState } from "react"; +// src/pages/cloud-storage.tsx +import { useEffect, useRef, useState } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; -import { TopAppBar } from "@/components/layout/top-app-bar"; -import { Sidebar } from "@/components/layout/sidebar"; import { Card, CardContent, @@ -15,37 +14,22 @@ import { Label } from "@/components/ui/label"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { - Upload, - Download, Folder, - Trash2, FolderPlus, - Edit2, - MoreVertical, - FileText, - Image, - FileCode, - FileArchive, - FileAudio, - FileVideo, - Eye, + Search as SearchIcon, X, + Plus, + File as FileIcon, + FileText, + Image as ImageIcon, + Image, } from "lucide-react"; import { Dialog, DialogContent, - DialogDescription, DialogHeader, - DialogTitle, DialogFooter, } from "@/components/ui/dialog"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Breadcrumb, BreadcrumbItem, @@ -54,344 +38,390 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; -import { CloudFolder, CloudFile } from "@shared/schema"; +import { useAuth } from "@/hooks/use-auth"; -interface CloudStorageItem { - folders: CloudFolder[]; - files: CloudFile[]; +import type { CloudFolder, CloudFile } from "@repo/db/types"; + +type ApiListResponse = { + error: boolean; + data: T; + total?: number; + limit?: number; + offset?: number; +}; + +function truncateName(name: string, len = 28) { + if (!name) return ""; + return name.length > len ? name.slice(0, len - 1) + "…" : name; +} + +function fileIcon(mime?: string) { + if (!mime) return ; + if (mime.startsWith("image/")) return ; + if (mime === "application/pdf" || mime.endsWith("/pdf")) + return ; + return ; } export default function CloudStoragePage() { const { toast } = useToast(); - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const [currentFolderId, setCurrentFolderId] = useState(null); - const [folderPath, setFolderPath] = useState< + const { user } = useAuth(); + const CURRENT_USER_ID = user?.id; + + // modal state + const [isFolderModalOpen, setIsFolderModalOpen] = useState(false); + const [modalFolder, setModalFolder] = useState(null); + + // Add-folder modal (simple name/cancel/confirm) - used both from main page and inside folder + const [isAddFolderModalOpen, setIsAddFolderModalOpen] = useState(false); + const [addFolderParentId, setAddFolderParentId] = useState( + null + ); // which parent to create in + const [addFolderName, setAddFolderName] = useState(""); + + // Upload modal (simple file picker + confirm) + const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); + const [uploadParentId, setUploadParentId] = useState(null); // which folder to upload into + const [uploadSelectedFiles, setUploadSelectedFiles] = useState([]); + const uploadFileInputRef = useRef(null); + + // breadcrumb inside modal: array of {id: number|null, name} + const [modalPath, setModalPath] = useState< { id: number | null; name: string }[] >([{ id: null, name: "My Cloud Storage" }]); - // Dialog states - const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); - const [isRenameOpen, setIsRenameOpen] = useState(false); - const [isDeleteOpen, setIsDeleteOpen] = useState(false); - const [isUploadOpen, setIsUploadOpen] = useState(false); + // pagination state for folders/files in modal + const [foldersOffset, setFoldersOffset] = useState(0); + const [filesOffset, setFilesOffset] = useState(0); + const FOLDERS_LIMIT = 10; + const FILES_LIMIT = 20; - // Form states - const [newFolderName, setNewFolderName] = useState(""); - const [newItemName, setNewItemName] = useState(""); - const [selectedItem, setSelectedItem] = useState<{ - type: "folder" | "file"; - item: CloudFolder | CloudFile; - } | null>(null); - const [selectedFile, setSelectedFile] = useState(null); - const [viewingFile, setViewingFile] = useState(null); - const [fileViewUrl, setFileViewUrl] = useState(""); - const [isViewerOpen, setIsViewerOpen] = useState(false); + const [modalFolders, setModalFolders] = useState([]); + const [modalFiles, setModalFiles] = useState([]); + const [foldersTotal, setFoldersTotal] = useState(0); + const [filesTotal, setFilesTotal] = useState(0); + const [isLoadingModalItems, setIsLoadingModalItems] = useState(false); - // Fetch folders and files + // recent folders (main page) - show only top-level (parentId === null) + const RECENT_LIMIT = 10; + const [recentOffset, setRecentOffset] = useState(0); const { - data: storageItems, - isLoading, - refetch, - } = useQuery({ - queryKey: ["/api/cloud-storage/items", currentFolderId], + data: recentFoldersData, + isLoading: isLoadingRecentFolders, + refetch: refetchRecentFolders, + } = useQuery({ + queryKey: ["/api/cloud-storage/folders/recent", recentOffset], queryFn: async () => { - const params = currentFolderId - ? `?parentId=${currentFolderId}` - : "?parentId="; - const res = await fetch(`/api/cloud-storage/items${params}`, { - credentials: "include", - }); - if (!res.ok) throw new Error("Failed to fetch items"); - return res.json(); - }, - }); - - // Create folder mutation - const createFolderMutation = useMutation({ - mutationFn: async (name: string) => { - const res = await apiRequest("POST", "/api/cloud-storage/folders", { - name, - parentId: currentFolderId, - }); - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/cloud-storage/items"] }); - setIsCreateFolderOpen(false); - setNewFolderName(""); - toast({ - title: "Folder created", - description: "Your folder has been created successfully.", - }); - }, - onError: (error) => { - toast({ - title: "Error", - description: "Failed to create folder. Please try again.", - variant: "destructive", - }); - }, - }); - - // Rename folder mutation - const renameFolderMutation = useMutation({ - mutationFn: async ({ id, name }: { id: number; name: string }) => { const res = await apiRequest( - "PATCH", - `/api/cloud-storage/folders/${id}`, - { name } + "GET", + `/api/cloud-storage/folders/recent?limit=${RECENT_LIMIT}&offset=${recentOffset}` ); - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/cloud-storage/items"] }); - setIsRenameOpen(false); - setNewItemName(""); - setSelectedItem(null); - toast({ - title: "Folder renamed", - description: "Your folder has been renamed successfully.", - }); - }, - onError: (error) => { - toast({ - title: "Error", - description: "Failed to rename folder. Please try again.", - variant: "destructive", - }); + const json = await res.json(); + if (!res.ok) + throw new Error(json?.message || "Failed to load recent folders"); + // filter to top-level only (parentId === null) + const filtered = (json.data || []).filter((f: any) => f.parentId == null); + return { ...json, data: filtered } as ApiListResponse; }, }); - // Rename file mutation - const renameFileMutation = useMutation({ - mutationFn: async ({ id, name }: { id: number; name: string }) => { - const res = await apiRequest("PATCH", `/api/cloud-storage/files/${id}`, { - name, - }); - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/cloud-storage/items"] }); - setIsRenameOpen(false); - setNewItemName(""); - setSelectedItem(null); - toast({ - title: "File renamed", - description: "Your file has been renamed successfully.", - }); - }, - onError: (error) => { - toast({ - title: "Error", - description: "Failed to rename file. Please try again.", - variant: "destructive", - }); - }, - }); + /* ---------- Server fetch functions (paginated) ---------- */ - // Delete folder mutation - const deleteFolderMutation = useMutation({ - mutationFn: async (id: number) => { - const res = await apiRequest( - "DELETE", - `/api/cloud-storage/folders/${id}` - ); - if (!res.ok) throw new Error("Failed to delete folder"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/cloud-storage/items"] }); - setIsDeleteOpen(false); - setSelectedItem(null); - toast({ - title: "Folder deleted", - description: "Your folder has been deleted successfully.", - }); - }, - onError: (error) => { - toast({ - title: "Error", - description: "Failed to delete folder. Please try again.", - variant: "destructive", - }); - }, - }); + async function fetchModalFolders( + parentId: number | null, + limit = FOLDERS_LIMIT, + offset = 0 + ) { + const pid = parentId === null ? "null" : String(parentId); + const res = await apiRequest( + "GET", + `/api/cloud-storage/items/folders?parentId=${encodeURIComponent(pid)}&limit=${limit}&offset=${offset}` + ); + const json = await res.json().catch(() => null); + if (!res.ok || !json) + throw new Error(json?.message || "Failed to fetch folders"); + return json as ApiListResponse; + } - // Delete file mutation - const deleteFileMutation = useMutation({ - mutationFn: async (id: number) => { - const res = await apiRequest("DELETE", `/api/cloud-storage/files/${id}`); - if (!res.ok) throw new Error("Failed to delete file"); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/cloud-storage/items"] }); - setIsDeleteOpen(false); - setSelectedItem(null); - toast({ - title: "File deleted", - description: "Your file has been deleted successfully.", - }); - }, - onError: (error) => { - toast({ - title: "Error", - description: "Failed to delete file. Please try again.", - variant: "destructive", - }); - }, - }); + async function fetchModalFiles( + folderId: number | null, + limit = FILES_LIMIT, + offset = 0 + ) { + const fid = folderId === null ? "null" : String(folderId); + const res = await apiRequest( + "GET", + `/api/cloud-storage/folders/${encodeURIComponent(fid)}/files?limit=${limit}&offset=${offset}` + ); + const json = await res.json().catch(() => null); + if (!res.ok || !json) + throw new Error(json?.message || "Failed to fetch files"); + return json as ApiListResponse; + } - // Upload file mutation - const uploadFileMutation = useMutation({ - mutationFn: async (file: File) => { - // Get upload URL - const uploadUrlRes = await apiRequest( - "POST", - "/api/cloud-storage/upload-url", - { - fileName: file.name, - } - ); - const { uploadURL, storagePath } = await uploadUrlRes.json(); + /* ---------- load modal items (folders + files) ---------- */ - // Upload file to object storage - const uploadRes = await fetch(uploadURL, { - method: "PUT", - body: file, - headers: { - "Content-Type": file.type || "application/octet-stream", - }, - }); - - if (!uploadRes.ok) { - throw new Error("Failed to upload file to storage"); - } - - // Save file metadata with the storage path - const res = await apiRequest("POST", "/api/cloud-storage/files", { - name: file.name, - folderId: currentFolderId, - fileUrl: storagePath, - fileSize: file.size, - mimeType: file.type, - }); - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["/api/cloud-storage/items"] }); - setIsUploadOpen(false); - setSelectedFile(null); - toast({ - title: "File uploaded", - description: "Your file has been uploaded successfully.", - }); - }, - onError: (error) => { - toast({ - title: "Error", - description: "Failed to upload file. Please try again.", - variant: "destructive", - }); - }, - }); - - const handleFolderClick = (folder: CloudFolder) => { - setCurrentFolderId(folder.id); - setFolderPath([...folderPath, { id: folder.id, name: folder.name }]); - }; - - const handleBreadcrumbClick = (index: number) => { - const newPath = folderPath.slice(0, index + 1); - setFolderPath(newPath); - setCurrentFolderId(newPath[newPath.length - 1]!.id); - }; - - const handleCreateFolder = () => { - if (newFolderName.trim()) { - createFolderMutation.mutate(newFolderName.trim()); - } - }; - - const handleRename = () => { - if (selectedItem && newItemName.trim()) { - if (selectedItem.type === "folder") { - renameFolderMutation.mutate({ - id: (selectedItem.item as CloudFolder).id, - name: newItemName.trim(), - }); - } else { - renameFileMutation.mutate({ - id: (selectedItem.item as CloudFile).id, - name: newItemName.trim(), - }); - } - } - }; - - const handleDelete = () => { - if (selectedItem) { - if (selectedItem.type === "folder") { - deleteFolderMutation.mutate((selectedItem.item as CloudFolder).id); - } else { - deleteFileMutation.mutate((selectedItem.item as CloudFile).id); - } - } - }; - - const handleFileUpload = () => { - if (selectedFile) { - uploadFileMutation.mutate(selectedFile); - } - }; - - const handleViewFile = async (file: CloudFile) => { + const loadModalItems = async ( + parentId: number | null, + foldersOffsetArg = 0, + filesOffsetArg = 0 + ) => { + setIsLoadingModalItems(true); try { - const res = await fetch(`/api/cloud-storage/files/${file.id}/url`, { - credentials: "include", - }); - if (!res.ok) throw new Error("Failed to get file URL"); - const { url, mimeType } = await res.json(); - - setViewingFile(file); - setFileViewUrl(url); - setIsViewerOpen(true); - } catch (error) { + const [foldersResp, filesResp] = await Promise.all([ + fetchModalFolders(parentId, FOLDERS_LIMIT, foldersOffsetArg), + fetchModalFiles(parentId, FILES_LIMIT, filesOffsetArg), + ]); + setModalFolders(foldersResp.data ?? []); + setFoldersTotal(foldersResp.total ?? foldersResp.data?.length ?? 0); + setModalFiles(filesResp.data ?? []); + setFilesTotal(filesResp.total ?? filesResp.data?.length ?? 0); + } catch (err: any) { toast({ title: "Error", - description: "Failed to open file. Please try again.", + description: err?.message || "Failed to load items", variant: "destructive", }); + setModalFolders([]); + setModalFiles([]); + setFoldersTotal(0); + setFilesTotal(0); + } finally { + setIsLoadingModalItems(false); } }; - const getFileIcon = (mimeType?: string) => { - if (!mimeType) return ; + /* ---------- Open modal (single-click from recent or elsewhere) ---------- */ + const openFolderModal = async (folder: CloudFolder | null) => { + const fid: number | null = + folder && typeof (folder as any).id === "number" + ? (folder as any).id + : null; - if (mimeType.startsWith("image/")) - return ; - if (mimeType.startsWith("video/")) - return ; - if (mimeType.startsWith("audio/")) - return ; - if (mimeType.includes("zip") || mimeType.includes("tar")) - return ; - if ( - mimeType.includes("javascript") || - mimeType.includes("typescript") || - mimeType.includes("json") - ) - return ; + setModalPath((prev) => { + if (!folder) return [{ id: null, name: "My Cloud Storage" }]; + const idx = prev.findIndex((p) => p.id === fid); + if (idx >= 0) return prev.slice(0, idx + 1); + const last = prev[prev.length - 1]; + if (last && last.id === (folder as any).parentId) { + return [...prev, { id: fid, name: folder.name }]; + } + return [ + { id: null, name: "My Cloud Storage" }, + { id: fid, name: folder.name }, + ]; + }); - return ; + setModalFolder(folder ?? null); + setFoldersOffset(0); + setFilesOffset(0); + setUploadSelectedFiles([]); + setIsFolderModalOpen(true); + await loadModalItems(fid, 0, 0); }; - const formatFileSize = (bytes: number) => { - if (bytes === 0) return "0 Bytes"; - const k = 1024; - const sizes = ["Bytes", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; + /* ---------- breadcrumb click inside modal ---------- */ + const handleModalBreadcrumbClick = async (index: number) => { + if (!Number.isInteger(index) || index < 0) return; + + const newPath = modalPath.slice(0, index + 1); + if (newPath.length === 0) { + setModalPath([{ id: null, name: "My Cloud Storage" }]); + setModalFolder(null); + setFoldersOffset(0); + setFilesOffset(0); + await loadModalItems(null, 0, 0); + return; + } + + setModalPath(newPath); + + const target = newPath[newPath.length - 1]; + const targetId: number | null = + target && typeof target.id === "number" ? target.id : null; + + setModalFolder(null); + setFoldersOffset(0); + setFilesOffset(0); + await loadModalItems(targetId, 0, 0); }; + /* ---------- create folder (via Add Folder modal) ---------- */ + const createFolder = useMutation({ + mutationFn: async (name: string) => { + const body = { + userId: CURRENT_USER_ID, + name, + parentId: addFolderParentId ?? null, + }; + const res = await apiRequest("POST", "/api/cloud-storage/folders", body); + const json = await res.json(); + if (!res.ok) throw new Error(json?.message || "Failed to create folder"); + return json; + }, + onSuccess: async () => { + toast({ title: "Folder created", description: "Folder created." }); + setIsAddFolderModalOpen(false); + setAddFolderName(""); + // refresh modal view if we're in same parent + await loadModalItems( + addFolderParentId ?? null, + foldersOffset, + filesOffset + ); + queryClient.invalidateQueries({ + queryKey: ["/api/cloud-storage/folders/recent"], + }); + }, + onError: () => + toast({ + title: "Error", + description: "Failed to create folder", + variant: "destructive", + }), + }); + + /* ---------- Upload logic (via upload modal) ---------- + Use arrayBuffer() and send as raw bytes to POST /files/:id/chunks?seq=..., + then POST /files/:id/complete + */ + const uploadSingleFile = async ( + file: File, + targetFolderId: number | null + ) => { + const folderParam = + targetFolderId === null ? "null" : String(targetFolderId); + const body = { + userId: CURRENT_USER_ID, + name: file.name, + mimeType: file.type || null, + expectedSize: file.size, + totalChunks: 1, + }; + + const initRes = await apiRequest( + "POST", + `/api/cloud-storage/folders/${encodeURIComponent(folderParam)}/files`, + body + ); + const initJson = await initRes.json().catch(() => null); + if (!initRes.ok || !initJson) + throw new Error( + initJson?.message ?? `Init failed (status ${initRes.status})` + ); + const created: CloudFile = initJson.data; + if (!created || typeof created.id !== "number") + throw new Error("Invalid response from init: missing file id"); + + // prepare raw bytes + const raw = await file.arrayBuffer(); + const chunkUrl = `/api/cloud-storage/files/${created.id}/chunks?seq=0`; + + try { + await apiRequest("POST", chunkUrl, raw); + } catch (err: any) { + try { + await apiRequest("DELETE", `/api/cloud-storage/files/${created.id}`); + } catch (_) {} + throw new Error(`Chunk upload failed: ${err?.message ?? String(err)}`); + } + + const completeRes = await apiRequest( + "POST", + `/api/cloud-storage/files/${created.id}/complete`, + {} + ); + const completeJson = await completeRes.json().catch(() => null); + if (!completeRes.ok || !completeJson) + throw new Error( + completeJson?.message ?? + `Finalize failed (status ${completeRes.status})` + ); + return completeJson; + }; + + const uploadFilesMutation = useMutation({ + mutationFn: async (files: File[]) => { + const targetFolderId = + uploadParentId ?? modalPath[modalPath.length - 1]?.id ?? null; + const results = []; + for (const f of files) { + results.push(await uploadSingleFile(f, targetFolderId)); + } + return results; + }, + onSuccess: async () => { + toast({ title: "Upload complete", description: "Files uploaded." }); + setUploadSelectedFiles([]); + setIsUploadModalOpen(false); + await loadModalItems( + modalPath[modalPath.length - 1]?.id ?? null, + foldersOffset, + filesOffset + ); + queryClient.invalidateQueries({ + queryKey: ["/api/cloud-storage/folders/recent"], + }); + }, + onError: (err: any) => { + toast({ + title: "Upload failed", + description: err?.message ?? "Upload failed", + variant: "destructive", + }); + }, + }); + + /* ---------- handlers ---------- */ + + const handleUploadFileSelection = (filesList: FileList | null) => { + setUploadSelectedFiles(filesList ? Array.from(filesList) : []); + }; + + const startUploadFromModal = () => { + if (!uploadSelectedFiles.length) { + toast({ + title: "No files", + description: "Select files to upload", + variant: "destructive", + }); + return; + } + uploadFilesMutation.mutate(uploadSelectedFiles); + }; + + const handleFoldersPage = async (dir: "next" | "prev") => { + const newOffset = + dir === "next" + ? foldersOffset + FOLDERS_LIMIT + : Math.max(0, foldersOffset - FOLDERS_LIMIT); + setFoldersOffset(newOffset); + await loadModalItems( + modalPath[modalPath.length - 1]?.id ?? null, + newOffset, + filesOffset + ); + }; + + const handleFilesPage = async (dir: "next" | "prev") => { + const newOffset = + dir === "next" + ? filesOffset + FILES_LIMIT + : Math.max(0, filesOffset - FILES_LIMIT); + setFilesOffset(newOffset); + await loadModalItems( + modalPath[modalPath.length - 1]?.id ?? null, + foldersOffset, + newOffset + ); + }; + + useEffect(() => { + refetchRecentFolders(); + }, [recentOffset]); + + /* ---------- Render ---------- */ + return (
@@ -399,429 +429,471 @@ export default function CloudStoragePage() {

Cloud Storage

- View and manage files and folder at cloud storage. + Recent top-level folders — click any folder to open it.

+ +
+ + + {/* MAIN PAGE New Folder: open Add Folder modal (parent null) */} + +
-
- - -
+ + {/* Recent Folders (top-level only) */} + + + Recent Folders + + Most recently updated top-level folders + + + + {isLoadingRecentFolders ? ( +
Loading...
+ ) : ( + <> +
+ {(recentFoldersData?.data ?? []).map((f) => ( +
+
openFolderModal(f)} + className="flex flex-col items-center p-3 rounded-lg hover:bg-gray-100 cursor-pointer" + style={{ minWidth: 120 }} + > + +
+ {f.name} +
+
+
+ ))} +
+ +
+
+ Showing {(recentFoldersData?.data ?? []).length} recent + folders +
+
+ + +
+
+ + )} +
+
- {/* Breadcrumb */} - - - - - {folderPath.map((item, index) => ( -
- {index > 0 && } - - {index === folderPath.length - 1 ? ( - {item.name} - ) : ( - handleBreadcrumbClick(index)} - > - {item.name} - - )} - -
- ))} -
-
-
-
- - {/* Storage Content */} - - - Files and Folders - - Manage your files and folders in the cloud - - - - {isLoading ? ( -
Loading...
- ) : ( -
- {/* Folders */} - {storageItems?.folders.map((folder) => ( -
handleFolderClick(folder)} - > -
- - - {folder.name} - -
- - - - - - { - setSelectedItem({ type: "folder", item: folder }); - setNewItemName(folder.name); - setIsRenameOpen(true); - }} - > - - Rename - - - { - setSelectedItem({ type: "folder", item: folder }); - setIsDeleteOpen(true); - }} - className="text-red-600" - > - - Delete - - - -
- ))} - - {/* Files */} - {storageItems?.files.map((file) => ( -
-
- {getFileIcon(file.mimeType || undefined)} - - {file.name} - - - {formatFileSize(file.fileSize)} - -
- - - - - - handleViewFile(file)}> - - View - - { - try { - const res = await fetch( - `/api/cloud-storage/files/${file.id}/url`, - { - credentials: "include", - } - ); - if (!res.ok) - throw new Error("Failed to get file URL"); - const { url } = await res.json(); - window.open(url, "_blank"); - } catch (error) { - toast({ - title: "Error", - description: "Failed to download file.", - variant: "destructive", - }); - } - }} - > - - Download - - { - setSelectedItem({ type: "file", item: file }); - setNewItemName(file.name); - setIsRenameOpen(true); - }} - > - - Rename - - - { - setSelectedItem({ type: "file", item: file }); - setIsDeleteOpen(true); - }} - className="text-red-600" - > - - Delete - - - -
- ))} - - {/* Empty state */} - {!storageItems?.folders.length && !storageItems?.files.length && ( -
- -

This folder is empty

-

- Create a new folder or upload files to get started -

-
- )} -
- )} -
-
- - {/* Create Folder Dialog */} - - + {/* Main Modal: spacing so not flush top/bottom */} + { + setIsFolderModalOpen(v); + if (!v) { + setModalFolder(null); + setModalPath([{ id: null, name: "My Cloud Storage" }]); + setModalFolders([]); + setModalFiles([]); + setUploadSelectedFiles([]); + } + }} + > + - Create New Folder - - Enter a name for your new folder - - -
-
- - setNewFolderName(e.target.value)} - placeholder="My Folder" - /> +
+
+ {/* breadcrumb inside modal */} + + + {modalPath.map((p, idx) => ( +
+ {idx > 0 && } + + {idx === modalPath.length - 1 ? ( + {p.name} + ) : ( + handleModalBreadcrumbClick(idx)} + > + {p.name} + + )} + +
+ ))} +
+
+
+ +
+ +
+ + +
+ {/* ----- Folders row ----- */} + + + Folders + + Child folders (page size {FOLDERS_LIMIT}) + + + +
+ {/* Add Folder tile: opens Add Folder modal (parent = current modal parent) */} +
+
{ + setAddFolderParentId( + modalPath[modalPath.length - 1]?.id ?? null + ); + setAddFolderName(""); + setIsAddFolderModalOpen(true); + }} + style={{ minWidth: 120 }} + > + +
+
+ + {modalFolders.map((f) => ( +
+
openFolderModal(f)} + className="flex flex-col items-center p-3 rounded-lg hover:bg-gray-100 cursor-pointer" + style={{ minWidth: 120 }} + > + +
+ {f.name} +
+
+
+ ))} +
+ +
+
+ Showing {modalFolders.length} of {foldersTotal} +
+
+ + +
+
+
+
+ + {/* ----- Files section (below folders) ----- */} + + + Files + + Files in this folder (page size {FILES_LIMIT}) + + + +
+
+
+ Target: {modalPath[modalPath.length - 1]?.name} +
+
+ +
+ {/* ADD FILE tile (only + icon) */} +
{ + setUploadParentId( + modalPath[modalPath.length - 1]?.id ?? null + ); + setUploadSelectedFiles([]); + setIsUploadModalOpen(true); + }} + style={{ minWidth: 120 }} + > + +
+
+
+ + {isLoadingModalItems ? ( +
Loading...
+ ) : ( + <> +
+ {modalFiles.map((file) => ( +
+
+
+ {fileIcon((file as any).mimeType)} +
+
+
+ {truncateName(file.name, 28)} +
+
+ {((file as any).fileSize ?? 0).toString()} bytes +
+
+
+
+ ))} +
+ +
+
+ Showing {modalFiles.length} of {filesTotal} +
+
+ + +
+
+ + )} +
+
+ -
- {/* Rename Dialog */} - - + {/* Add Folder Modal (simple name/cancel/confirm) */} + { + setIsAddFolderModalOpen(v); + if (!v) { + setAddFolderName(""); + setAddFolderParentId(null); + } + }} + > + - - Rename {selectedItem?.type === "folder" ? "Folder" : "File"} - - - Enter a new name for this {selectedItem?.type} - - -
-
- - setNewItemName(e.target.value)} - placeholder="New name" - /> -
-
- - - - -
-
- - {/* Delete Confirmation Dialog */} - - - - - Delete {selectedItem?.type === "folder" ? "Folder" : "File"} - - - Are you sure you want to delete "{selectedItem?.item.name}"? This - action cannot be undone. - - - - - - - - - - {/* Upload File Dialog */} - - - - Upload File - - Select a file to upload to your cloud storage - - -
-
- - setSelectedFile(e.target.files?.[0] || null)} - /> -
- {selectedFile && ( -
-

File: {selectedFile.name}

-

Size: {formatFileSize(selectedFile.size)}

+
+
+

Create Folder

+
+ Parent:{" "} + {addFolderParentId == null + ? "Root" + : `id ${addFolderParentId}`} +
- )} -
- - - - - -
+
+ +
+ + - {/* File Viewer Dialog */} - - -
-
-

{viewingFile?.name}

+
+ setAddFolderName(e.target.value)} + /> +
+
-
- {viewingFile && fileViewUrl && ( - <> - {/* PDF Viewer */} - {viewingFile.mimeType === "application/pdf" && ( -
-