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..2e18619 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"); @@ -20,19 +36,55 @@ const getTabloFilenames = factory.createHandlers(checkTabloMember, async (c) => } }); +// Returns file names for all tablos the authenticated user has access to, in one request +const getAllTablosFilenames = factory.createHandlers(async (c) => { + const supabase = c.get("supabase"); + const user = c.get("user"); + const s3_client = c.get("s3_client"); + + try { + const { data: tabloAccess, error } = await supabase + .from("tablo_access") + .select("tablo_id") + .eq("user_id", user.id) + .eq("is_active", true); + + if (error) { + return c.json({ error: "Failed to fetch tablos" }, 500); + } + + const tabloIds = (tabloAccess ?? []).map((row: { tablo_id: string }) => row.tablo_id); + + const results = await Promise.all( + tabloIds.map(async (tabloId: string) => { + const fileNames = await getTabloFileNames(s3_client, tabloId); + return { tabloId, fileNames: fileNames ?? [] }; + }) + ); + + return c.json({ tablos: results }); + } catch (error) { + console.error("Error fetching all tablo files:", error); + return c.json({ error: "Failed to fetch all tablo files" }, 500); + } +}); + 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 +95,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 +108,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 +128,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 +136,7 @@ const postTabloFile = factory.createHandlers(checkTabloMember, async (c) => { return c.json({ message: "File uploaded successfully", - fileName, + fileName: filePath, tabloId, }); } catch (error) { @@ -88,61 +145,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 +176,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 +371,25 @@ export const getTabloDataRouter = () => { tabloDataRouter.use(middlewareManager.streamChat); tabloDataRouter.use(middlewareManager.r2); + // All-tablos file listing (must be before /:tabloId routes) + tabloDataRouter.get("/all-filenames", ...getAllTablosFilenames); + + // 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/ActionCard.tsx b/apps/main/src/components/ActionCard.tsx new file mode 100644 index 0000000..7336c78 --- /dev/null +++ b/apps/main/src/components/ActionCard.tsx @@ -0,0 +1,91 @@ +import { cn } from "@xtablo/shared"; +import { ReactNode } from "react"; + +export interface ActionCardProps { + icon: ReactNode; + label: string; + description: string; + variant?: "primary" | "default"; + isSelected?: boolean; + disabled?: boolean; + badge?: string; + onClick?: () => void; + className?: string; +} + +export function ActionCard({ + icon, + label, + description, + variant = "default", + isSelected = false, + disabled = false, + badge, + onClick, + className, +}: ActionCardProps) { + const isPrimary = variant === "primary"; + const isActive = !disabled && (isSelected || isPrimary); + + return ( + + ); +} diff --git a/apps/main/src/components/ChannelBadge.tsx b/apps/main/src/components/ChannelBadge.tsx index 9dbdc51..bfd0fd4 100644 --- a/apps/main/src/components/ChannelBadge.tsx +++ b/apps/main/src/components/ChannelBadge.tsx @@ -17,7 +17,7 @@ export const ChannelBadge = ({
diff --git a/apps/main/src/components/ChannelPreview.tsx b/apps/main/src/components/ChannelPreview.tsx index b464145..abe2c03 100644 --- a/apps/main/src/components/ChannelPreview.tsx +++ b/apps/main/src/components/ChannelPreview.tsx @@ -85,7 +85,7 @@ export function ChannelPreview({
{displayTitle} @@ -131,7 +131,7 @@ export function ChannelPreview({ {/* Active indicator */} {isActive && ( -
+
)}
); diff --git a/apps/main/src/components/DashboardActionCards.tsx b/apps/main/src/components/DashboardActionCards.tsx new file mode 100644 index 0000000..515446e --- /dev/null +++ b/apps/main/src/components/DashboardActionCards.tsx @@ -0,0 +1,50 @@ +import { FolderPlus, MessageCircle, PlusCircle, UserPlus } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { ActionCard } from "./ActionCard"; + +export interface DashboardActionCardsProps { + onCreateProject?: () => void; + onCreateTask?: () => void; + onSendMessage?: () => void; +} + +export function DashboardActionCards({ + onCreateProject, + onCreateTask, + onSendMessage, +}: DashboardActionCardsProps) { + const { t } = useTranslation("pages"); + + return ( +
+ } + label={t("dashboard.actionCards.createProject.label")} + description={t("dashboard.actionCards.createProject.description")} + onClick={onCreateProject} + /> + + } + label={t("dashboard.actionCards.createTask.label")} + description={t("dashboard.actionCards.createTask.description")} + onClick={onCreateTask} + /> + + } + label={t("dashboard.actionCards.inviteTeam.label")} + description={t("dashboard.actionCards.inviteTeam.description")} + disabled + badge="Bientôt" + /> + + } + label={t("dashboard.actionCards.sendMessage.label")} + description={t("dashboard.actionCards.sendMessage.description")} + onClick={onSendMessage} + /> +
+ ); +} diff --git a/apps/main/src/components/DashboardTaskList.tsx b/apps/main/src/components/DashboardTaskList.tsx new file mode 100644 index 0000000..c3f71c1 --- /dev/null +++ b/apps/main/src/components/DashboardTaskList.tsx @@ -0,0 +1,199 @@ +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 dark:bg-blue-950/30 dark:text-blue-400", + labelKey: "dashboard.taskList.status.todo", + }, + in_progress: { + className: + "bg-yellow-50 text-yellow-600 dark:bg-yellow-950/30 dark:text-yellow-400", + labelKey: "dashboard.taskList.status.inProgress", + }, + in_review: { + className: + "bg-purple-50 text-purple-600 dark:bg-purple-950/30 dark:text-purple-400", + labelKey: "dashboard.taskList.status.inReview", + }, + done: { + className: + "bg-green-50 text-green-600 dark:bg-green-950/30 dark:text-green-400", + 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/EventModal.tsx b/apps/main/src/components/EventModal.tsx index 6f35b98..200cc1c 100644 --- a/apps/main/src/components/EventModal.tsx +++ b/apps/main/src/components/EventModal.tsx @@ -29,7 +29,19 @@ import { useCreateEvents, useEvent, useUpdateEvent } from "../hooks/events"; import { useTablosList } from "../hooks/tablos"; import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider"; -export const EventModal = ({ mode }: { mode: "create" | "edit" }) => { +export const EventModal = ({ + mode, + isOpen, + onClose: onCloseProp, + defaultTabloId, + defaultDate, +}: { + mode: "create" | "edit"; + isOpen?: boolean; + onClose?: () => void; + defaultTabloId?: string; + defaultDate?: Date; +}) => { const { t, i18n } = useTranslation("components"); const { event_id } = useParams(); const { data: event } = useEvent(event_id as string); @@ -37,17 +49,24 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => { const user = useUser(); const isReadOnly = useIsReadOnlyUser(); const [searchParams] = useSearchParams(); - const tablo_id = searchParams.get("tablo_id"); - const dateFromParams = searchParams.get("date"); - const date = dateFromParams ? new Date(dateFromParams) : new Date(); + const navigate = useNavigate(); + + // When used standalone (isOpen prop provided), ignore URL params and use props instead + const isStandalone = isOpen !== undefined; + const tablo_id = isStandalone ? (defaultTabloId ?? "") : (searchParams.get("tablo_id") ?? ""); + const dateFromParams = isStandalone ? null : searchParams.get("date"); + const date = defaultDate ?? (dateFromParams ? new Date(dateFromParams) : new Date()); const { data: tablos, isLoading: tablosLoading } = useTablosList(); const createEvents = useCreateEvents(); const updateEvent = useUpdateEvent(); - const navigate = useNavigate(); const onClose = () => { - navigate(-1); + if (onCloseProp) { + onCloseProp(); + } else { + navigate(-1); + } }; // Get the local date string without timezone conversion @@ -93,7 +112,7 @@ export const EventModal = ({ mode }: { mode: "create" | "edit" }) => { }, [mode, event]); return ( - + diff --git a/apps/main/src/components/ExceptionModal.tsx b/apps/main/src/components/ExceptionModal.tsx index 2ef661d..65acae3 100644 --- a/apps/main/src/components/ExceptionModal.tsx +++ b/apps/main/src/components/ExceptionModal.tsx @@ -41,7 +41,8 @@ export const ExceptionModal = ({ }) => { const { t } = useTranslation("components"); const form = useForm>({ - resolver: zodResolver(formSchema), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolver: zodResolver(formSchema as any), defaultValues: { exceptionType: "day", exceptionDate: new Date(), 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..0aebea1 100644 --- a/apps/main/src/components/NavigationBar.tsx +++ b/apps/main/src/components/NavigationBar.tsx @@ -17,10 +17,17 @@ import { CalendarCheckIcon, CalendarIcon, Circle, + Compass, ConstructionIcon, CreditCard, // FileTextIcon, // Notes feature temporarily hidden + Flame, + FolderIcon, + Gem, + Heart, Kanban, + LayersIcon, + Leaf, ListTodo, LogOutIcon, MessageCircleIcon, @@ -31,7 +38,12 @@ import { SettingsIcon, Sparkles, SquareKanban, + Star, + Sun, + Waves, + Zap, } from "lucide-react"; +import { useTablosList } from "../hooks/tablos"; import { useState } from "react"; import { Separator } from "react-aria-components"; import { useTranslation } from "react-i18next"; @@ -42,7 +54,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 +67,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", @@ -67,8 +78,8 @@ function NavLink({ isActive, children }: NavLinkProps) { "*:data-[ui=notification-badge]:text-xs/6", "*:data-[ui=notification-badge]:font-semibold", isActive - ? "bg-navbar-darker font-semibold text-white *:data-[ui=notification-badge]:bg-transparent" - : ["font-medium", "text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker"] + ? "bg-purple-100 dark:bg-purple-900/30 font-semibold text-[#804EEC] dark:text-purple-300 *:data-[ui=notification-badge]:bg-transparent" + : ["font-medium", "text-gray-500 dark:text-gray-300/90 [&:not(:hover)>[data-ui=icon]]:bg-navbar-darker"] )} > {children} @@ -82,13 +93,13 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) { const { t } = useTranslation("navigation"); const MenuSeparator = () => { - return ; + return ; }; const itemVariants = cva("", { variants: { variant: { - default: "text-gray-200/90 focus:bg-gray-500/80 focus:text-white", + default: "text-gray-600 dark:text-gray-200/90 focus:bg-gray-200/80 dark:focus:bg-gray-500/80 focus:text-gray-900 dark:focus:text-white", destructive: "text-red-500/80 focus:bg-red-500/80 focus:text-white", }, }, @@ -134,10 +145,10 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) { {!isCollapsed && (
- + {user.first_name} {user.last_name} - + {user.email}
@@ -145,7 +156,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) { - + {user.name?.charAt(0).toUpperCase()} @@ -161,10 +172,10 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
- + {user.name} - + {user.email}
@@ -246,7 +257,7 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean />

@@ -263,7 +274,7 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean className={twMerge( isCollapsed ? "relative" : "absolute top-2 right-2", "size-5 p-1", - "text-gray-300 hover:text-white", + "text-gray-500 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white", "transition-all duration-300", "bg-navbar-background", "rounded-full shadow-md", @@ -282,13 +293,80 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean isCollapsed ? "pl-2.5 pr-3.5" : "" )} > -

); }; +function getTabloIcon(color: string | null | undefined) { + switch (color) { + case "bg-blue-500": return Zap; + case "bg-green-500": return Leaf; + case "bg-purple-500": return Gem; + case "bg-red-500": return Flame; + case "bg-yellow-500": return Star; + case "bg-indigo-500": return Compass; + case "bg-pink-500": return Heart; + case "bg-teal-500": return Waves; + case "bg-orange-500": return Sun; + case "bg-cyan-500": return Sparkles; + default: return FolderIcon; + } +} + +function getTabloIconColor(_color: string | null | undefined): string { + return "text-gray-700 dark:text-white"; +} + +function RecentProjectsSection() { + const { t } = useTranslation("navigation"); + const location = useLocation(); + const { data: tablos } = useTablosList(); + const recentTablos = (tablos ?? []).slice(0, 4); + + if (recentTablos.length === 0) return null; + + return ( +
+ +
+ + {t("projects", "Projects")} + +
+
    + {recentTablos.map((tablo) => { + const isActive = location.pathname === `/tablos/${tablo.id}`; + const TabloIcon = getTabloIcon(tablo.color); + const iconColor = getTabloIconColor(tablo.color); + return ( +
  • + + + + + {tablo.name} + +
  • + ); + })} +
+
+ ); +} + export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { const location = useLocation(); const isReadOnly = useIsReadOnlyUser(); @@ -322,7 +400,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { ? [ { path: "/", - label: t("projects"), + label: t("home"), icon: , }, { @@ -334,7 +412,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { : [ { path: "/", - label: t("projects"), + label: t("home"), icon: , }, { @@ -355,6 +433,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { label: t("tasks"), icon: , }, + { + path: "/tablos", + label: t("tablos"), + icon: , + }, { isHorizontalBar: true }, { path: "/planning", @@ -366,6 +449,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { label: t("discussions"), icon: , }, + { + path: "/files", + label: t("files", "Fichiers"), + icon: , + }, // Notes feature temporarily hidden // { // path: "/notes", @@ -380,7 +468,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { if ("isHorizontalBar" in item) { return (
  • - +
  • ); } @@ -396,12 +484,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { className="w-full" aria-label={isCollapsed ? label : undefined} > -
    - {icon} +
    + {icon} @@ -414,6 +502,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { ) : null; })} + + {/* Recent projects section */} + {!isCollapsed && } +
      {/* Trial upsell message */} {shouldShowTrialUpsell && !isCollapsed && ( @@ -497,7 +589,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { Plan Freemium

      - Passer au plan Starter pour profiter de tablos illimités. + Passer au plan Starter pour profiter de projets illimités.

    @@ -555,12 +647,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { className="w-full" aria-label={isCollapsed ? "Feedback" : undefined} > -
    -