From 4347adedd9df9a8f0ea9e6e14812e1ca278b41c2 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 18 Dec 2025 11:25:00 +0100 Subject: [PATCH 01/40] Folder --- .../src/__tests__/routes/tablo_data.test.ts | 690 +++++++++- apps/api/src/helpers/helpers.ts | 6 +- apps/api/src/index.ts | 2 +- apps/api/src/routers/tablo_data.ts | 294 ++++- .../main/src/components/TabloFilesSection.tsx | 1133 ++++++++++++----- apps/main/src/hooks/tablo_data.ts | 6 +- apps/main/src/hooks/tablo_folders.ts | 191 +++ packages/shared-types/package.json | 1 + packages/shared-types/src/index.ts | 4 + packages/shared-types/src/tablo-data.types.ts | 23 + 10 files changed, 1949 insertions(+), 401 deletions(-) create mode 100644 apps/main/src/hooks/tablo_folders.ts create mode 100644 packages/shared-types/src/tablo-data.types.ts diff --git a/apps/api/src/__tests__/routes/tablo_data.test.ts b/apps/api/src/__tests__/routes/tablo_data.test.ts index cb974d9..572fcb8 100644 --- a/apps/api/src/__tests__/routes/tablo_data.test.ts +++ b/apps/api/src/__tests__/routes/tablo_data.test.ts @@ -5,17 +5,26 @@ import { PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; +import type { TabloFoldersMetadata } from "@xtablo/shared-types"; import { sdkStreamMixin } from "@smithy/util-stream"; import { mockClient } from "aws-sdk-client-mock"; import { testClient } from "hono/testing"; import { Readable } from "stream"; -import { beforeAll, describe, expect, it } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; import { createConfig } from "../../config.js"; import { MiddlewareManager } from "../../middlewares/middleware.js"; import { getMainRouter } from "../../routers/index.js"; import type { TestUserData } from "../helpers/dbSetup.js"; import { getTestUser } from "../helpers/dbSetup.js"; +// Helper to create S3 stream from content +const createS3Stream = (content: string) => { + const stream = new Readable(); + stream.push(content); + stream.push(null); + return sdkStreamMixin(stream); +}; + // Create S3 mock const s3Mock = mockClient(S3Client); @@ -43,6 +52,9 @@ describe("TabloData Endpoint", () => { return res; }; + // Store folder metadata state for tests + let mockFolderMetadata: TabloFoldersMetadata = { folders: [], version: 1 }; + beforeAll(() => { // Reset mocks before all tests s3Mock.reset(); @@ -56,26 +68,48 @@ describe("TabloData Endpoint", () => { ], }); - // Mock GetObjectCommand (used by getTabloFile) - // Create a proper SDK stream from Readable - const stream = new Readable(); - stream.push("test file content"); - stream.push(null); - const sdkStream = sdkStreamMixin(stream); - - s3Mock.on(GetObjectCommand).resolves({ - Body: sdkStream, - ContentType: "text/plain", - LastModified: new Date("2025-11-12"), + // Mock GetObjectCommand (used by getTabloFile and getFolderMetadata) + s3Mock.on(GetObjectCommand).callsFake((input) => { + // Handle folder metadata requests + if (input.Key?.endsWith(".tablo-folders.json")) { + if (mockFolderMetadata.folders.length === 0 && mockFolderMetadata.version === 1) { + // Return empty metadata (simulates file not found) + return Promise.resolve({ + Body: createS3Stream(JSON.stringify(mockFolderMetadata)), + ContentType: "application/json", + }); + } + return Promise.resolve({ + Body: createS3Stream(JSON.stringify(mockFolderMetadata)), + ContentType: "application/json", + }); + } + // Handle regular file requests + return Promise.resolve({ + Body: createS3Stream("test file content"), + ContentType: "text/plain", + LastModified: new Date("2025-11-12"), + }); }); - // Mock PutObjectCommand (used by postTabloFile) - s3Mock.on(PutObjectCommand).resolves({}); + // Mock PutObjectCommand (used by postTabloFile and saveFolderMetadata) + s3Mock.on(PutObjectCommand).callsFake((input) => { + // Handle folder metadata saves + if (input.Key?.endsWith(".tablo-folders.json") && input.Body) { + mockFolderMetadata = JSON.parse(input.Body as string); + } + return Promise.resolve({}); + }); // Mock DeleteObjectCommand (used by deleteTabloFile) s3Mock.on(DeleteObjectCommand).resolves({}); }); + beforeEach(() => { + // Reset folder metadata before each test + mockFolderMetadata = { folders: [], version: 1 }; + }); + describe("GET /tablo-data/:tabloId/filenames - Owner Access", () => { it("should allow owner to access their private tablo", async () => { const res = await getTabloFileNamesResponse(ownerUser, client, "test_tablo_owner_private"); @@ -491,4 +525,632 @@ describe("TabloData Endpoint", () => { }); }); }); + + // ============================================ + // FOLDER ENDPOINT TESTS + // ============================================ + + describe("GET /tablo-data/:tabloId/folders - Get Folders (Member Access)", () => { + // Helper function to get folders + const getFoldersRequest = async ( + user: TestUserData, + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + client: any, + tabloId: string + ) => { + return await client["tablo-data"][":tabloId"]["folders"].$get( + { param: { tabloId } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + }; + + describe("Owner Access", () => { + it("should allow owner to get folders from their own tablo", async () => { + const res = await getFoldersRequest(ownerUser, client, "test_tablo_owner_private"); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.folders).toEqual([]); + expect(data.version).toBe(1); + }); + + it("should allow owner to get folders from their shared tablo", async () => { + const res = await getFoldersRequest(ownerUser, client, "test_tablo_owner_shared"); + + expect(res.status).toBe(200); + }); + }); + + describe("Temp User Access", () => { + it("should allow temp user to get folders from owner's shared tablo", async () => { + const res = await getFoldersRequest(temporaryUser, client, "test_tablo_owner_shared"); + + expect(res.status).toBe(200); + }); + + it("should deny temp user access to owner's private tablo folders", async () => { + const res = await getFoldersRequest(temporaryUser, client, "test_tablo_owner_private"); + + expect(res.status).toBe(403); + }); + }); + + describe("Unauthenticated Access", () => { + it("should deny unauthenticated access to folders", async () => { + const res = await client["tablo-data"][":tabloId"]["folders"].$get({ + param: { tabloId: "test_tablo_owner_private" }, + }); + + expect(res.status).toBe(401); + }); + }); + }); + + describe("POST /tablo-data/:tabloId/folders - Create Folder (Admin Only)", () => { + // Helper function to create folder + const createFolderRequest = async ( + user: TestUserData, + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + client: any, + tabloId: string, + name: string, + description?: string + ) => { + return await client["tablo-data"][":tabloId"]["folders"].$post( + { param: { tabloId }, json: { name, description } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + }; + + describe("Owner with Admin Access", () => { + it("should allow owner to create folder in their own tablo", async () => { + const res = await createFolderRequest( + ownerUser, + client, + "test_tablo_owner_private", + "My Folder", + "Test description" + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.message).toBe("Folder created successfully"); + expect(data.folder.name).toBe("My Folder"); + expect(data.folder.description).toBe("Test description"); + expect(data.folder.id).toMatch(/^folder-/); + }); + + it("should allow owner to create folder without description", async () => { + const res = await createFolderRequest( + ownerUser, + client, + "test_tablo_owner_private", + "Folder Without Description" + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.folder.name).toBe("Folder Without Description"); + expect(data.folder.description).toBeUndefined(); + }); + }); + + describe("Validation", () => { + it("should return 400 if folder name is missing", async () => { + const res = await client["tablo-data"][":tabloId"]["folders"].$post( + { param: { tabloId: "test_tablo_owner_private" }, json: {} }, + { headers: { Authorization: `Bearer ${ownerUser.accessToken}` } } + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe("Folder name is required"); + }); + + it("should return 400 if folder name is empty string", async () => { + const res = await createFolderRequest( + ownerUser, + client, + "test_tablo_owner_private", + " " + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe("Folder name is required"); + }); + + it("should return 400 for duplicate folder names", async () => { + // Create first folder + await createFolderRequest(ownerUser, client, "test_tablo_owner_private", "Duplicate Name"); + + // Try to create folder with same name + const res = await createFolderRequest( + ownerUser, + client, + "test_tablo_owner_private", + "duplicate name" // Case insensitive check + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe("A folder with this name already exists"); + }); + }); + + describe("Temp User - Blocked by regularUserCheck", () => { + it("should deny temp user from creating folder (regularUserCheck)", async () => { + const res = await createFolderRequest( + temporaryUser, + client, + "test_tablo_temp_private", + "Temp Folder" + ); + + // Temporary users are blocked by regularUserCheck middleware + expect(res.status).toBe(401); + }); + }); + + describe("Unauthenticated Access", () => { + it("should deny unauthenticated folder creation", async () => { + const res = await client["tablo-data"][":tabloId"]["folders"].$post({ + param: { tabloId: "test_tablo_owner_private" }, + json: { name: "Test Folder" }, + }); + + expect(res.status).toBe(401); + }); + }); + }); + + describe("PUT /tablo-data/:tabloId/folders/:folderId - Update Folder (Admin Only)", () => { + // Helper function to update folder + const updateFolderRequest = async ( + user: TestUserData, + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + client: any, + tabloId: string, + folderId: string, + name?: string, + description?: string + ) => { + return await client["tablo-data"][":tabloId"]["folders"][":folderId"].$put( + { param: { tabloId, folderId }, json: { name, description } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + }; + + // Helper to create a folder and return its ID + const createTestFolder = async (tabloId: string, name: string): Promise => { + const res = await client["tablo-data"][":tabloId"]["folders"].$post( + { param: { tabloId }, json: { name } }, + { headers: { Authorization: `Bearer ${ownerUser.accessToken}` } } + ); + const data = await res.json(); + return data.folder.id; + }; + + describe("Owner with Admin Access", () => { + it("should allow owner to update folder name", async () => { + const folderId = await createTestFolder("test_tablo_owner_private", "Original Name"); + + const res = await updateFolderRequest( + ownerUser, + client, + "test_tablo_owner_private", + folderId, + "Updated Name" + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.message).toBe("Folder updated successfully"); + expect(data.folder.name).toBe("Updated Name"); + }); + + it("should allow owner to update folder description", async () => { + const folderId = await createTestFolder("test_tablo_owner_private", "Test Folder"); + + const res = await updateFolderRequest( + ownerUser, + client, + "test_tablo_owner_private", + folderId, + undefined, + "New description" + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.folder.description).toBe("New description"); + }); + + it("should allow owner to clear folder description", async () => { + const folderId = await createTestFolder("test_tablo_owner_private", "Test Folder"); + + // First set a description + await updateFolderRequest( + ownerUser, + client, + "test_tablo_owner_private", + folderId, + undefined, + "Some description" + ); + + // Then clear it + const res = await updateFolderRequest( + ownerUser, + client, + "test_tablo_owner_private", + folderId, + undefined, + "" + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.folder.description).toBeUndefined(); + }); + }); + + describe("Validation", () => { + it("should return 404 for non-existent folder", async () => { + const res = await updateFolderRequest( + ownerUser, + client, + "test_tablo_owner_private", + "non-existent-folder-id", + "New Name" + ); + + expect(res.status).toBe(404); + const data = await res.json(); + expect(data.error).toBe("Folder not found"); + }); + + it("should return 400 for duplicate folder names on update", async () => { + const folderId1 = await createTestFolder("test_tablo_owner_private", "Folder One"); + await createTestFolder("test_tablo_owner_private", "Folder Two"); + + // Try to rename folder 1 to folder 2's name + const res = await updateFolderRequest( + ownerUser, + client, + "test_tablo_owner_private", + folderId1, + "Folder Two" + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.error).toBe("A folder with this name already exists"); + }); + }); + + describe("Temp User - Blocked by regularUserCheck", () => { + it("should deny temp user from updating folder (regularUserCheck)", async () => { + const res = await updateFolderRequest( + temporaryUser, + client, + "test_tablo_temp_private", + "some-folder-id", + "New Name" + ); + + expect(res.status).toBe(401); + }); + }); + + describe("Unauthenticated Access", () => { + it("should deny unauthenticated folder update", async () => { + const res = await client["tablo-data"][":tabloId"]["folders"][":folderId"].$put({ + param: { tabloId: "test_tablo_owner_private", folderId: "some-id" }, + json: { name: "New Name" }, + }); + + expect(res.status).toBe(401); + }); + }); + }); + + describe("DELETE /tablo-data/:tabloId/folders/:folderId - Delete Folder (Admin Only)", () => { + // Helper function to delete folder + const deleteFolderRequest = async ( + user: TestUserData, + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + client: any, + tabloId: string, + folderId: string + ) => { + return await client["tablo-data"][":tabloId"]["folders"][":folderId"].$delete( + { param: { tabloId, folderId } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + }; + + // Helper to create a folder and return its ID + const createTestFolder = async (tabloId: string, name: string): Promise => { + const res = await client["tablo-data"][":tabloId"]["folders"].$post( + { param: { tabloId }, json: { name } }, + { headers: { Authorization: `Bearer ${ownerUser.accessToken}` } } + ); + const data = await res.json(); + return data.folder.id; + }; + + describe("Owner with Admin Access", () => { + it("should allow owner to delete folder from their own tablo", async () => { + const folderId = await createTestFolder("test_tablo_owner_private", "To Delete"); + + const res = await deleteFolderRequest( + ownerUser, + client, + "test_tablo_owner_private", + folderId + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.message).toBe("Folder deleted successfully"); + expect(data.folder.name).toBe("To Delete"); + }); + }); + + describe("Validation", () => { + it("should return 404 for non-existent folder", async () => { + const res = await deleteFolderRequest( + ownerUser, + client, + "test_tablo_owner_private", + "non-existent-folder-id" + ); + + expect(res.status).toBe(404); + const data = await res.json(); + expect(data.error).toBe("Folder not found"); + }); + }); + + describe("Temp User - Blocked by regularUserCheck", () => { + it("should deny temp user from deleting folder (regularUserCheck)", async () => { + const res = await deleteFolderRequest( + temporaryUser, + client, + "test_tablo_temp_private", + "some-folder-id" + ); + + expect(res.status).toBe(401); + }); + }); + + describe("Unauthenticated Access", () => { + it("should deny unauthenticated folder deletion", async () => { + const res = await client["tablo-data"][":tabloId"]["folders"][":folderId"].$delete({ + param: { tabloId: "test_tablo_owner_private", folderId: "some-id" }, + }); + + expect(res.status).toBe(401); + }); + }); + }); + + // ============================================ + // NESTED FILE PATH TESTS (/:tabloId/file/:path) + // ============================================ + + describe("Nested File Path Routes (/file/:path)", () => { + // Helper function to get file with nested path + const getNestedFileRequest = async ( + user: TestUserData, + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + client: any, + tabloId: string, + filePath: string + ) => { + return await client["tablo-data"][":tabloId"]["file"][":path"].$get( + { param: { tabloId, path: filePath } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + }; + + // Helper function to post file with nested path + const postNestedFileRequest = async ( + user: TestUserData, + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + client: any, + tabloId: string, + filePath: string, + content: string + ) => { + return await client["tablo-data"][":tabloId"]["file"][":path"].$post( + { param: { tabloId, path: filePath }, json: { content, contentType: "text/plain" } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + }; + + // Helper function to delete file with nested path + const deleteNestedFileRequest = async ( + user: TestUserData, + // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access + client: any, + tabloId: string, + filePath: string + ) => { + return await client["tablo-data"][":tabloId"]["file"][":path"].$delete( + { param: { tabloId, path: filePath } }, + { headers: { Authorization: `Bearer ${user.accessToken}` } } + ); + }; + + describe("GET /tablo-data/:tabloId/file/:path - Get File with Nested Path", () => { + it("should allow owner to get file with nested path", async () => { + const res = await getNestedFileRequest( + ownerUser, + client, + "test_tablo_owner_private", + "folder-123/document.pdf" + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.fileName).toBe("folder-123/document.pdf"); + expect(data.content).toBe("test file content"); + }); + + it("should allow owner to get file with deeply nested path", async () => { + const res = await getNestedFileRequest( + ownerUser, + client, + "test_tablo_owner_private", + "folder-123/subfolder/deep/file.txt" + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.fileName).toBe("folder-123/subfolder/deep/file.txt"); + }); + + it("should allow member to get nested file from shared tablo", async () => { + const res = await getNestedFileRequest( + temporaryUser, + client, + "test_tablo_owner_shared", + "folder-456/report.pdf" + ); + + expect(res.status).toBe(200); + }); + + it("should deny access to nested file in private tablo", async () => { + const res = await getNestedFileRequest( + temporaryUser, + client, + "test_tablo_owner_private", + "folder-123/secret.pdf" + ); + + expect(res.status).toBe(403); + }); + + it("should deny unauthenticated access to nested file", async () => { + const res = await client["tablo-data"][":tabloId"]["file"][":path"].$get({ + param: { tabloId: "test_tablo_owner_private", path: "folder/file.txt" }, + }); + + expect(res.status).toBe(401); + }); + }); + + describe("POST /tablo-data/:tabloId/file/:path - Upload File with Nested Path", () => { + it("should allow owner to upload file with nested path", async () => { + const res = await postNestedFileRequest( + ownerUser, + client, + "test_tablo_owner_private", + "folder-123/new-document.pdf", + "Document content" + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.message).toBe("File uploaded successfully"); + expect(data.fileName).toBe("folder-123/new-document.pdf"); + }); + + it("should allow member to upload file with nested path to shared tablo", async () => { + const res = await postNestedFileRequest( + temporaryUser, + client, + "test_tablo_owner_shared", + "folder-789/upload.txt", + "Uploaded content" + ); + + expect(res.status).toBe(200); + }); + + it("should deny member from uploading nested file to private tablo", async () => { + const res = await postNestedFileRequest( + temporaryUser, + client, + "test_tablo_owner_private", + "folder-123/unauthorized.txt", + "Should fail" + ); + + expect(res.status).toBe(403); + }); + + it("should deny unauthenticated nested file upload", async () => { + const res = await client["tablo-data"][":tabloId"]["file"][":path"].$post({ + param: { tabloId: "test_tablo_owner_private", path: "folder/file.txt" }, + json: { content: "test" }, + }); + + expect(res.status).toBe(401); + }); + }); + + describe("DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin Only)", () => { + it("should allow admin to delete file with nested path", async () => { + const res = await deleteNestedFileRequest( + ownerUser, + client, + "test_tablo_owner_private", + "folder-123/to-delete.pdf" + ); + + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.message).toBe("File deleted successfully"); + expect(data.fileName).toBe("folder-123/to-delete.pdf"); + }); + + it("should allow admin to delete deeply nested file", async () => { + const res = await deleteNestedFileRequest( + ownerUser, + client, + "test_tablo_owner_private", + "folder-123/level2/level3/deep-file.txt" + ); + + expect(res.status).toBe(200); + }); + + it("should deny temp user from deleting nested file (regularUserCheck)", async () => { + const res = await deleteNestedFileRequest( + temporaryUser, + client, + "test_tablo_temp_private", + "folder-123/file.pdf" + ); + + expect(res.status).toBe(401); + }); + + it("should deny non-admin from deleting nested file", async () => { + // Owner has admin access to test_tablo_temp_shared_admin + // BUT regularUserCheck blocks access to tablos owned by temporary users + const res = await deleteNestedFileRequest( + ownerUser, + client, + "test_tablo_temp_shared_admin", + "folder/file.pdf" + ); + + expect(res.status).toBe(403); + }); + + it("should deny unauthenticated nested file deletion", async () => { + const res = await client["tablo-data"][":tabloId"]["file"][":path"].$delete({ + param: { tabloId: "test_tablo_owner_private", path: "folder/file.txt" }, + }); + + expect(res.status).toBe(401); + }); + }); + }); }); diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts index 3ee8e4c..6a94c3b 100644 --- a/apps/api/src/helpers/helpers.ts +++ b/apps/api/src/helpers/helpers.ts @@ -116,11 +116,13 @@ export const getTabloFileNames = async (s3_client: S3Client, tabloId: string) => const { Contents } = await s3_client.send( new ListObjectsV2Command({ Bucket: bucketName, - Prefix: tabloId, + Prefix: `${tabloId}/`, }) ); - return Contents?.map((content) => content.Key?.split("/")[1]).filter( + // Return full file paths relative to tabloId (e.g., "file.pdf" or "folder-123/file.pdf") + const prefix = `${tabloId}/`; + return Contents?.map((content) => content.Key?.substring(prefix.length)).filter( (content) => content?.length && content.length > 0 ); }; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 261a010..4ed49e3 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -45,7 +45,7 @@ async function startServer(secrets: Secrets) { "Access-Control-Allow-Credentials", "Access-Control-Expose-Headers", ], - allowMethods: ["GET", "POST", "PATCH", "OPTIONS", "DELETE"], + allowMethods: ["GET", "POST", "PUT", "PATCH", "OPTIONS", "DELETE"], exposeHeaders: ["set-cookie"], credentials: true, }); diff --git a/apps/api/src/routers/tablo_data.ts b/apps/api/src/routers/tablo_data.ts index b41ad54..2bb8546 100644 --- a/apps/api/src/routers/tablo_data.ts +++ b/apps/api/src/routers/tablo_data.ts @@ -1,4 +1,10 @@ -import { PutObjectCommand } from "@aws-sdk/client-s3"; +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from "@aws-sdk/client-s3"; +import type { TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types"; import { Hono } from "hono"; import { createFactory } from "hono/factory"; import { checkTabloAdmin, checkTabloMember, getTabloFileNames } from "../helpers/helpers.js"; @@ -7,6 +13,16 @@ import type { AuthEnv } from "../types/app.types.js"; const factory = createFactory(); +// Metadata file name for folders +const FOLDERS_METADATA_FILE = ".tablo-folders.json"; + +// Helper to generate unique folder IDs +const generateFolderId = () => `folder-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + +// ============================================ +// FILE ENDPOINTS +// ============================================ + const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) => { const tabloId = c.req.param("tabloId"); const s3_client = c.get("s3_client"); @@ -22,17 +38,20 @@ const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) => const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => { const tabloId = c.req.param("tabloId"); - const fileName = c.req.param("fileName"); + // Get the file path - supports both wildcard (*) and named parameter (:fileName) + const filePath = c.req.param("path") || c.req.param("fileName"); + + if (!filePath) { + return c.json({ error: "File path is required" }, 400); + } const s3_client = c.get("s3_client"); try { - const { GetObjectCommand } = await import("@aws-sdk/client-s3"); - const response = await s3_client.send( new GetObjectCommand({ Bucket: "tablo-data", - Key: `${tabloId}/${fileName}`, + Key: `${tabloId}/${filePath}`, }) ); @@ -43,7 +62,7 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => { const content = await response.Body.transformToString(); return c.json({ - fileName, + fileName: filePath, content, contentType: response.ContentType, lastModified: response.LastModified, @@ -56,7 +75,12 @@ const getTabloFile = factory.createHandlers(checkTabloMember, async (c) => { const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => { const tabloId = c.req.param("tabloId"); - const fileName = c.req.param("fileName"); + // Get the file path - supports both wildcard (*) and named parameter (:fileName) + const filePath = c.req.param("path") || c.req.param("fileName"); + + if (!filePath) { + return c.json({ error: "File path is required" }, 400); + } const s3_client = c.get("s3_client"); @@ -71,7 +95,7 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => { await s3_client.send( new PutObjectCommand({ Bucket: "tablo-data", - Key: `${tabloId}/${fileName}`, + Key: `${tabloId}/${filePath}`, Body: content, ContentType: contentType, }) @@ -79,7 +103,7 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => { return c.json({ message: "File uploaded successfully", - fileName, + fileName: filePath, tabloId, }); } catch (error) { @@ -88,61 +112,29 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => { } }); -// // PUT /tablo-data/:tabloId/:fileName - Update a file -// tabloDataRouter.put("/:tabloId/:fileName", async (c) => { -// const tabloId = c.req.param("tabloId"); -// const fileName = c.req.param("fileName"); -// const s3_client = c.get("s3_client"); - -// try { -// const body = await c.req.json(); -// const { content, contentType = "text/plain" } = body; - -// if (!content) { -// return c.json({ error: "Content is required" }, 400); -// } - -// const { PutObjectCommand } = await import("@aws-sdk/client-s3"); - -// await s3_client.send( -// new PutObjectCommand({ -// Bucket: "tablo-data", -// Key: `${tabloId}/${fileName}`, -// Body: content, -// ContentType: contentType, -// }) -// ); - -// return c.json({ -// message: "File updated successfully", -// fileName, -// tabloId, -// }); -// } catch (error) { -// console.error("Error updating file:", error); -// return c.json({ error: "Failed to update file" }, 500); -// } -// }); - const deleteTabloFile = (middlewareManager: ReturnType) => factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { const tabloId = c.req.param("tabloId"); - const fileName = c.req.param("fileName"); + // Get the file path - supports both wildcard (*) and named parameter (:fileName) + const filePath = c.req.param("path") || c.req.param("fileName"); + + if (!filePath) { + return c.json({ error: "File path is required" }, 400); + } + const s3_client = c.get("s3_client"); try { - const { DeleteObjectCommand } = await import("@aws-sdk/client-s3"); - await s3_client.send( new DeleteObjectCommand({ Bucket: "tablo-data", - Key: `${tabloId}/${fileName}`, + Key: `${tabloId}/${filePath}`, }) ); return c.json({ message: "File deleted successfully", - fileName, + fileName: filePath, tabloId, }); } catch (error) { @@ -151,6 +143,193 @@ const deleteTabloFile = (middlewareManager: ReturnType => { + try { + const response = await s3_client.send( + new GetObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${FOLDERS_METADATA_FILE}`, + }) + ); + + if (response.Body) { + const content = await response.Body.transformToString(); + return JSON.parse(content); + } + } catch { + // File doesn't exist, return default + } + return { folders: [], version: 1 }; +}; + +// Helper to save folder metadata +const saveFolderMetadata = async ( + s3_client: S3Client, + tabloId: string, + metadata: TabloFoldersMetadata +) => { + await s3_client.send( + new PutObjectCommand({ + Bucket: "tablo-data", + Key: `${tabloId}/${FOLDERS_METADATA_FILE}`, + Body: JSON.stringify(metadata, null, 2), + ContentType: "application/json", + }) + ); +}; + +// GET /tablo-data/:tabloId/folders - Get all folders for a tablo +const getTabloFolders = factory.createHandlers(checkTabloMember, async (c) => { + const tabloId = c.req.param("tabloId"); + const s3_client = c.get("s3_client"); + + try { + const metadata = await getFolderMetadata(s3_client, tabloId); + return c.json(metadata); + } catch (error) { + console.error("Error fetching folders:", error); + return c.json({ error: "Failed to fetch folders" }, 500); + } +}); + +// POST /tablo-data/:tabloId/folders - Create a new folder (admin only) +const createTabloFolder = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const tabloId = c.req.param("tabloId"); + const s3_client = c.get("s3_client"); + const user = c.get("user"); + + try { + const body = await c.req.json(); + const { name, description } = body; + + if (!name || typeof name !== "string" || !name.trim()) { + return c.json({ error: "Folder name is required" }, 400); + } + + const metadata = await getFolderMetadata(s3_client, tabloId); + + // Check for duplicate folder names + if (metadata.folders.some((f) => f.name.toLowerCase() === name.trim().toLowerCase())) { + return c.json({ error: "A folder with this name already exists" }, 400); + } + + const newFolder: TabloFolder = { + id: generateFolderId(), + name: name.trim(), + description: description?.trim() || undefined, + createdAt: new Date().toISOString(), + createdBy: user.id, + }; + + metadata.folders.push(newFolder); + metadata.version += 1; + + await saveFolderMetadata(s3_client, tabloId, metadata); + + return c.json({ + message: "Folder created successfully", + folder: newFolder, + }); + } catch (error) { + console.error("Error creating folder:", error); + return c.json({ error: "Failed to create folder" }, 500); + } + }); + +// PUT /tablo-data/:tabloId/folders/:folderId - Update a folder (admin only) +const updateTabloFolder = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const tabloId = c.req.param("tabloId"); + const folderId = c.req.param("folderId"); + const s3_client = c.get("s3_client"); + + try { + const body = await c.req.json(); + const { name, description } = body; + + const metadata = await getFolderMetadata(s3_client, tabloId); + const folderIndex = metadata.folders.findIndex((f) => f.id === folderId); + + if (folderIndex === -1) { + return c.json({ error: "Folder not found" }, 404); + } + + // Check for duplicate folder names (excluding current folder) + if ( + name && + metadata.folders.some( + (f, idx) => idx !== folderIndex && f.name.toLowerCase() === name.trim().toLowerCase() + ) + ) { + return c.json({ error: "A folder with this name already exists" }, 400); + } + + // Update folder + if (name) { + metadata.folders[folderIndex].name = name.trim(); + } + if (description !== undefined) { + metadata.folders[folderIndex].description = description?.trim() || undefined; + } + metadata.version += 1; + + await saveFolderMetadata(s3_client, tabloId, metadata); + + return c.json({ + message: "Folder updated successfully", + folder: metadata.folders[folderIndex], + }); + } catch (error) { + console.error("Error updating folder:", error); + return c.json({ error: "Failed to update folder" }, 500); + } + }); + +// DELETE /tablo-data/:tabloId/folders/:folderId - Delete a folder (admin only) +const deleteTabloFolder = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const tabloId = c.req.param("tabloId"); + const folderId = c.req.param("folderId"); + const s3_client = c.get("s3_client"); + + try { + const metadata = await getFolderMetadata(s3_client, tabloId); + const folderIndex = metadata.folders.findIndex((f) => f.id === folderId); + + if (folderIndex === -1) { + return c.json({ error: "Folder not found" }, 404); + } + + const deletedFolder = metadata.folders[folderIndex]; + metadata.folders.splice(folderIndex, 1); + metadata.version += 1; + + await saveFolderMetadata(s3_client, tabloId, metadata); + + // Note: Files in the folder are NOT deleted, they become "orphaned" / unorganized + return c.json({ + message: "Folder deleted successfully", + folder: deletedFolder, + }); + } catch (error) { + console.error("Error deleting folder:", error); + return c.json({ error: "Failed to delete folder" }, 500); + } + }); + +// ============================================ +// ROUTER SETUP +// ============================================ + export const getTabloDataRouter = () => { const tabloDataRouter = new Hono(); const middlewareManager = MiddlewareManager.getInstance(); @@ -159,7 +338,22 @@ export const getTabloDataRouter = () => { tabloDataRouter.use(middlewareManager.streamChat); tabloDataRouter.use(middlewareManager.r2); + // File endpoints tabloDataRouter.get("/:tabloId/filenames", ...getTabloFilenames); + + // Folder endpoints (must be defined before wildcard file routes) + tabloDataRouter.get("/:tabloId/folders", ...getTabloFolders); + tabloDataRouter.post("/:tabloId/folders", ...createTabloFolder(middlewareManager)); + tabloDataRouter.put("/:tabloId/folders/:folderId", ...updateTabloFolder(middlewareManager)); + tabloDataRouter.delete("/:tabloId/folders/:folderId", ...deleteTabloFolder(middlewareManager)); + + // File routes using wildcard to support nested paths (e.g., "folder-123/file.pdf") + // These must be defined after the specific routes above + tabloDataRouter.get("/:tabloId/file/:path{.+}", ...getTabloFile); + tabloDataRouter.post("/:tabloId/file/:path{.+}", ...postTabloFile); + tabloDataRouter.delete("/:tabloId/file/:path{.+}", ...deleteTabloFile(middlewareManager)); + + // Legacy routes for backward compatibility (single-level file names only) tabloDataRouter.get("/:tabloId/:fileName", ...getTabloFile); tabloDataRouter.post("/:tabloId/:fileName", ...postTabloFile); tabloDataRouter.delete("/:tabloId/:fileName", ...deleteTabloFile(middlewareManager)); diff --git a/apps/main/src/components/TabloFilesSection.tsx b/apps/main/src/components/TabloFilesSection.tsx index 9c9c6ff..748ee64 100644 --- a/apps/main/src/components/TabloFilesSection.tsx +++ b/apps/main/src/components/TabloFilesSection.tsx @@ -1,16 +1,48 @@ import { toast } from "@xtablo/shared"; import { UserTablo } from "@xtablo/shared/types/tablos.types"; import { Button } from "@xtablo/ui/components/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@xtablo/ui/components/collapsible"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@xtablo/ui/components/dialog"; +import { Input } from "@xtablo/ui/components/input"; import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography"; -import { DownloadIcon, Trash2Icon } from "lucide-react"; -import { useRef, useState } from "react"; +import { + ChevronDownIcon, + ChevronRightIcon, + DownloadIcon, + FolderIcon, + FolderPlusIcon, + PencilIcon, + Trash2Icon, +} from "lucide-react"; +import { useMemo, useRef, useState } from "react"; import { useCreateTabloFile, useDeleteTabloFile, useDownloadTabloFile, useTabloFileNames, } from "../hooks/tablo_data"; -import { useIsReadOnlyUser } from "../providers/UserStoreProvider"; +import { + extractFolderIdFromFileName, + getFileNameWithoutFolder, + getFolderFilePrefix, + TabloFolder, + useCreateTabloFolder, + useDeleteTabloFolder, + useTabloFolders, + useUpdateTabloFolder, +} from "../hooks/tablo_folders"; +import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider"; import { TabloHeaderActions } from "./TabloHeaderActions"; interface TabloFilesSectionProps { @@ -18,31 +50,481 @@ interface TabloFilesSectionProps { isAdmin: boolean; } -export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => { - const { - data: fileData, - isLoading: filesLoading, - error: filesError, - } = useTabloFileNames(tablo.id); +// Helper to get file icon color based on extension +const getFileIconProps = (fileName: string) => { + const fileExtension = fileName.split(".").pop()?.toLowerCase() || ""; + const isImage = ["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExtension); + const isPdf = fileExtension === "pdf"; + const isText = ["txt", "md", "json", "csv"].includes(fileExtension); - const [selectedFile, setSelectedFile] = useState(null); - const [isUploading, setIsUploading] = useState(false); - const [deletingFile, setDeletingFile] = useState(null); - const [downloadingFile, setDownloadingFile] = useState(null); - const [error, setError] = useState(""); + if (isImage) return { bgColor: "bg-purple-500 hover:bg-purple-600", type: "image" as const }; + if (isPdf) return { bgColor: "bg-red-500 hover:bg-red-600", type: "pdf" as const }; + if (isText) return { bgColor: "bg-blue-500 hover:bg-blue-600", type: "text" as const }; + return { bgColor: "bg-gray-500 hover:bg-gray-600", type: "other" as const }; +}; + +// File Icon Component +const FileIcon = ({ type }: { type: "image" | "pdf" | "text" | "other" }) => { + if (type === "image") { + return ( + + + + ); + } + if (type === "pdf") { + return ( + + + + ); + } + return ( + + + + ); +}; + +// File Item Component +const FileItem = ({ + displayName, + isAdmin, + isReadOnly, + onDownload, + onDelete, + isDownloading, + isDeleting, +}: { + displayName: string; + isAdmin: boolean; + isReadOnly: boolean; + onDownload: () => void; + onDelete: () => void; + isDownloading: boolean; + isDeleting: boolean; +}) => { + const fileExtension = displayName.split(".").pop()?.toLowerCase() || ""; + const { bgColor, type } = getFileIconProps(displayName); + + return ( +
+ +
+

+ {displayName} +

+

{fileExtension || "Fichier"}

+
+
+ + {isAdmin && !isReadOnly && ( + + )} +
+
+ ); +}; + +// Folder Create/Edit Dialog +const FolderDialog = ({ + isOpen, + onClose, + onSave, + folder, + isLoading, +}: { + isOpen: boolean; + onClose: () => void; + onSave: (name: string, description: string) => void; + folder?: TabloFolder | null; + isLoading: boolean; +}) => { + const [name, setName] = useState(folder?.name || ""); + const [description, setDescription] = useState(folder?.description || ""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (name.trim()) { + onSave(name.trim(), description.trim()); + } + }; + + // Reset form when dialog opens + const handleOpenChange = (open: boolean) => { + if (open) { + setName(folder?.name || ""); + setDescription(folder?.description || ""); + } else { + onClose(); + } + }; + + return ( + + + + {folder ? "Modifier le livrable" : "Nouveau livrable"} + + {folder + ? "Modifiez les informations du livrable attendu." + : "Créez un nouveau livrable attendu pour organiser les fichiers de ce tablo."} + + +
+
+
+ + setName(e.target.value)} + placeholder="ex: Maquettes finales, Documentation technique..." + required + /> +
+
+ + setDescription(e.target.value)} + placeholder="Décrivez ce qui est attendu dans ce livrable..." + /> +
+
+ + + + +
+
+
+ ); +}; + +// Folder Component with collapsible content +const FolderSection = ({ + folder, + files, + isAdmin, + isReadOnly, + isOpen, + onToggle, + onEdit, + onDelete, + onUploadFile, + onDownloadFile, + onDeleteFile, + downloadingFile, + deletingFile, + isUploading, +}: { + folder: TabloFolder; + files: string[]; + isAdmin: boolean; + isReadOnly: boolean; + isOpen: boolean; + onToggle: () => void; + onEdit: () => void; + onDelete: () => void; + onUploadFile: (file: File) => void; + onDownloadFile: (fileName: string) => void; + onDeleteFile: (fileName: string) => void; + downloadingFile: string | null; + deletingFile: string | null; + isUploading: boolean; +}) => { const fileInputRef = useRef(null); - const createFile = useCreateTabloFile(); - const deleteFile = useDeleteTabloFile(); - const downloadFile = useDownloadTabloFile(); - const isReadOnly = useIsReadOnlyUser(); - const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; - // Validate file size (20MB limit) - const maxSize = 20 * 1024 * 1024; // 20MB in bytes + const maxSize = 20 * 1024 * 1024; + if (file.size > maxSize) { + toast.add( + { title: "Erreur", description: "Le fichier ne peut pas dépasser 20MB", type: "error" }, + { timeout: 5000 } + ); + return; + } + + onUploadFile(file); + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + return ( + +
+ + + + + )} + {isOpen ? ( + + ) : ( + + )} +
+ + + +
+ {/* File Upload Area */} +
+ + +
+ + {/* Files List */} + {files.length > 0 ? ( +
+ {files.map((fileName) => ( + onDownloadFile(fileName)} + onDelete={() => onDeleteFile(fileName)} + isDownloading={downloadingFile === fileName} + isDeleting={deletingFile === fileName} + /> + ))} +
+ ) : ( +
+

Aucun fichier dans ce livrable

+
+ )} +
+
+ +
+ ); +}; + +export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => { + const currentUser = useUser(); + const { data: fileData, isLoading: filesLoading } = useTabloFileNames(tablo.id); + const { data: foldersData, isLoading: foldersLoading } = useTabloFolders(tablo.id); + + const [selectedFile, setSelectedFile] = useState(null); + const [isUploading, setIsUploading] = useState(false); + const [uploadingToFolder, setUploadingToFolder] = useState(null); + const [deletingFile, setDeletingFile] = useState(null); + const [downloadingFile, setDownloadingFile] = useState(null); + const [error, setError] = useState(""); + const [openFolders, setOpenFolders] = useState>(new Set()); + const [isFolderDialogOpen, setIsFolderDialogOpen] = useState(false); + const [editingFolder, setEditingFolder] = useState(null); + const fileInputRef = useRef(null); + + const createFile = useCreateTabloFile(); + const deleteFile = useDeleteTabloFile(); + const downloadFile = useDownloadTabloFile(); + const createFolder = useCreateTabloFolder(); + const updateFolder = useUpdateTabloFolder(); + const deleteFolder = useDeleteTabloFolder(); + const isReadOnly = useIsReadOnlyUser(); + + // Organize files by folder + const { filesInFolders, unorganizedFiles } = useMemo(() => { + if (!fileData?.fileNames) { + return { filesInFolders: new Map(), unorganizedFiles: [] }; + } + + const filesInFolders = new Map(); + const unorganizedFiles: string[] = []; + + for (const fileName of fileData.fileNames) { + // Skip metadata files + if (fileName.startsWith(".")) continue; + + const folderId = extractFolderIdFromFileName(fileName); + if (folderId) { + const existing = filesInFolders.get(folderId) || []; + existing.push(fileName); + filesInFolders.set(folderId, existing); + } else { + unorganizedFiles.push(fileName); + } + } + + return { filesInFolders, unorganizedFiles }; + }, [fileData?.fileNames]); + + const folders = foldersData?.folders || []; + + const toggleFolder = (folderId: string) => { + setOpenFolders((prev) => { + const next = new Set(prev); + if (next.has(folderId)) { + next.delete(folderId); + } else { + next.add(folderId); + } + return next; + }); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const maxSize = 20 * 1024 * 1024; if (file.size > maxSize) { setError("Le fichier ne peut pas dépasser 20MB"); return; @@ -52,63 +534,76 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => setSelectedFile(file); }; - const handleFileUpload = async () => { - if (!selectedFile || !tablo.id) return; + const uploadFile = async (file: File, folderId?: string) => { + if (!tablo.id) return; + + const targetFolderId = folderId; + if (targetFolderId) { + setUploadingToFolder(targetFolderId); + } else { + setIsUploading(true); + } - setIsUploading(true); try { const reader = new FileReader(); reader.onload = async (e) => { try { const content = e.target?.result as string; + const fileName = targetFolderId + ? `${getFolderFilePrefix(targetFolderId)}${file.name}` + : file.name; await createFile.mutateAsync({ tabloId: tablo.id, - fileName: selectedFile.name, + fileName, data: { content, - contentType: selectedFile.type || "application/octet-stream", + contentType: file.type || "application/octet-stream", }, }); - // Reset upload state setSelectedFile(null); - setIsUploading(false); if (fileInputRef.current) { fileInputRef.current.value = ""; } } catch (uploadError) { - setIsUploading(false); console.error("Upload error:", uploadError); + } finally { + setIsUploading(false); + setUploadingToFolder(null); } }; reader.onerror = () => { setIsUploading(false); + setUploadingToFolder(null); toast.add( { title: "Erreur de lecture", description: "Impossible de lire le fichier sélectionné", type: "error", }, - { - timeout: 5000, - } + { timeout: 5000 } ); }; - // Read file as base64 data URL for binary files, or as text for text files - if (selectedFile.type.startsWith("text/") || selectedFile.type === "application/json") { - reader.readAsText(selectedFile); + if (file.type.startsWith("text/") || file.type === "application/json") { + reader.readAsText(file); } else { - reader.readAsDataURL(selectedFile); + reader.readAsDataURL(file); } } catch (error) { setIsUploading(false); + setUploadingToFolder(null); console.error("Upload error:", error); } }; + const handleFileUpload = async () => { + if (!selectedFile) return; + await uploadFile(selectedFile); + }; + const cancelFileUpload = () => { setSelectedFile(null); if (fileInputRef.current) { @@ -118,18 +613,14 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => const handleDeleteFile = async (fileName: string) => { if (!tablo.id) return; - - // Simple confirmation - if (!window.confirm(`Êtes-vous sûr de vouloir supprimer le fichier "${fileName}" ?`)) { + const displayName = getFileNameWithoutFolder(fileName); + if (!window.confirm(`Êtes-vous sûr de vouloir supprimer le fichier "${displayName}" ?`)) { return; } setDeletingFile(fileName); try { - await deleteFile.mutateAsync({ - tabloId: tablo.id, - fileName, - }); + await deleteFile.mutateAsync({ tabloId: tablo.id, fileName }); } catch (error) { console.error("Delete error:", error); } finally { @@ -142,10 +633,7 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => setDownloadingFile(fileName); try { - await downloadFile.mutateAsync({ - tabloId: tablo.id, - fileName, - }); + await downloadFile.mutateAsync({ tabloId: tablo.id, fileName }); } catch (error) { console.error("Download error:", error); } finally { @@ -153,13 +641,61 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => } }; + const handleCreateFolder = () => { + setEditingFolder(null); + setIsFolderDialogOpen(true); + }; + + const handleEditFolder = (folder: TabloFolder) => { + setEditingFolder(folder); + setIsFolderDialogOpen(true); + }; + + const handleDeleteFolder = async (folder: TabloFolder) => { + const filesInFolder = filesInFolders.get(folder.id) || []; + const confirmMessage = + filesInFolder.length > 0 + ? `Êtes-vous sûr de vouloir supprimer le livrable "${folder.name}" ? Les ${filesInFolder.length} fichier(s) associé(s) resteront dans le tablo mais ne seront plus organisés.` + : `Êtes-vous sûr de vouloir supprimer le livrable "${folder.name}" ?`; + + if (!window.confirm(confirmMessage)) return; + + await deleteFolder.mutateAsync({ + tabloId: tablo.id, + folderId: folder.id, + folderName: folder.name, + }); + }; + + const handleSaveFolder = async (name: string, description: string) => { + if (editingFolder) { + await updateFolder.mutateAsync({ + tabloId: tablo.id, + folderId: editingFolder.id, + name, + description, + }); + } else { + await createFolder.mutateAsync({ + tabloId: tablo.id, + name, + description, + createdBy: currentUser?.id || "", + }); + } + setIsFolderDialogOpen(false); + setEditingFolder(null); + }; + + const isLoading = filesLoading || foldersLoading; + return (
Fichiers - Gérez les fichiers attachés à ce tablo + Gérez les fichiers et livrables de ce tablo
@@ -198,150 +734,61 @@ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) =>
)} - {/* File Upload Section - Available for all members */} -
-
- - - -

Ajouter un fichier

+ {/* Create Folder Button - Admin Only */} + {isAdmin && !isReadOnly && ( +
+
+ )} - {!selectedFile ? ( -
- - -
- ) : ( -
-
-
- - - -
-
-

- {selectedFile.name} -

-

- {(selectedFile.size / 1024 / 1024).toFixed(2)} MB -

+ ))}
- -
- - -
-
- )} - -

- Taille maximale par fichier: 20MB -

-
- - {/* File List */} -
-

- Liste des fichiers - {fileData?.fileNames && ( - - ({fileData.fileNames.length}) - )} -

- {filesLoading ? ( -
-
- - Chargement des fichiers... - -
- ) : filesError ? ( -
-
+ {/* Unorganized Files Section */} +
+
strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} - d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" + d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> - - Erreur lors du chargement des fichiers - +

+ {folders.length > 0 ? "Autres fichiers" : "Fichiers"} +

+ {unorganizedFiles.length > 0 && ( + ({unorganizedFiles.length}) + )}
-
- ) : fileData && fileData.fileNames && fileData.fileNames.length > 0 ? ( -
- {fileData.fileNames.map((fileName, index) => { - const fileExtension = fileName.split(".").pop()?.toLowerCase() || ""; - const isImage = ["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExtension); - const isPdf = fileExtension === "pdf"; - const isText = ["txt", "md", "json", "csv"].includes(fileExtension); - return ( -
+ + -
-

- {fileName} -

-

- {fileExtension || "Fichier"} -

-
-
- - {isAdmin && !isReadOnly && ( - - )} + + +
+ + Cliquez pour sélectionner un fichier + +
+
+ +
+ ) : ( +
+
+
+ + + +
+
+

+ {selectedFile.name} +

+

+ {(selectedFile.size / 1024 / 1024).toFixed(2)} MB +

- ); - })} -
- ) : ( -
- - - - -

Aucun fichier dans ce tablo

-

- Ajoutez votre premier fichier ci-dessus + +

+ + +
+
+ )} + +

+ Taille maximale par fichier: 20MB

+ + {/* Unorganized Files List */} + {unorganizedFiles.length > 0 && ( +
+ {unorganizedFiles.map((fileName) => ( + handleDownloadFile(fileName)} + onDelete={() => handleDeleteFile(fileName)} + isDownloading={downloadingFile === fileName} + isDeleting={deletingFile === fileName} + /> + ))} +
+ )} + + {/* Empty State */} + {unorganizedFiles.length === 0 && !selectedFile && folders.length === 0 && ( +
+ + + + +

+ Aucun fichier dans ce tablo +

+

+ Ajoutez votre premier fichier ci-dessus +

+
+ )}
- )} -
+ + )} + + {/* Folder Dialog */} + { + setIsFolderDialogOpen(false); + setEditingFolder(null); + }} + onSave={handleSaveFolder} + folder={editingFolder} + isLoading={createFolder.isPending || updateFolder.isPending} + />
); }; diff --git a/apps/main/src/hooks/tablo_data.ts b/apps/main/src/hooks/tablo_data.ts index 1df1633..1112459 100644 --- a/apps/main/src/hooks/tablo_data.ts +++ b/apps/main/src/hooks/tablo_data.ts @@ -140,7 +140,7 @@ export function useCreateTabloFile() { { tabloId: string; fileName: string; data: FileUploadRequest } >({ mutationFn: async ({ tabloId, fileName, data }) => { - const response = await api.post(`/api/v1/tablo-data/${tabloId}/${fileName}`, data); + const response = await api.post(`/api/v1/tablo-data/${tabloId}/file/${fileName}`, data); if (response.status !== 200) { throw new Error("Failed to create file"); } @@ -185,7 +185,7 @@ export function useUpdateTabloFile() { { tabloId: string; fileName: string; data: FileUploadRequest } >({ mutationFn: async ({ tabloId, fileName, data }) => { - const response = await api.put(`/api/v1/tablo-data/${tabloId}/${fileName}`, data); + const response = await api.put(`/api/v1/tablo-data/${tabloId}/file/${fileName}`, data); if (response.status !== 200) { throw new Error("Failed to update file"); } @@ -226,7 +226,7 @@ export function useDeleteTabloFile() { return useMutation({ mutationFn: async ({ tabloId, fileName }) => { - const response = await api.delete(`/api/v1/tablo-data/${tabloId}/${fileName}`); + const response = await api.delete(`/api/v1/tablo-data/${tabloId}/file/${fileName}`); if (response.status !== 200) { throw new Error("Failed to delete file"); } diff --git a/apps/main/src/hooks/tablo_folders.ts b/apps/main/src/hooks/tablo_folders.ts new file mode 100644 index 0000000..2a608b0 --- /dev/null +++ b/apps/main/src/hooks/tablo_folders.ts @@ -0,0 +1,191 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "@xtablo/shared"; +import type { TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types"; +import { useAuthedApi } from "./auth"; + +// Re-export types for convenience +export type { TabloFolder, TabloFoldersMetadata } from "@xtablo/shared-types"; + +const toastOptions = { timeout: 5000 }; + +// Hook to get folders for a tablo +export function useTabloFolders(tabloId: string) { + const api = useAuthedApi(); + + return useQuery({ + queryKey: ["tablo-folders", tabloId], + queryFn: async () => { + const response = await api.get(`/api/v1/tablo-data/${tabloId}/folders`); + if (response.status === 200) { + return response.data; + } + return { folders: [], version: 1 }; + }, + enabled: !!tabloId, + }); +} + +// Hook to create a new folder +export function useCreateTabloFolder() { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation< + TabloFolder, + Error, + { tabloId: string; name: string; description?: string; createdBy: string } + >({ + mutationFn: async ({ tabloId, name, description }) => { + const response = await api.post(`/api/v1/tablo-data/${tabloId}/folders`, { + name, + description, + }); + + if (response.status !== 200) { + throw new Error(response.data?.error || "Failed to create folder"); + } + + return response.data.folder; + }, + onSuccess: (folder) => { + toast.add( + { + title: "Livrable créé", + description: `Le livrable "${folder.name}" a été créé avec succès`, + type: "success", + }, + toastOptions + ); + }, + onError: (error) => { + toast.add( + { + title: "Erreur", + description: `Échec de la création du livrable: ${error.message}`, + type: "error", + }, + toastOptions + ); + }, + onSettled: (_, _err, variables) => { + if (variables) { + queryClient.invalidateQueries({ + queryKey: ["tablo-folders", variables.tabloId], + }); + } + }, + }); +} + +// Hook to update a folder +export function useUpdateTabloFolder() { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation< + TabloFolder, + Error, + { tabloId: string; folderId: string; name?: string; description?: string } + >({ + mutationFn: async ({ tabloId, folderId, name, description }) => { + const response = await api.put(`/api/v1/tablo-data/${tabloId}/folders/${folderId}`, { + name, + description, + }); + + if (response.status !== 200) { + throw new Error(response.data?.error || "Failed to update folder"); + } + + return response.data.folder; + }, + onSuccess: (folder) => { + toast.add( + { + title: "Livrable mis à jour", + description: `Le livrable "${folder.name}" a été mis à jour`, + type: "success", + }, + toastOptions + ); + }, + onError: (error) => { + toast.add( + { + title: "Erreur", + description: `Échec de la mise à jour du livrable: ${error.message}`, + type: "error", + }, + toastOptions + ); + }, + onSettled: (_, _err, variables) => { + if (variables) { + queryClient.invalidateQueries({ + queryKey: ["tablo-folders", variables.tabloId], + }); + } + }, + }); +} + +// Hook to delete a folder +export function useDeleteTabloFolder() { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tabloId, folderId }) => { + const response = await api.delete(`/api/v1/tablo-data/${tabloId}/folders/${folderId}`); + + if (response.status !== 200) { + throw new Error(response.data?.error || "Failed to delete folder"); + } + }, + onSuccess: (_, variables) => { + toast.add( + { + title: "Livrable supprimé", + description: `Le livrable "${variables.folderName}" a été supprimé`, + type: "success", + }, + toastOptions + ); + }, + onError: (error) => { + toast.add( + { + title: "Erreur", + description: `Échec de la suppression du livrable: ${error.message}`, + type: "error", + }, + toastOptions + ); + }, + onSettled: (_, _err, variables) => { + if (variables) { + queryClient.invalidateQueries({ + queryKey: ["tablo-folders", variables.tabloId], + }); + } + }, + }); +} + +// Helper function to get folder path prefix for a file +export const getFolderFilePrefix = (folderId: string) => `${folderId}/`; + +// Helper to extract folder ID from a file name +export const extractFolderIdFromFileName = (fileName: string): string | null => { + const match = fileName.match(/^(folder-[^/]+)\//); + return match ? match[1] : null; +}; + +// Helper to get the actual file name without folder prefix +export const getFileNameWithoutFolder = (fileName: string): string => { + const folderId = extractFolderIdFromFileName(fileName); + if (folderId) { + return fileName.substring(folderId.length + 1); + } + return fileName; +}; diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json index 17a7adf..ac7519d 100644 --- a/packages/shared-types/package.json +++ b/packages/shared-types/package.json @@ -10,6 +10,7 @@ "./database": "./src/database.types.ts", "./events": "./src/events.types.ts", "./tablos": "./src/tablos.types.ts", + "./tablo-data": "./src/tablo-data.types.ts", "./stripe": "./src/stripe.types.ts", "./kanban": "./src/kanban.types.ts", "./utils": "./src/utils.ts" diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 21cc520..c47e09a 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -52,6 +52,10 @@ export type { // ============================================================================ export type { CreateTablo, Tablo, TabloInsert, TabloUpdate, UserTablo } from "./tablos.types.js"; // ============================================================================ +// Tablo Data Types (Files and Folders) +// ============================================================================ +export type { TabloFolder, TabloFoldersMetadata } from "./tablo-data.types.js"; +// ============================================================================ // Utility Types // ============================================================================ export type { diff --git a/packages/shared-types/src/tablo-data.types.ts b/packages/shared-types/src/tablo-data.types.ts new file mode 100644 index 0000000..953c47e --- /dev/null +++ b/packages/shared-types/src/tablo-data.types.ts @@ -0,0 +1,23 @@ +// ============================================================================ +// Tablo Data Types (Files and Folders) +// ============================================================================ + +/** + * Represents a folder within a tablo for organizing files/deliverables + */ +export interface TabloFolder { + id: string; + name: string; + description?: string; + createdAt: string; + createdBy: string; +} + +/** + * Metadata file structure for storing folder information in S3 + */ +export interface TabloFoldersMetadata { + folders: TabloFolder[]; + version: number; +} + From a3f5cf5e4e61a6320bfdd486d31b9c41c6c37e8f Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 21 Feb 2026 14:35:12 +0100 Subject: [PATCH 02/40] Redesign overview dashboard with new project cards and task list - Replace renderTabloListView with reusable ProjectCard / ProjectCardList components - Card layout with status badge, progress bar, date, and delete action - Default view shows 6 tablos with expand/collapse toggle - Add DashboardTaskList component showing tasks assigned to the current user - Toggle done/todo inline; "Add Task" button opens TaskModal with tablo selection - Wire TopBar search input to URL param ?q= to filter tablos on the overview page - Add TopBar component to Layout (was missing) Co-Authored-By: Claude Opus 4.6 --- apps/main/src/components/ActionCard.tsx | 77 ++++ .../src/components/DashboardActionCards.tsx | 62 +++ .../main/src/components/DashboardTaskList.tsx | 191 ++++++++ apps/main/src/components/Layout.tsx | 10 +- apps/main/src/components/NavigationBar.tsx | 26 +- apps/main/src/components/ProjectCard.tsx | 157 +++++++ apps/main/src/components/ProjectCardList.tsx | 78 ++++ apps/main/src/components/TopBar.tsx | 362 +++++++++++++++ apps/main/src/lib/routes.tsx | 5 + apps/main/src/locales/en/navigation.json | 4 +- apps/main/src/locales/en/pages.json | 21 + apps/main/src/locales/fr/navigation.json | 4 +- apps/main/src/locales/fr/pages.json | 21 + apps/main/src/pages/tablo.tsx | 424 +++++++----------- apps/main/src/pages/tablos.tsx | 7 + 15 files changed, 1163 insertions(+), 286 deletions(-) create mode 100644 apps/main/src/components/ActionCard.tsx create mode 100644 apps/main/src/components/DashboardActionCards.tsx create mode 100644 apps/main/src/components/DashboardTaskList.tsx create mode 100644 apps/main/src/components/ProjectCard.tsx create mode 100644 apps/main/src/components/ProjectCardList.tsx create mode 100644 apps/main/src/components/TopBar.tsx create mode 100644 apps/main/src/pages/tablos.tsx diff --git a/apps/main/src/components/ActionCard.tsx b/apps/main/src/components/ActionCard.tsx new file mode 100644 index 0000000..7c6ffd8 --- /dev/null +++ b/apps/main/src/components/ActionCard.tsx @@ -0,0 +1,77 @@ +import { cn } from "@xtablo/shared"; +import { ReactNode } from "react"; + +export interface ActionCardProps { + icon: ReactNode; + label: string; + description: string; + variant?: "primary" | "default"; + isSelected?: boolean; + onClick?: () => void; + className?: string; +} + +export function ActionCard({ + icon, + label, + description, + variant = "default", + isSelected = false, + onClick, + className, +}: ActionCardProps) { + const isPrimary = variant === "primary"; + const isActive = isSelected || isPrimary; + + return ( + + ); +} diff --git a/apps/main/src/components/DashboardActionCards.tsx b/apps/main/src/components/DashboardActionCards.tsx new file mode 100644 index 0000000..55b5406 --- /dev/null +++ b/apps/main/src/components/DashboardActionCards.tsx @@ -0,0 +1,62 @@ +import { FolderPlus, MessageCircle, PlusCircle, UserPlus } from "lucide-react"; +import { useState } from "react"; +import { ActionCard } from "./ActionCard"; + +export interface DashboardActionCardsProps { + onCreateProject?: () => void; + onCreateTask?: () => void; + onInviteTeam?: () => void; + onSendMessage?: () => void; +} + +type CardId = "createProject" | "createTask" | "inviteTeam" | "sendMessage"; + +export function DashboardActionCards({ + onCreateProject, + onCreateTask, + onInviteTeam, + onSendMessage, +}: DashboardActionCardsProps) { + const [selected, setSelected] = useState(null); + + const handleClick = (id: CardId, callback?: () => void) => { + setSelected(id); + callback?.(); + }; + + return ( +
+ } + label="Create Project" + description="Set goals and scope" + isSelected={selected === "createProject"} + onClick={() => handleClick("createProject", onCreateProject)} + /> + + } + label="Create Task" + description="Break work into actions" + isSelected={selected === "createTask"} + onClick={() => handleClick("createTask", onCreateTask)} + /> + + } + label="Invite Team" + description="Add collaborators instantly" + isSelected={selected === "inviteTeam"} + onClick={() => handleClick("inviteTeam", onInviteTeam)} + /> + + } + label="Send Message" + description="Communicate updates fast" + isSelected={selected === "sendMessage"} + onClick={() => handleClick("sendMessage", onSendMessage)} + /> +
+ ); +} diff --git a/apps/main/src/components/DashboardTaskList.tsx b/apps/main/src/components/DashboardTaskList.tsx new file mode 100644 index 0000000..d3211c7 --- /dev/null +++ b/apps/main/src/components/DashboardTaskList.tsx @@ -0,0 +1,191 @@ +import { cn } from "@xtablo/shared"; +import type { KanbanTask, TaskStatus } from "@xtablo/shared-types"; +import { CheckCircle2, Plus } from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { useTablosList } from "../hooks/tablos"; +import { useAllTasks, useUpdateTask } from "../hooks/tasks"; +import { useUser } from "../providers/UserStoreProvider"; +import { TaskModal } from "./kanban/TaskModal"; + +type TaskWithTablo = KanbanTask & { + tablos: { id: string; name: string; color: string | null } | null; +}; + +const STATUS_BADGE: Record< + TaskStatus, + { className: string; labelKey: string } +> = { + todo: { + className: "bg-blue-50 text-blue-600", + labelKey: "dashboard.taskList.status.todo", + }, + in_progress: { + className: "bg-yellow-50 text-yellow-600", + labelKey: "dashboard.taskList.status.inProgress", + }, + in_review: { + className: "bg-purple-50 text-purple-600", + labelKey: "dashboard.taskList.status.inReview", + }, + done: { + className: "bg-green-50 text-green-600", + labelKey: "dashboard.taskList.status.done", + }, +}; + +function TaskRow({ + task, + onToggleDone, +}: { + task: TaskWithTablo; + onToggleDone: (task: TaskWithTablo) => void; +}) { + const { t } = useTranslation("pages"); + const navigate = useNavigate(); + const status = task.status ?? "todo"; + const isDone = status === "done"; + const badge = STATUS_BADGE[status]; + + const dateStr = task.updated_at ?? task.created_at; + const formattedDate = dateStr + ? new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(dateStr)) + : ""; + + return ( +
{ + if (task.tablos) { + navigate(`/tablos/${task.tablos.id}?section=tasks`); + } + }} + > + {/* Checkbox + Title */} +
+ +

+ {task.title} +

+
+ + {/* Tablo */} + {task.tablos && ( +
+
+ + {task.tablos.name.charAt(0).toUpperCase()} + +
+ + {task.tablos.name} + +
+ )} + + {/* Date */} + + {formattedDate} + + + {/* Status badge */} + + {t(badge.labelKey)} + +
+ ); +} + +export function DashboardTaskList() { + const { t } = useTranslation("pages"); + const user = useUser(); + const { data: allTasks } = useAllTasks(); + const { data: tablos } = useTablosList(); + const updateTask = useUpdateTask(); + const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); + + // Filter to tasks assigned to the current user, limited to recent ones + const myTasks = + allTasks + ?.filter((task) => task.assignee_id === user.id) + .slice(0, 7) ?? []; + + const handleToggleDone = (task: TaskWithTablo) => { + const newStatus: TaskStatus = + task.status === "done" ? "todo" : "done"; + updateTask.mutate({ id: task.id, status: newStatus }); + }; + + if (myTasks.length === 0) return null; + + return ( + <> +
+
+

+ {t("dashboard.taskList.title")} +

+ +
+
+
+ {myTasks.map((task) => ( + + ))} +
+
+
+ + setIsTaskModalOpen(false)} + tablos={tablos} + allowTabloSelection + initialStatus="todo" + /> + + ); +} diff --git a/apps/main/src/components/Layout.tsx b/apps/main/src/components/Layout.tsx index 48d63cb..3c4f7f8 100644 --- a/apps/main/src/components/Layout.tsx +++ b/apps/main/src/components/Layout.tsx @@ -5,6 +5,7 @@ import { Outlet } from "react-router-dom"; import { twMerge } from "tailwind-merge"; import { SideNavigation } from "./NavigationBar"; import { OnboardingModal } from "./OnboardingModal"; +import { TopBar } from "./TopBar"; const ONBOARDING_STORAGE_KEY = "xtablo-onboarding-completed"; @@ -50,9 +51,12 @@ export function Layout() {
-
- -
+
+ +
+ +
+
); } diff --git a/apps/main/src/components/NavigationBar.tsx b/apps/main/src/components/NavigationBar.tsx index fef8ca2..bf3bac3 100644 --- a/apps/main/src/components/NavigationBar.tsx +++ b/apps/main/src/components/NavigationBar.tsx @@ -21,6 +21,7 @@ import { CreditCard, // FileTextIcon, // Notes feature temporarily hidden Kanban, + LayersIcon, ListTodo, LogOutIcon, MessageCircleIcon, @@ -42,7 +43,6 @@ import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe"; import { isProd, isStaging } from "../lib/env"; import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider"; import { getXtabloIcon } from "../utils/iconHelpers"; -import { NotificationPanel } from "./NotificationPanel"; import { ThemeSwitcher } from "./ThemeSwitcher"; type NavLinkItem = { @@ -56,7 +56,7 @@ function NavLink({ isActive, children }: NavLinkProps) { return (
[data-ui=icon]:not([class*=size-])]:size-4.5", + "group w-full gap-x-3 overflow-hidden px-2.5 py-2 text-nowrap hover:bg-navbar-darker hover:no-underline focus-visible:outline-offset-0 [&>[data-ui=icon]:not([class*=size-])]:size-5", "*:data-[ui=notification-badge]:bg-navbar-darker", "*:data-[ui=notification-badge]:rounded-md", "*:data-[ui=notification-badge]:top-1/2", @@ -282,7 +282,6 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean isCollapsed ? "pl-2.5 pr-3.5" : "" )} > -
@@ -322,7 +321,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { ? [ { path: "/", - label: t("projects"), + label: t("home"), icon: , }, { @@ -334,7 +333,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { : [ { path: "/", - label: t("projects"), + label: t("home"), icon: , }, { @@ -355,6 +354,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { label: t("tasks"), icon: , }, + { + path: "/tablos", + label: t("tablos"), + icon: , + }, { isHorizontalBar: true }, { path: "/planning", @@ -396,11 +400,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { className="w-full" aria-label={isCollapsed ? label : undefined} > -
- {icon} +
+ {icon} -
-