Merge pull request #58 from artslidd/develop

develop
This commit is contained in:
Arthur Belleville 2026-02-27 09:23:05 +01:00 committed by GitHub
commit f2a35a85dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 9060 additions and 2320 deletions

View file

@ -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<string> => {
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<string> => {
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);
});
});
});
});

View file

@ -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
);
};

View file

@ -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,
});

View file

@ -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<AuthEnv>();
// 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<typeof MiddlewareManager.getInstance>) =>
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<typeof MiddlewareManager.
}
});
// ============================================
// FOLDER ENDPOINTS
// ============================================
// Helper to get or create folder metadata
const getFolderMetadata = async (
s3_client: S3Client,
tabloId: string
): Promise<TabloFoldersMetadata> => {
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<typeof MiddlewareManager.getInstance>) =>
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<typeof MiddlewareManager.getInstance>) =>
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<typeof MiddlewareManager.getInstance>) =>
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));

View file

@ -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 (
<button
onClick={disabled ? undefined : onClick}
disabled={disabled}
className={cn(
"h-fit p-3 rounded-2xl text-left transition-all",
disabled
? "bg-white dark:bg-gray-800 border border-[#EAECF0] dark:border-gray-700 opacity-50 cursor-not-allowed"
: isSelected
? "bg-[rgb(128,78,236)] text-white border-transparent shadow-lg"
: isPrimary
? "bg-primary text-white hover:shadow-lg"
: "bg-white dark:bg-gray-800 border border-[#EAECF0] dark:border-gray-700 hover:shadow-md",
className,
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"w-10 h-10 rounded-[8px] flex items-center justify-center flex-shrink-0",
isActive ? "bg-white/20" : "bg-[#F4F3FF] dark:bg-purple-900/20",
)}
>
<span
className={cn(
"w-6 h-6 flex items-center justify-center",
isActive ? "text-white" : "text-[#7F56D9]",
)}
>
{icon}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
"font-semibold text-lg leading-tight",
isActive ? "text-white" : "text-gray-900 dark:text-gray-100",
)}
>
{label}
</span>
{badge && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 leading-none shrink-0">
{badge}
</span>
)}
</div>
<p
className={cn(
"text-sm mt-0.5",
isActive ? "text-purple-100" : "text-gray-500 dark:text-gray-400",
)}
>
{description}
</p>
</div>
</div>
</button>
);
}

View file

@ -17,7 +17,7 @@ export const ChannelBadge = ({
<div className="relative">
<div
className={twMerge(
"size-12 rounded-full flex items-center justify-center text-white font-semibold text-sm",
"size-12 rounded-full flex items-center justify-center text-white font-semibold text-sm bg-[#804EEC]",
tablo?.color && tablo.color
)}
>

View file

@ -85,7 +85,7 @@ export function ChannelPreview({
<div
className={twMerge(
"group relative flex items-center gap-3 p-3 cursor-pointer transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800",
isActive && "bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800",
isActive && "bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800",
className
)}
onClick={handleClick}
@ -98,7 +98,7 @@ export function ChannelPreview({
<h3
className={twMerge(
"font-medium text-gray-900 dark:text-gray-100 truncate",
isActive && "text-blue-600 dark:text-blue-400"
isActive && "text-[#804EEC] dark:text-purple-400"
)}
>
{displayTitle}
@ -131,7 +131,7 @@ export function ChannelPreview({
{/* Active indicator */}
{isActive && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-blue-500 dark:bg-blue-400 rounded-r-full" />
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#804EEC] dark:bg-purple-400 rounded-r-full" />
)}
</div>
);

View file

@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-5">
<ActionCard
icon={<FolderPlus className="w-6 h-6" />}
label={t("dashboard.actionCards.createProject.label")}
description={t("dashboard.actionCards.createProject.description")}
onClick={onCreateProject}
/>
<ActionCard
icon={<PlusCircle className="w-6 h-6" />}
label={t("dashboard.actionCards.createTask.label")}
description={t("dashboard.actionCards.createTask.description")}
onClick={onCreateTask}
/>
<ActionCard
icon={<UserPlus className="w-6 h-6" />}
label={t("dashboard.actionCards.inviteTeam.label")}
description={t("dashboard.actionCards.inviteTeam.description")}
disabled
badge="Bientôt"
/>
<ActionCard
icon={<MessageCircle className="w-6 h-6" />}
label={t("dashboard.actionCards.sendMessage.label")}
description={t("dashboard.actionCards.sendMessage.description")}
onClick={onSendMessage}
/>
</div>
);
}

View file

@ -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 (
<div
className="grid grid-cols-[auto_1fr_1fr_auto_auto] items-center gap-4 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors border-b border-gray-200 dark:border-gray-700 cursor-pointer"
onClick={() => {
if (task.tablos) {
navigate(`/tablos/${task.tablos.id}?section=tasks`);
}
}}
>
{/* Checkbox */}
<button
className={cn(
"w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0",
isDone
? "bg-purple-600 border-purple-600"
: "border-gray-300 hover:border-purple-400 dark:border-gray-600 dark:hover:border-purple-500",
)}
onClick={(e) => {
e.stopPropagation();
onToggleDone(task);
}}
>
{isDone && <CheckCircle2 className="w-4 h-4 text-white" />}
</button>
{/* Title */}
<p
className={cn(
"text-sm font-medium truncate",
isDone
? "line-through text-gray-400 dark:text-gray-500"
: "text-gray-900 dark:text-gray-100",
)}
>
{task.title}
</p>
{/* Tablo */}
<div className="flex items-center gap-2 min-w-0">
{task.tablos && (
<>
<div
className={cn(
"w-6 h-6 rounded-lg flex items-center justify-center text-xs shrink-0",
task.tablos.color || "bg-gray-400",
)}
>
<span className="text-white font-bold text-[10px]">
{task.tablos.name.charAt(0).toUpperCase()}
</span>
</div>
<span className="text-sm text-gray-700 dark:text-gray-300 hidden sm:inline truncate">
{task.tablos.name}
</span>
</>
)}
</div>
{/* Date */}
<span className="text-sm text-gray-500 dark:text-gray-400 hidden md:inline whitespace-nowrap">
{formattedDate}
</span>
{/* Status badge */}
<span
className={cn(
"px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap",
badge.className,
)}
>
{t(badge.labelKey)}
</span>
</div>
);
}
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 (
<>
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-100 dark:border-gray-700">
<div className="flex items-center justify-between px-4 py-5 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{t("dashboard.taskList.title")}
</h2>
<button
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
onClick={() => setIsTaskModalOpen(true)}
>
<Plus className="w-4 h-4" />
<span>{t("dashboard.taskList.addTask")}</span>
</button>
</div>
<div className="overflow-x-auto">
<div className="min-w-[600px]">
{myTasks.map((task) => (
<TaskRow
key={task.id}
task={task}
onToggleDone={handleToggleDone}
/>
))}
</div>
</div>
</div>
<TaskModal
isOpen={isTaskModalOpen}
onClose={() => setIsTaskModalOpen(false)}
tablos={tablos}
allowTabloSelection
initialStatus="todo"
/>
</>
);
}

View file

@ -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 (
<Dialog open={true} onOpenChange={onClose}>
<Dialog open={isStandalone ? isOpen : true} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>

View file

@ -41,7 +41,8 @@ export const ExceptionModal = ({
}) => {
const { t } = useTranslation("components");
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolver: zodResolver(formSchema as any),
defaultValues: {
exceptionType: "day",
exceptionDate: new Date(),

View file

@ -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() {
<SideNavigation isMobileMenuOpen={isMobileMenuOpen} />
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
<div className="flex flex-col flex-1 overflow-hidden">
<TopBar />
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
}

View file

@ -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 (
<div
className={twMerge(
"group w-full gap-x-3 overflow-hidden px-2.5 py-1.5 text-nowrap hover:bg-navbar-darker hover:no-underline focus-visible:outline-offset-0 [&>[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 <DropdownMenuSeparator className="bg-gray-500!" />;
return <DropdownMenuSeparator className="bg-gray-300 dark:bg-gray-500!" />;
};
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 }) {
</Avatar>
{!isCollapsed && (
<div className="flex flex-col items-start">
<TypographyMuted className="text-gray-300/90 transition-all duration-300 ml-1 truncate font-medium overflow-hidden text-ellipsis">
<TypographyMuted className="text-gray-700 dark:text-gray-300/90 transition-all duration-300 ml-1 truncate font-medium overflow-hidden text-ellipsis">
{user.first_name} {user.last_name}
</TypographyMuted>
<TypographyMuted className="text-gray-400/90 transition-all duration-300 ml-1 text-xs truncate overflow-hidden text-ellipsis">
<TypographyMuted className="text-gray-500 dark:text-gray-400/90 transition-all duration-300 ml-1 text-xs truncate overflow-hidden text-ellipsis">
{user.email}
</TypographyMuted>
</div>
@ -145,7 +156,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 bg-navbar-background border-gray-600/50 p-1 rounded-lg text-white"
className="min-w-56 bg-navbar-background border-gray-300 dark:border-gray-600/50 p-1 rounded-lg text-gray-900 dark:text-white"
side="right"
align="end"
sideOffset={-8}
@ -153,7 +164,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
<div className="flex gap-2 p-1">
<Avatar className="size-8">
<AvatarImage src={user.avatar_url ?? undefined} alt={user.name ?? "User avatar"} />
<AvatarFallback className="bg-gray-700 text-white">
<AvatarFallback className="bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-white">
{user.name?.charAt(0).toUpperCase()}
</AvatarFallback>
<AvatarBadge className="size-3">
@ -161,10 +172,10 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
</AvatarBadge>
</Avatar>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<TypographyMuted className="font-bold text-gray-100 text-sm truncate">
<TypographyMuted className="font-bold text-gray-800 dark:text-gray-100 text-sm truncate">
{user.name}
</TypographyMuted>
<TypographyMuted className="text-gray-300 text-xs truncate">
<TypographyMuted className="text-gray-500 dark:text-gray-300 text-xs truncate">
{user.email}
</TypographyMuted>
</div>
@ -246,7 +257,7 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
/>
<h1
className={twMerge(
"text-lg font-bold transition-all duration-300 text-white whitespace-nowrap",
"text-lg font-bold transition-all duration-300 text-gray-900 dark:text-white whitespace-nowrap",
isCollapsed ? "w-0 h-0 opacity-0" : "w-auto opacity-100"
)}
>
@ -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" : ""
)}
>
<NotificationPanel isCollapsed={isCollapsed} />
<UserMenuPopover isCollapsed={isCollapsed} />
</div>
</nav>
);
};
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 (
<div className="px-2 pb-2">
<Separator className="border-gray-300 dark:border-gray-300/20 mb-3" />
<div className="px-2 mb-2">
<span className="text-[10px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{t("projects", "Projects")}
</span>
</div>
<ul className="space-y-0.5">
{recentTablos.map((tablo) => {
const isActive = location.pathname === `/tablos/${tablo.id}`;
const TabloIcon = getTabloIcon(tablo.color);
const iconColor = getTabloIconColor(tablo.color);
return (
<li key={tablo.id}>
<RouterLink
to={`/tablos/${tablo.id}`}
className={twMerge(
"flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-sm transition-colors",
isActive
? "bg-purple-100 dark:bg-purple-900/30 text-[#804EEC] dark:text-purple-300 font-semibold"
: "text-gray-500 dark:text-gray-300/90 hover:bg-navbar-darker hover:text-gray-900 dark:hover:text-white"
)}
>
<span
className="w-6 h-6 rounded-full shrink-0 flex items-center justify-center border border-gray-400 dark:border-gray-500/50"
style={{ backgroundColor: tablo.color ?? "#6b7280" }}
>
<TabloIcon className={twMerge("w-3.5 h-3.5", iconColor)} />
</span>
<span className="truncate flex-1">{tablo.name}</span>
</RouterLink>
</li>
);
})}
</ul>
</div>
);
}
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: <PanelsTopLeft className="w-5 h-5" />,
},
{
@ -334,7 +412,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
: [
{
path: "/",
label: t("projects"),
label: t("home"),
icon: <PanelsTopLeft className="w-5 h-5" />,
},
{
@ -355,6 +433,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
label: t("tasks"),
icon: <ListTodo className="w-5 h-5" />,
},
{
path: "/tablos",
label: t("tablos"),
icon: <LayersIcon className="w-5 h-5" />,
},
{ isHorizontalBar: true },
{
path: "/planning",
@ -366,6 +449,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
label: t("discussions"),
icon: <MessageCircleIcon className="w-5 h-5" />,
},
{
path: "/files",
label: t("files", "Fichiers"),
icon: <FolderIcon className="w-5 h-5" />,
},
// Notes feature temporarily hidden
// {
// path: "/notes",
@ -380,7 +468,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
if ("isHorizontalBar" in item) {
return (
<li key={`horizontal-bar-${index}`} className="my-2">
<Separator className="border-gray-300/20" />
<Separator className="border-gray-300 dark:border-gray-300/20" />
</li>
);
}
@ -396,12 +484,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
className="w-full"
aria-label={isCollapsed ? label : undefined}
>
<div className={twMerge("flex items-center gap-x-2", isCollapsed ? "" : "pl-2")}>
{icon}
<div className={twMerge("flex items-center gap-x-2.5", isCollapsed ? "" : "pl-2")}>
<span className="[&>svg]:w-6 [&>svg]:h-6">{icon}</span>
<TypographyLarge
className={twMerge(
"text-sm transition-all duration-300 font-normal",
isActive ? "text-white" : "text-gray-300/90",
"text-base transition-all duration-300 font-normal",
isActive ? "text-[#804EEC] dark:text-purple-300" : "text-gray-500 dark:text-gray-300/90",
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
)}
>
@ -414,6 +502,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
) : null;
})}
</ul>
{/* Recent projects section */}
{!isCollapsed && <RecentProjectsSection />}
<ul role="list" className={twMerge("mt-auto grid py-1", isCollapsed ? "pl-2.5 pr-3" : "")}>
{/* Trial upsell message */}
{shouldShowTrialUpsell && !isCollapsed && (
@ -497,7 +589,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
Plan Freemium
</p>
<p className="text-xs mt-0.5 text-blue-700 dark:text-blue-300">
Passer au plan Starter pour profiter de tablos illimités.
Passer au plan Starter pour profiter de projets illimités.
</p>
</div>
</div>
@ -555,12 +647,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
className="w-full"
aria-label={isCollapsed ? "Feedback" : undefined}
>
<div className={twMerge("flex items-center gap-x-2", isCollapsed ? "" : "pl-2")}>
<SendIcon className="w-5 h-5" aria-hidden="true" />
<div className={twMerge("flex items-center gap-x-2.5", isCollapsed ? "" : "pl-2")}>
<span className="[&>svg]:w-6 [&>svg]:h-6"><SendIcon aria-hidden="true" /></span>
<TypographyLarge
className={twMerge(
"text-sm transition-all duration-300 font-normal",
location.pathname === "/feedback" ? "text-white" : "text-gray-300/90",
"text-base transition-all duration-300 font-normal",
location.pathname === "/feedback" ? "text-gray-900 dark:text-white" : "text-gray-500 dark:text-gray-300/90",
isCollapsed ? "opacity-0 w-0 hidden" : "opacity-100"
)}
>
@ -569,9 +661,9 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
</div>
</RouterLink>
</NavLink>
<li className="my-2">
<Separator className="border-gray-300/20" />
</li>
</li>
<li className="my-2">
<Separator className="border-gray-300 dark:border-gray-300/20" />
</li>
</ul>
</nav>

View file

@ -0,0 +1,165 @@
import { cn } from "@xtablo/shared";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Calendar, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
type StatusConfig = {
label: string;
badgeClass: string;
progressColor: string;
};
function useStatusConfig(status: string): StatusConfig {
const { t } = useTranslation("pages");
switch (status) {
case "todo":
return {
label: t("tablo.status.todo"),
badgeClass:
"bg-blue-50 text-blue-600 border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800",
progressColor: "bg-blue-500",
};
case "in_progress":
return {
label: t("tablo.status.inProgress"),
badgeClass:
"bg-yellow-50 text-yellow-600 border-yellow-200 dark:bg-yellow-950/30 dark:text-yellow-400 dark:border-yellow-800",
progressColor: "bg-purple-500",
};
case "done":
return {
label: t("tablo.status.done"),
badgeClass:
"bg-green-50 text-green-600 border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800",
progressColor: "bg-green-500",
};
default:
return {
label: t("tablo.status.todo"),
badgeClass:
"bg-blue-50 text-blue-600 border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800",
progressColor: "bg-blue-500",
};
}
}
function getProgressFromStatus(status: string): number {
switch (status) {
case "todo":
return 0;
case "in_progress":
return 50;
case "done":
return 100;
default:
return 0;
}
}
export interface ProjectCardProps {
tablo: UserTablo;
onClick?: (tabloId: string) => void;
onMenuClick?: (tabloId: string) => void;
className?: string;
}
export function ProjectCard({
tablo,
onClick,
onMenuClick,
className,
}: ProjectCardProps) {
const { t } = useTranslation("pages");
const statusConfig = useStatusConfig(tablo.status);
const progress = getProgressFromStatus(tablo.status);
const formattedDate = new Intl.DateTimeFormat(undefined, {
month: "short",
day: "2-digit",
year: "numeric",
}).format(new Date(tablo.created_at));
return (
<div
className={cn(
"bg-white dark:bg-gray-800 rounded-2xl p-4 border border-[#EAECF0] dark:border-gray-700 hover:shadow-md transition-shadow cursor-pointer",
className,
)}
onClick={() => onClick?.(tablo.id)}
>
{/* Status + Menu */}
<div className="flex items-start justify-between mb-4">
<span
className={cn(
"px-3 py-1 rounded-full text-xs font-medium border",
statusConfig.badgeClass,
)}
>
{statusConfig.label}
</span>
<button
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
onMenuClick?.(tablo.id);
}}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Thumbnail + Name */}
<div className="flex items-center gap-3 mb-4">
<div
className={cn(
"w-12 h-12 rounded-xl flex items-center justify-center shrink-0 overflow-hidden",
!tablo.image && (tablo.color || "bg-gray-400"),
)}
>
{tablo.image ? (
<img
src={tablo.image}
alt={tablo.name}
className="w-full h-full object-cover"
/>
) : (
<span className="text-white font-bold text-lg">
{tablo.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<h3 className="font-semibold text-gray-900 dark:text-gray-100 flex-1 truncate">
{tablo.name}
</h3>
</div>
{/* Date */}
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
<Calendar className="w-4 h-4" />
<span>{formattedDate}</span>
</div>
{/* Progress */}
<div className="mb-3">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-gray-600 dark:text-gray-400">
{t("tablo.card.progress")}:
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{progress}%
</span>
</div>
<div className="w-full bg-gray-100 dark:bg-gray-700 rounded-full h-2">
<div
className={cn(
"h-2 rounded-full transition-all",
statusConfig.progressColor,
)}
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,78 @@
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { ChevronDown, ChevronRight, ChevronUp } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ProjectCard } from "./ProjectCard";
const DEFAULT_VISIBLE = 6;
export interface ProjectCardListProps {
tablos: UserTablo[];
onTabloClick?: (tabloId: string) => void;
onTabloMenuClick?: (tabloId: string) => void;
onSeeAllClick?: () => void;
}
export function ProjectCardList({
tablos,
onTabloClick,
onTabloMenuClick,
onSeeAllClick,
}: ProjectCardListProps) {
const { t } = useTranslation("pages");
const [expanded, setExpanded] = useState(false);
const hasMore = tablos.length > DEFAULT_VISIBLE;
const visibleTablos = expanded ? tablos : tablos.slice(0, DEFAULT_VISIBLE);
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold text-gray-900 dark:text-gray-100">
{t("tablo.projectList.title")}
</h2>
{onSeeAllClick && (
<button
className="flex items-center gap-1 text-purple-600 hover:text-purple-500 dark:text-purple-400 dark:hover:text-purple-300 font-medium"
onClick={onSeeAllClick}
>
{t("tablo.projectList.seeAll")}
<ChevronRight className="w-4 h-4" />
</button>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{visibleTablos.map((tablo) => (
<ProjectCard
key={tablo.id}
tablo={tablo}
onClick={onTabloClick}
onMenuClick={onTabloMenuClick}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center mt-6">
<button
className="flex items-center gap-1.5 text-purple-600 hover:text-purple-500 dark:text-purple-400 dark:hover:text-purple-300 font-medium text-sm"
onClick={() => setExpanded((prev) => !prev)}
>
{expanded ? (
<>
{t("tablo.projectList.showLess")}
<ChevronUp className="w-4 h-4" />
</>
) : (
<>
{t("tablo.projectList.showAll", {
count: tablos.length - DEFAULT_VISIBLE,
})}
<ChevronDown className="w-4 h-4" />
</>
)}
</button>
</div>
)}
</div>
);
}

View file

@ -65,7 +65,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
{t("tablo:events.description")}
</TypographyMuted>
{!isReadOnly && (
<Button onClick={handleCreateEvent} className="flex items-center gap-2 mt-4">
<Button onClick={handleCreateEvent} className="flex items-center gap-2 mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white">
<Plus className="w-4 h-4" />
{t("tablo:events.createEvent")}
</Button>
@ -77,7 +77,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
<div className="bg-card rounded-lg border border-border">
{isLoading ? (
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[#804EEC]"></div>
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
Chargement des événements...
</span>
@ -111,13 +111,13 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
<div key={event.event_id} className="p-6">
<div className="flex items-start space-x-4">
{/* Date Badge */}
<div className="shrink-0 w-16 h-16 bg-primary/10 dark:bg-primary/20 rounded-lg flex flex-col items-center justify-center">
<span className="text-xs font-medium text-primary uppercase">
<div className="shrink-0 w-14 h-14 bg-[#F4F3FF] dark:bg-purple-900/20 rounded-[8px] flex flex-col items-center justify-center">
<span className="text-[10px] font-semibold text-[#7F56D9] dark:text-purple-400 uppercase tracking-wide leading-none">
{new Date(event.start_date).toLocaleDateString("fr-FR", {
month: "short",
})}
</span>
<span className="text-2xl font-bold text-primary">
<span className="text-xl font-bold text-[#7F56D9] dark:text-purple-400 leading-none mt-0.5">
{new Date(event.start_date).getDate()}
</span>
</div>
@ -172,7 +172,7 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps)
Aucun événement à venir pour ce tablo
</p>
{!isReadOnly && (
<Button onClick={handleCreateEvent} variant="outline" className="mt-4">
<Button onClick={handleCreateEvent} className="mt-4 bg-[#804EEC] hover:bg-[#6f3fd4] text-white">
<Plus className="w-4 h-4 mr-2" />
Créer le premier événement
</Button>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,409 @@
import type { Database } from "@xtablo/shared-types";
import { Badge } from "@xtablo/ui/components/badge";
import { Button } from "@xtablo/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@xtablo/ui/components/dropdown-menu";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@xtablo/ui/components/avatar";
import {
TypographyMuted,
TypographySmall,
} from "@xtablo/ui/components/typography";
import {
BellIcon,
CalendarCheckIcon,
CalendarIcon,
CheckCheckIcon,
FileTextIcon,
KanbanIcon,
LayoutDashboardIcon,
LogOutIcon,
MailIcon,
SearchIcon,
SettingsIcon,
UserPlusIcon,
XIcon,
} from "lucide-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Link, useLocation, useSearchParams } from "react-router-dom";
import { useLogout } from "../hooks/auth";
import {
useNotifications,
useNotificationsSubscription,
} from "../hooks/notifications";
import { useUser } from "../providers/UserStoreProvider";
type Notification = Database["public"]["Tables"]["notifications"]["Row"];
function getNotificationIcon(entityType: string) {
switch (entityType) {
case "tablos":
return <LayoutDashboardIcon className="w-4 h-4" />;
case "tasks":
return <KanbanIcon className="w-4 h-4" />;
case "events":
return <CalendarIcon className="w-4 h-4" />;
case "notes":
return <FileTextIcon className="w-4 h-4" />;
case "tablo_access":
return <UserPlusIcon className="w-4 h-4" />;
case "tablo_invites":
return <MailIcon className="w-4 h-4" />;
default:
return <BellIcon className="w-4 h-4" />;
}
}
function getNotificationLink(notification: Notification): string {
const { entity_type, entity_id, metadata } = notification;
switch (entity_type) {
case "tablos":
return `/tablos/${entity_id}`;
case "tasks":
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
return `/tablos/${metadata.tablo_id}`;
}
return "/";
case "events":
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
return `/tablos/${metadata.tablo_id}`;
}
return "/planning?tab=events";
case "notes":
return `/notes/${entity_id}`;
case "tablo_access":
case "tablo_invites":
if (metadata && typeof metadata === "object" && "tablo_id" in metadata) {
return `/tablos/${metadata.tablo_id}`;
}
return "/";
default:
return "/";
}
}
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) return "Just now";
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) return `${diffInMinutes}m ago`;
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) return `${diffInHours}h ago`;
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) return `${diffInDays}d ago`;
return date.toLocaleDateString();
}
function NotificationItem({
notification,
onMarkAsRead,
}: {
notification: Notification;
onMarkAsRead: (id: string) => void;
}) {
const { i18n } = useTranslation();
const link = getNotificationLink(notification);
const getMessage = () => {
const locale = i18n.language.startsWith("fr") ? "fr" : "en";
return (
(notification.message as Record<string, string>)[locale] ||
(notification.message as Record<string, string>)["en"] ||
""
);
};
return (
<Link to={link} onClick={() => onMarkAsRead(notification.id)}>
<DropdownMenuItem className="cursor-pointer p-3 focus:bg-gray-100 hover:bg-gray-100 text-gray-800">
<div className="flex gap-3 w-full">
<div className="shrink-0 mt-1">
<div className="p-2 rounded-full bg-blue-100 text-blue-600">
{getNotificationIcon(notification.entity_type)}
</div>
</div>
<div className="flex-1 min-w-0">
<TypographySmall className="font-medium text-gray-900 line-clamp-2">
{getMessage()}
</TypographySmall>
<TypographyMuted className="text-xs mt-1 text-gray-500">
{formatRelativeTime(notification.created_at)}
</TypographyMuted>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-200 text-gray-500 hover:text-gray-900"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onMarkAsRead(notification.id);
}}
>
<XIcon className="h-3 w-3" />
</Button>
</div>
</DropdownMenuItem>
</Link>
);
}
function NotificationDropdown() {
const { t } = useTranslation("navigation");
const { notifications, unreadCount, isLoading, markAsRead, markAllAsRead } =
useNotifications();
const { setupSubscription } = useNotificationsSubscription();
useEffect(() => {
const cleanup = setupSubscription();
return cleanup;
}, []);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative w-10 h-10 border border-[#EAECF0] dark:border-gray-700 rounded-[8px] text-[#0C111D] dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800"
aria-label="Notifications"
>
<BellIcon className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center w-4 h-4 rounded-full bg-red-500 text-white text-[10px] font-semibold leading-none">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-96 bg-white border border-[#EAECF0] p-1 rounded-lg text-gray-900 shadow-lg"
side="bottom"
align="end"
sideOffset={8}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100">
<div className="flex items-center gap-2">
<TypographySmall className="font-semibold text-gray-900">
{t("notifications.title", "Notifications")}
</TypographySmall>
{unreadCount > 0 && (
<Badge className="bg-red-500 text-white text-xs">
{unreadCount}
</Badge>
)}
</div>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-8 text-xs text-gray-600 hover:text-gray-900 hover:bg-gray-100"
onClick={() => markAllAsRead()}
>
<CheckCheckIcon className="h-3 w-3 mr-1" />
{t("notifications.markAllRead", "Mark all read")}
</Button>
)}
</div>
<div className="max-h-[400px] overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<TypographyMuted className="text-sm text-gray-500">
{t("notifications.loading", "Loading...")}
</TypographyMuted>
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center">
<div className="p-3 rounded-full bg-gray-100 mb-3">
<BellIcon className="h-6 w-6 text-gray-400" />
</div>
<TypographySmall className="font-medium text-gray-900 mb-1">
{t("notifications.noNotifications", "No new notifications")}
</TypographySmall>
<TypographyMuted className="text-xs text-gray-500">
{t("notifications.allCaughtUp", "You're all caught up!")}
</TypographyMuted>
</div>
) : (
<div className="divide-y divide-gray-100">
{notifications.map((notification) => (
<div key={notification.id} className="group">
<NotificationItem
notification={notification}
onMarkAsRead={markAsRead}
/>
</div>
))}
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ProfileDropdown() {
const { t } = useTranslation("navigation");
const user = useUser();
const { mutate: logout } = useLogout();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-2 p-1 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-[8px]"
aria-label="Profile menu"
>
<Avatar className="w-10 h-10">
<AvatarImage src={user.avatar_url ?? undefined} alt="Avatar" />
<AvatarFallback className="bg-[#B8EAFF] text-gray-800 font-medium">
{user.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-56 bg-white dark:bg-gray-900 border border-[#EAECF0] dark:border-gray-700 p-1 rounded-lg text-gray-900 dark:text-gray-100 shadow-lg"
side="bottom"
align="end"
sideOffset={8}
>
<div className="flex gap-2 p-2">
<Avatar className="size-8">
<AvatarImage
src={user.avatar_url ?? undefined}
alt={user.name ?? "User avatar"}
/>
<AvatarFallback className="bg-[#B8EAFF] text-gray-800 font-medium">
{user.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<TypographySmall className="font-semibold text-gray-900 dark:text-gray-100 truncate">
{user.name}
</TypographySmall>
<TypographyMuted className="text-xs text-gray-500 dark:text-gray-400 truncate">
{user.email}
</TypographyMuted>
</div>
</div>
<DropdownMenuSeparator className="bg-gray-100 dark:bg-gray-700" />
<Link to="/settings">
<DropdownMenuItem className="cursor-pointer gap-2 text-gray-700 dark:text-gray-200 focus:bg-gray-100 dark:focus:bg-gray-800">
<SettingsIcon className="w-4 h-4" />
{t("userMenu.settings")}
</DropdownMenuItem>
</Link>
<Link to="/events">
<DropdownMenuItem className="cursor-pointer gap-2 text-gray-700 dark:text-gray-200 focus:bg-gray-100 dark:focus:bg-gray-800">
<CalendarCheckIcon className="w-4 h-4" />
{t("myEvents")}
</DropdownMenuItem>
</Link>
<Link to="/availabilities">
<DropdownMenuItem className="cursor-pointer gap-2 text-gray-700 dark:text-gray-200 focus:bg-gray-100 dark:focus:bg-gray-800">
<CalendarIcon className="w-4 h-4" />
{t("userMenu.availabilities")}
</DropdownMenuItem>
</Link>
<DropdownMenuSeparator className="bg-gray-100 dark:bg-gray-700" />
<DropdownMenuItem
className="cursor-pointer gap-2 text-red-600 dark:text-red-400 focus:bg-red-50 dark:focus:bg-red-900/30 focus:text-red-600 dark:focus:text-red-300"
onClick={() => logout()}
>
<LogOutIcon className="w-4 h-4" />
{t("userMenu.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
const SEARCH_ROUTES = ["/tablos", "/", "/tasks", "/files"];
export function TopBar() {
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const searchQuery = searchParams.get("q") ?? "";
const isSearchRoute = SEARCH_ROUTES.includes(location.pathname);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newParams = new URLSearchParams(searchParams);
if (e.target.value) {
newParams.set("q", e.target.value);
} else {
newParams.delete("q");
}
setSearchParams(newParams, { replace: true });
};
return (
<header className="h-[75px] flex items-center justify-between px-6 gap-4 border-b border-[#EAECF0] dark:border-gray-700 bg-navbar-background shrink-0">
<div className="relative flex-1 max-w-sm">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 dark:text-gray-500 pointer-events-none" />
<input
type="text"
placeholder="Search..."
value={isSearchRoute ? searchQuery : ""}
onChange={isSearchRoute ? handleSearchChange : undefined}
readOnly={!isSearchRoute}
className="w-full pl-9 pr-4 py-2 bg-transparent border border-[#EAECF0] dark:border-gray-700 rounded-lg text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary dark:bg-gray-800/50"
/>
</div>
<div className="flex items-center gap-3">
{location.pathname === "/planning" && (
<div className="hidden md:flex items-center bg-gray-100 dark:bg-gray-800 rounded-lg p-1 gap-0.5">
<Link
to="/planning"
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
new URLSearchParams(location.search).get("tab") !== "events"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
}`}
>
<CalendarIcon className="w-4 h-4" />
Calendrier
</Link>
<Link
to="/planning?tab=events"
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
new URLSearchParams(location.search).get("tab") === "events"
? "bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 shadow-sm"
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
}`}
>
<CalendarCheckIcon className="w-4 h-4" />
Événements
</Link>
</div>
)}
<NotificationDropdown />
<ProfileDropdown />
</div>
</header>
);
}

View file

@ -0,0 +1,569 @@
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import type { KanbanTask, TaskStatus } from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@xtablo/ui/components/dropdown-menu";
import {
CalendarIcon,
ChevronLeftIcon,
ChevronRightIcon,
Compass,
Flame,
FolderIcon,
Gem,
Heart,
Leaf,
MapIcon,
Sparkles,
Star,
Sun,
Waves,
Zap,
} from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
// ─── Types ───────────────────────────────────────────────────────────────────
type GanttTask = KanbanTask & {
tablos?: { id: string; name: string; color: string | null } | null;
};
interface GanttChartProps {
tasks: GanttTask[];
isLoading: boolean;
onDateClick?: (date: Date) => void;
onTaskStatusChange?: (taskId: string, status: TaskStatus) => void;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
const STATUS_STYLES: Record<
string,
{ bg: string; border: string; dot: string; label: string }
> = {
todo: {
bg: "bg-[#EFF8FF]",
border: "border-l-[#3B82F6]",
dot: "bg-[#1570EF]",
label: "À faire",
},
in_progress: {
bg: "bg-[#FFF4E2]",
border: "border-l-[#F1A62D]",
dot: "bg-[#DB9729]",
label: "En cours",
},
in_review: {
bg: "bg-[#F4F3FF]",
border: "border-l-[#804EEC]",
dot: "bg-[#804EEC]",
label: "Vérification",
},
done: {
bg: "bg-[#EDFCF2]",
border: "border-l-[#16B364]",
dot: "bg-[#16B364]",
label: "Terminé",
},
};
const STATUS_TEXT_COLORS: Record<string, string> = {
todo: "text-[#1570EF]",
in_progress: "text-[#DB9729]",
in_review: "text-[#804EEC]",
done: "text-[#16B364]",
};
const ROADMAP_TASK_STATUSES: TaskStatus[] = [
"todo",
"in_progress",
"in_review",
"done",
];
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 getMonday(date: Date): Date {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
d.setDate(diff);
d.setHours(0, 0, 0, 0);
return d;
}
function addDays(date: Date, n: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + n);
return d;
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function formatShortDay(date: Date): string {
return date
.toLocaleDateString("fr-FR", { weekday: "short" })
.replace(".", "");
}
function formatDateRange(start: Date, end: Date): string {
const opts: Intl.DateTimeFormatOptions = { month: "long", day: "numeric" };
const startStr = start.toLocaleDateString("fr-FR", opts);
const endStr = end.toLocaleDateString("fr-FR", opts);
return `${startStr} - ${endStr}`;
}
type ViewMode = "weekly" | "biweekly";
const VIEW_CONFIG = {
weekly: { days: 7, label: "Semaine", cardHeight: 130, stepWeeks: 1 },
biweekly: { days: 14, label: "2 semaines", cardHeight: 100, stepWeeks: 2 },
} as const;
const CARD_GAP = 10;
const CARD_TOP_OFFSET = 20;
// ─── Component ───────────────────────────────────────────────────────────────
export function GanttChart({
tasks,
isLoading,
onDateClick,
onTaskStatusChange,
}: GanttChartProps) {
const [weekOffset, setWeekOffset] = useState(0);
const [viewMode, setViewMode] = useState<ViewMode>("weekly");
const containerRef = useRef<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(992);
const config = VIEW_CONFIG[viewMode];
const numDays = config.days;
const cardHeight = config.cardHeight;
// Measure container width
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
setContainerWidth(Math.max(entry.contentRect.width, 992));
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const colWidth = containerWidth / numDays;
// Compute days
const today = useMemo(() => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}, []);
const periodStart = useMemo(
() => addDays(getMonday(today), weekOffset * 7),
[today, weekOffset],
);
const periodEnd = useMemo(
() => addDays(periodStart, numDays - 1),
[periodStart, numDays],
);
const days = useMemo(
() => Array.from({ length: numDays }, (_, i) => addDays(periodStart, i)),
[periodStart, numDays],
);
// Filter tasks with due_date in this period
const visibleTasks = useMemo(() => {
const start = periodStart.getTime();
const end = addDays(periodStart, numDays).getTime();
return tasks.filter((t) => {
if (!t.due_date) return false;
const d = new Date(t.due_date).getTime();
return d >= start && d < end;
});
}, [tasks, periodStart, numDays]);
// Position tasks: group by day column, stack vertically
const positionedTasks = useMemo(() => {
const columnSlots: number[][] = Array.from({ length: numDays }, () => []);
return visibleTasks
.map((task) => {
const dueDate = new Date(task.due_date!);
const dayIndex = days.findIndex((d) => isSameDay(d, dueDate));
if (dayIndex === -1) return null;
// Find first available row in this column
const slots = columnSlots[dayIndex];
let row = 0;
while (slots.includes(row)) row++;
slots.push(row);
return {
task,
dayIndex,
row,
left: dayIndex * colWidth + 4,
top: CARD_TOP_OFFSET + row * (cardHeight + CARD_GAP),
width: colWidth - 8,
};
})
.filter(Boolean) as Array<{
task: GanttTask;
dayIndex: number;
row: number;
left: number;
top: number;
width: number;
}>;
}, [visibleTasks, days, colWidth, numDays, cardHeight]);
// Compute chart height
const maxRow = positionedTasks.reduce((max, pt) => Math.max(max, pt.row), 0);
const chartHeight = Math.max(
400,
(maxRow + 1) * (cardHeight + CARD_GAP) + CARD_TOP_OFFSET + 20,
);
// Today indicator position
const todayIndex = days.findIndex((d) => isSameDay(d, today));
const todayInRange = todayIndex >= 0;
const handleViewModeChange = (mode: ViewMode) => {
setViewMode(mode);
setWeekOffset(0);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner />
</div>
);
}
return (
<div className="w-full">
{/* Navigation bar */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="rounded-lg"
onClick={() => setWeekOffset((w) => w - config.stepWeeks)}
>
<ChevronLeftIcon className="h-4 w-4" />
</Button>
<div className="px-4 py-2 bg-card dark:bg-gray-900 border border-border dark:border-gray-700 rounded-lg min-w-[200px] text-center">
<span className="text-sm font-medium text-foreground">
{formatDateRange(periodStart, periodEnd)}
</span>
</div>
<Button
variant="outline"
size="icon"
className="rounded-lg"
onClick={() => setWeekOffset((w) => w + config.stepWeeks)}
>
<ChevronRightIcon className="h-4 w-4" />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2 rounded-lg">
<CalendarIcon className="h-4 w-4" />
<span>{config.label}</span>
<ChevronRightIcon className="h-4 w-4 rotate-90" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleViewModeChange("weekly")}>
Semaine
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleViewModeChange("biweekly")}>
2 semaines
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setWeekOffset(0)}>
Aller à aujourd'hui
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Gantt chart */}
<div
className="bg-card dark:bg-gray-900 rounded-xl border border-border dark:border-gray-700 overflow-hidden shadow-sm"
ref={containerRef}
>
<div className="overflow-x-auto">
<div style={{ minWidth: 992 }}>
{/* Day headers */}
<div className="flex border-b border-border">
{days.map((day, i) => {
const isToday = isSameDay(day, today);
return (
<button
key={i}
type="button"
onClick={() => onDateClick?.(new Date(day))}
className="flex flex-col items-center py-3 relative transition-colors hover:bg-accent/40"
style={{ width: colWidth }}
>
<span
className={twMerge(
"text-sm font-medium",
isToday ? "text-primary" : "text-muted-foreground",
)}
>
{formatShortDay(day)} {day.getDate()}
</span>
</button>
);
})}
</div>
{/* Grid body */}
<div className="relative" style={{ height: chartHeight }}>
{/* Clickable day columns */}
<div className="absolute inset-0 flex z-0">
{days.map((day, i) => (
<button
key={`click-${i}`}
type="button"
onClick={() => onDateClick?.(new Date(day))}
className="h-full transition-colors hover:bg-accent/20"
style={{ width: colWidth }}
/>
))}
</div>
{/* Vertical grid lines */}
<div className="absolute inset-0 flex pointer-events-none z-10">
{days.map((_, i) => (
<div
key={i}
className={twMerge(
"border-border",
i < numDays - 1 ? "border-r" : "",
)}
style={{ width: colWidth }}
/>
))}
</div>
{/* Today indicator */}
{todayInRange && (
<div
className="absolute z-20 pointer-events-none"
style={{
left: (todayIndex + 0.5) * colWidth,
top: 0,
height: chartHeight,
}}
>
<div className="absolute -top-1 left-1/2 -translate-x-1/2">
<div className="w-0 h-0 border-l-[8px] border-r-[8px] border-t-[10px] border-l-transparent border-r-transparent border-t-primary" />
</div>
<div className="w-0.5 h-full bg-primary mx-auto opacity-60" />
</div>
)}
{/* Task cards */}
{positionedTasks.map((pt) => {
const status =
STATUS_STYLES[pt.task.status ?? "todo"] ?? STATUS_STYLES.todo;
const textColor =
STATUS_TEXT_COLORS[pt.task.status ?? "todo"] ??
STATUS_TEXT_COLORS.todo;
const TabloIcon = pt.task.tablos
? getTabloIcon(pt.task.tablos.color)
: null;
const isCompact = viewMode === "biweekly";
const taskCardContent = (
<>
{/* Status badge */}
<div className="flex items-center gap-1.5 bg-white w-fit px-2 py-0.5 rounded-full shadow-sm">
<span
className={twMerge("w-2 h-2 rounded-full", status.dot)}
/>
{!isCompact && (
<span
className={twMerge("text-xs font-medium", textColor)}
>
{status.label}
</span>
)}
</div>
{/* Title */}
<h3
className={twMerge(
"font-semibold text-gray-900 leading-tight line-clamp-1",
isCompact ? "mt-1 text-xs" : "mt-2 text-sm",
)}
>
{pt.task.title}
</h3>
{/* Due date */}
<p className="text-xs text-muted-foreground mt-1">
{new Date(pt.task.due_date!).toLocaleDateString("fr-FR", {
weekday: "short",
day: "numeric",
month: isCompact ? undefined : "short",
})}
</p>
{/* Tablo badge — hidden in compact mode */}
{!isCompact && pt.task.tablos && TabloIcon && (
<div className="flex items-center gap-2 mt-2">
<div
className={twMerge(
"w-5 h-5 rounded-md flex items-center justify-center",
pt.task.tablos.color || "bg-gray-400",
)}
>
<TabloIcon className="w-3 h-3 text-white" />
</div>
<span className="text-xs text-muted-foreground truncate">
{pt.task.tablos.name}
</span>
</div>
)}
</>
);
if (!onTaskStatusChange) {
return (
<div
key={pt.task.id}
className={twMerge(
"absolute z-30 rounded-lg border-l-4 shadow-sm transition-all hover:shadow-md overflow-hidden text-left cursor-default",
isCompact ? "p-2" : "p-3",
status.bg,
status.border,
)}
style={{
left: pt.left,
width: pt.width,
top: pt.top,
minWidth: isCompact ? 80 : 160,
}}
>
{taskCardContent}
</div>
);
}
return (
<DropdownMenu key={pt.task.id}>
<DropdownMenuTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={twMerge(
"absolute z-30 rounded-lg border-l-4 shadow-sm transition-all hover:shadow-md overflow-hidden text-left",
isCompact ? "p-2" : "p-3",
status.bg,
status.border,
"cursor-pointer",
)}
style={{
left: pt.left,
width: pt.width,
top: pt.top,
minWidth: isCompact ? 80 : 160,
}}
>
{taskCardContent}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
{ROADMAP_TASK_STATUSES.map((nextStatus) => {
const isCurrent = nextStatus === pt.task.status;
return (
<DropdownMenuItem
key={nextStatus}
disabled={isCurrent}
onClick={() =>
onTaskStatusChange(pt.task.id, nextStatus)
}
className="gap-2"
>
<span
className={twMerge(
"w-2 h-2 rounded-full",
STATUS_STYLES[nextStatus]?.dot ?? "bg-gray-400",
)}
/>
<span>
{STATUS_STYLES[nextStatus]?.label ?? nextStatus}
{isCurrent ? " (actuel)" : ""}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
})}
{/* Empty state within chart */}
{positionedTasks.length === 0 && (
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none z-10">
<MapIcon className="w-10 h-10 text-muted-foreground/30 mb-2" />
<p className="text-sm text-muted-foreground">
Aucune tâche avec échéance sur cette période
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
import type { KanbanTask } from "@xtablo/shared-types";
import { TypographyH4, TypographyMuted } from "@xtablo/ui/components/typography";
import { User } from "lucide-react";
import { CalendarIcon, User } from "lucide-react";
interface KanbanTaskCardProps {
task: KanbanTask;
@ -8,7 +8,24 @@ interface KanbanTaskCardProps {
onClick: () => void;
}
function formatDueDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "short",
});
}
function isOverdue(dateStr: string): boolean {
const today = new Date();
today.setHours(0, 0, 0, 0);
const due = new Date(dateStr);
return due < today;
}
export const KanbanTaskCard = ({ task, etapeTitle, onClick }: KanbanTaskCardProps) => {
const overdue = task.due_date && task.status !== "done" && isOverdue(task.due_date);
return (
<div
onClick={onClick}
@ -24,6 +41,16 @@ export const KanbanTaskCard = ({ task, etapeTitle, onClick }: KanbanTaskCardProp
</TypographyMuted>
)}
{/* Due date */}
{task.due_date && (
<div
className={`flex items-center gap-1 text-xs mb-2 ${overdue ? "text-red-500" : "text-muted-foreground"}`}
>
<CalendarIcon className="w-3.5 h-3.5" />
<span>{formatDueDate(task.due_date)}</span>
</div>
)}
{/* Status Pill */}
<div className="mb-2">
<span

View file

@ -1,5 +1,7 @@
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import type { Etape, TaskStatus } from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
import { DatePicker } from "@xtablo/ui/components/date-picker";
import { Input } from "@xtablo/ui/components/input";
import { Label } from "@xtablo/ui/components/label";
import {
@ -13,10 +15,14 @@ import { Textarea } from "@xtablo/ui/components/textarea";
import { TypographyH2 } from "@xtablo/ui/components/typography";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { useCreateTask, useTask, useUpdateTask, useTabloEtapes } from "../../hooks/tasks";
import { useTabloMembers } from "../../hooks/tablos";
import {
useCreateTask,
useTabloEtapes,
useTask,
useUpdateTask,
} from "../../hooks/tasks";
import type { TabloMember } from "./types";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
interface TaskModalProps {
isOpen: boolean;
@ -28,6 +34,7 @@ interface TaskModalProps {
etapes?: Etape[]; // Optional - will be fetched if tabloId is provided
tablos?: UserTablo[]; // Optional - for tablo selection when creating
allowTabloSelection?: boolean; // Whether to show tablo selector
initialDueDate?: Date;
}
export const TaskModal = ({
@ -40,27 +47,35 @@ export const TaskModal = ({
etapes: providedEtapes,
tablos,
allowTabloSelection = false,
initialDueDate,
}: TaskModalProps) => {
const { data: task = null } = useTask(taskId);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [assigneeId, setAssigneeId] = useState<string>("unassigned");
const [etapeId, setEtapeId] = useState<string>("none");
const [dueDate, setDueDate] = useState<Date | undefined>(undefined);
const [selectedTabloId, setSelectedTabloId] = useState<string>(
initialTabloId || tablos?.[0]?.id || ""
initialTabloId || tablos?.[0]?.id || "",
);
// Determine which tablo to use for fetching data
const tabloIdForFetch = allowTabloSelection ? selectedTabloId : initialTabloId || "";
const tabloIdForFetch = allowTabloSelection
? selectedTabloId
: initialTabloId || "";
// Fetch members and etapes for selected tablo if not provided
const { data: fetchedMembers = [] } = useTabloMembers(tabloIdForFetch || "");
const { data: fetchedEtapes = [] } = useTabloEtapes(tabloIdForFetch || undefined);
const { data: fetchedEtapes = [] } = useTabloEtapes(
tabloIdForFetch || undefined,
);
// Use provided or fetched data
const members = providedMembers || fetchedMembers;
const etapes = providedEtapes || fetchedEtapes;
const currentTabloId = allowTabloSelection ? selectedTabloId : initialTabloId || "";
const currentTabloId = allowTabloSelection
? selectedTabloId
: initialTabloId || "";
useEffect(() => {
if (task) {
@ -68,6 +83,7 @@ export const TaskModal = ({
setDescription(task.description ?? "");
setAssigneeId(task.assignee_id ?? "unassigned");
setEtapeId(task.parent_task_id ?? "none");
setDueDate(task.due_date ? new Date(task.due_date) : undefined);
if (!initialTabloId && task.tablo_id) {
setSelectedTabloId(task.tablo_id);
}
@ -77,20 +93,32 @@ export const TaskModal = ({
setDescription("");
setAssigneeId("unassigned");
setEtapeId("none");
setDueDate(initialDueDate ? new Date(initialDueDate) : undefined);
if (allowTabloSelection && tablos && tablos.length > 0) {
setSelectedTabloId(tablos[0].id);
}
}
}, [task, initialTabloId, allowTabloSelection, tablos]);
}, [task, initialTabloId, allowTabloSelection, tablos, initialDueDate]);
const { mutate: createTask } = useCreateTask();
const { mutate: updateTask } = useUpdateTask();
// Format Date to YYYY-MM-DD string for database storage
const formatDateForDb = (date: Date | undefined): string | null => {
if (!date) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
if (!currentTabloId) return; // Need a tablo to create task
const dueDateValue = formatDateForDb(dueDate);
if (taskId && task) {
updateTask({
tablo_id: task.tablo_id,
@ -100,6 +128,7 @@ export const TaskModal = ({
assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined,
status: initialStatus,
parent_task_id: etapeId !== "none" ? etapeId : null,
due_date: dueDateValue,
});
} else {
createTask({
@ -109,6 +138,7 @@ export const TaskModal = ({
assignee_id: assigneeId !== "unassigned" ? assigneeId : undefined,
status: initialStatus,
parent_task_id: etapeId !== "none" ? etapeId : null,
due_date: dueDateValue,
});
}
// Reset form
@ -116,6 +146,7 @@ export const TaskModal = ({
setDescription("");
setAssigneeId("unassigned");
setEtapeId("none");
setDueDate(undefined);
onClose();
};
@ -143,7 +174,10 @@ export const TaskModal = ({
{allowTabloSelection && !taskId && tablos && tablos.length > 0 && (
<div className="space-y-2">
<Label htmlFor="tablo">Tablo *</Label>
<Select value={selectedTabloId} onValueChange={setSelectedTabloId}>
<Select
value={selectedTabloId}
onValueChange={setSelectedTabloId}
>
<SelectTrigger id="tablo" className="w-full">
<SelectValue placeholder="Sélectionner un tablo" />
</SelectTrigger>
@ -188,22 +222,15 @@ export const TaskModal = ({
/>
</div>
{/* Type */}
{/* <div className="space-y-2">
<Label htmlFor="type">Type</Label>
<Select value={type} onValueChange={(value) => setType(value as TaskType)}>
<SelectTrigger id="type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="task">Task</SelectItem>
<SelectItem value="story">Story</SelectItem>
<SelectItem value="bug">Bug</SelectItem>
<SelectItem value="epic">Epic</SelectItem>
<SelectItem value="subtask">Subtask</SelectItem>
</SelectContent>
</Select>
</div> */}
{/* Due Date */}
<div className="space-y-2">
<Label>Échéance</Label>
<DatePicker
value={dueDate}
onChange={setDueDate}
placeholder="Choisir une date"
/>
</div>
{/* Assignee */}
<div className="space-y-2">

View file

@ -31,6 +31,25 @@ export const toastOptions = {
timeout: toastTimeout,
};
export interface AllTablosFileNames {
tablos: { tabloId: string; fileNames: string[] }[];
}
// Hook to get file names for all tablos in a single request
export function useAllTablosFileNames() {
const api = useAuthedApi();
return useQuery<AllTablosFileNames>({
queryKey: ["all-tablo-files"],
queryFn: async () => {
const response = await api.get("/api/v1/tablo-data/all-filenames");
if (response.status !== 200) {
throw new Error("Failed to fetch all tablo files");
}
return response.data;
},
});
}
// Hook to get all file names for a tablo
export function useTabloFileNames(tabloId: string) {
const api = useAuthedApi();
@ -140,7 +159,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 +204,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 +245,7 @@ export function useDeleteTabloFile() {
return useMutation<FileOperationResponse, Error, { tabloId: string; fileName: string }>({
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");
}

View file

@ -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<TabloFoldersMetadata>({
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<void, Error, { tabloId: string; folderId: string; folderName: string }>({
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;
};

View file

@ -15,6 +15,7 @@ type CreateEtapeInput = {
title: string;
description?: string | null;
position?: number;
due_date?: string | null;
};
type UpdateEtapeInput = {
@ -23,6 +24,7 @@ type UpdateEtapeInput = {
title?: string;
description?: string | null;
position?: number;
due_date?: string | null;
};
type DeleteEtapeInput = {
@ -169,6 +171,7 @@ export const useCreateTask = () => {
position: task.position || 0,
parent_task_id: task.parent_task_id ?? null,
is_parent: task.is_parent ?? false,
due_date: task.due_date ?? null,
})
.select()
.single();
@ -358,7 +361,7 @@ export const useCreateEtape = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tabloId, title, description, position }: CreateEtapeInput) => {
mutationFn: async ({ tabloId, title, description, position, due_date }: CreateEtapeInput) => {
const { data, error } = await supabase
.from("tasks")
.insert({
@ -368,6 +371,7 @@ export const useCreateEtape = () => {
status: "todo",
position: position ?? 0,
is_parent: true,
due_date: due_date ?? null,
})
.select()
.single();
@ -404,11 +408,19 @@ export const useUpdateEtape = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, tabloId, title, description, position }: UpdateEtapeInput) => {
mutationFn: async ({
id,
tabloId,
title,
description,
position,
due_date,
}: UpdateEtapeInput) => {
const updates: Record<string, unknown> = {};
if (title !== undefined) updates.title = title;
if (description !== undefined) updates.description = description;
if (position !== undefined) updates.position = position;
if (due_date !== undefined) updates.due_date = due_date;
if (Object.keys(updates).length === 0) {
throw new Error("Aucune modification fournie pour l'Étape");

View file

@ -11,6 +11,7 @@ import { FeedbackPage } from "../pages/feedback";
import { JoinPage } from "../pages/join";
import { LegalNoticePage } from "../pages/legal-notice";
import { LoginPage } from "../pages/login";
import { LoginV2Page } from "../pages/login-v2";
import { NotFoundPage } from "../pages/NotFoundPage";
// import NotesPage from "../pages/notes"; // Notes feature temporarily hidden
import { OAuthSigninPage } from "../pages/oauth-signin";
@ -19,9 +20,12 @@ import { PrivacyPolicyPage } from "../pages/privacy-policy";
import { ResetPasswordPage } from "../pages/reset-password";
import SettingsPage from "../pages/settings";
import { SignUpPage } from "../pages/signup";
import { SignUpV2Page } from "../pages/signup-v2";
import { TabloPage } from "../pages/tablo";
import { TabloDetailsPage } from "../pages/tablo-details";
import { TablosPage } from "../pages/tablos";
import { TasksPage } from "../pages/tasks";
import { FilesPage } from "../pages/files";
import { UpdatePasswordPage } from "../pages/update-password";
import ChatProvider from "../providers/ChatProvider";
import { EventsPage } from "src/pages/events";
@ -32,14 +36,14 @@ export const routes: RouteObject[] = [
path: "/",
element: <ProtectedRoute fallback="/login" />,
children: [
{
path: "tablos/:tabloId",
element: <TabloDetailsPage />,
},
{
path: "",
element: <Layout />,
children: [
{
path: "tablos/:tabloId",
element: <TabloDetailsPage />,
},
{
index: true,
element: <TabloPage />,
@ -94,12 +98,23 @@ export const routes: RouteObject[] = [
{
path: "events",
element: <EventsPage />,
children: [{ index: true }, { path: "create", element: <EventModal mode="create" /> }],
children: [
{ index: true },
{ path: "create", element: <EventModal mode="create" /> },
],
},
{
path: "tasks",
element: <TasksPage />,
},
{
path: "files",
element: <FilesPage />,
},
{
path: "tablos",
element: <TablosPage />,
},
{
path: "feedback",
element: <FeedbackPage />,
@ -153,10 +168,18 @@ export const routes: RouteObject[] = [
path: "login",
element: <LoginPage />,
},
{
path: "login-v2",
element: <LoginV2Page />,
},
{
path: "signup",
element: <SignUpPage />,
},
{
path: "signup-v2",
element: <SignUpV2Page />,
},
{
path: "reset-password",
element: <ResetPasswordPage />,

View file

@ -13,7 +13,13 @@
"forgotPassword": "Forgot password?",
"loginButton": "Log in",
"noAccount": "Don't have an account?",
"signupLink": "Sign up"
"signupLink": "Sign up",
"newExperienceLink": "Take a look at the new login experience",
"asideTitle": "Centralize your projects with Xtablo",
"asideDescription": "Plan tasks, events and collaboration from one clean workspace.",
"feature1": "Track every deadline with shared timelines",
"feature2": "Keep files, discussions and decisions in context",
"feature3": "Give your team one reliable source of truth"
},
"signup": {
"title": "Create an Xtablo account",
@ -29,6 +35,11 @@
"signupButton": "Create my account",
"alreadyAccount": "Already have an account?",
"loginLink": "Log in",
"asideTitle": "Build a calmer workflow from day one",
"asideDescription": "Create your workspace, invite your team, and keep every project in sync.",
"feature1": "Shared task boards with clear ownership",
"feature2": "Planning views for milestones and deadlines",
"feature3": "Fast collaboration around files and updates",
"termsAccept": "I accept the",
"termsLink": "legal notice",
"termsAnd": "and the",

View file

@ -24,7 +24,7 @@
},
"labels": {
"title": "Title *",
"tablo": "Tablo *",
"tablo": "Project *",
"date": "Date *",
"startTime": "Start *",
"endTime": "End",
@ -32,7 +32,7 @@
},
"placeholders": {
"title": "Add a title",
"tablo": "Select a tablo",
"tablo": "Select a project",
"description": "Add a description (optional)"
},
"buttons": {
@ -80,10 +80,10 @@
"required": "*"
},
"deleteTabloModal": {
"title": "Delete tablo",
"title": "Delete project",
"subtitle": "This action is irreversible",
"confirmQuestion": "Are you sure you want to delete the tablo",
"warning": "All data associated with this tablo will be permanently lost.",
"confirmQuestion": "Are you sure you want to delete the project",
"warning": "All data associated with this project will be permanently lost.",
"buttons": {
"cancel": "Cancel",
"delete": "Delete",
@ -105,11 +105,11 @@
"importing": "Importing..."
},
"checkbox": {
"createNewTablo": "Create a new tablo"
"createNewTablo": "Create a new project"
},
"placeholders": {
"newTabloName": "New tablo name",
"selectTablo": "Select an existing tablo"
"newTabloName": "New project name",
"selectTablo": "Select an existing project"
},
"messages": {
"eventsFound": "{{count}} event(s) found",
@ -133,12 +133,12 @@
"description": "No events to import"
},
"tabloRequired": {
"title": "Tablo required",
"description": "Please select a tablo or create a new one"
"title": "Project required",
"description": "Please select a project or create a new one"
},
"tabloNameRequired": {
"title": "Tablo name required",
"description": "Please enter a name for the new tablo"
"title": "Project name required",
"description": "Please enter a name for the new project"
},
"importSuccess": {
"title": "Import successful",

View file

@ -1,7 +1,7 @@
{
"createTablo": {
"title": "Create a new tablo",
"nameLabel": "Tablo name",
"namePlaceholder": "Enter tablo name"
"title": "Create a new project",
"nameLabel": "Project name",
"namePlaceholder": "Enter project name"
}
}

View file

@ -1,10 +1,13 @@
{
"projects": "Tablos",
"home": "Home",
"tablos": "Projects",
"projects": "Projects",
"myEvents": "My Events",
"planning": "Planning",
"tasks": "Tasks",
"discussions": "Discussions",
"notes": "Notes",
"files": "Files",
"feedback": "Feedback",
"settings": "Settings",
"availabilities": "Availabilities",

View file

@ -41,8 +41,8 @@
"shareNoteDescription": "Control who can access this note",
"publicAccess": "Public Access",
"publicAccessDescription": "Anyone with the link can view this note",
"shareWithAllTablos": "Share with All Tablos",
"shareWithAllTablosDescription": "Members of all your tablos can view this note",
"shareWithAllTablos": "Share with All Projects",
"shareWithAllTablosDescription": "Members of all your projects can view this note",
"close": "Close",
"saveNoteBeforeSharing": "Please save the note before sharing",
"sharingSettingsUpdated": "Sharing settings updated",

View file

@ -3,14 +3,14 @@
"skipTutorial": "Skip onboarding",
"back": "Back",
"next": "Next",
"getStarted": "Create Tablo",
"getStarted": "Create Project",
"steps": {
"welcome": {
"title": "Welcome to XTablo",
"description": "Here, you will create your first Tablo - a clear, elegant project tracking space shareable with your clients or team.",
"description": "Here, you will create your first project - a clear, elegant project tracking space shareable with your clients or team.",
"content": {
"intro": "In less than 2 minutes, you will have a ready-to-use Tablo, structured around your services.",
"cta": "Create my first Tablo"
"intro": "In less than 2 minutes, you will have a ready-to-use project, structured around your services.",
"cta": "Create my first project"
}
},
"profile": {
@ -24,7 +24,7 @@
},
"service": {
"title": "What is your main service?",
"description": "Define the service you want to track in this Tablo",
"description": "Define the service you want to track in this project",
"question": "What is your service?",
"placeholder": "Ex: Web Development, Consulting..."
},
@ -36,9 +36,9 @@
"step_placeholder": "Step {{index}}"
},
"project": {
"title": "Project Name (Tablo)",
"description": "Give your first Tablo a name",
"question": "What would you like to call this tablo?",
"title": "Project Name",
"description": "Give your first project a name",
"question": "What would you like to call this project?",
"placeholder": "ex: Landing Page - Client X"
}
}

View file

@ -1,12 +1,17 @@
{
"tablo": {
"title": "Tablos",
"subtitle": "Manage your tablos and collaborations",
"createButton": "New tablo",
"greeting": {
"morning": "Good Morning",
"afternoon": "Good Afternoon",
"evening": "Good Evening"
},
"title": "Projects",
"subtitle": "Manage your projects and collaborations",
"createButton": "New project",
"emptyState": {
"title": "No tablos found",
"description": "Create your first tablo to start organizing your work",
"button": "Create your first tablo"
"title": "No projects found",
"description": "Create your first project to start organizing your work",
"button": "Create your first project"
},
"filter": {
"all": "All",
@ -30,7 +35,16 @@
"contextMenu": {
"openDiscussions": "Open discussions",
"openPlanning": "Open planning",
"delete": "Delete tablo"
"delete": "Delete project"
},
"card": {
"progress": "Progress"
},
"projectList": {
"title": "My Projects",
"seeAll": "See All",
"showAll": "See {{count}} more",
"showLess": "Show less"
},
"kpis": {
"total": "Total",
@ -53,7 +67,7 @@
"createEventType": "New type",
"search": "Search for an event...",
"filters": {
"allTablos": "All boards",
"allTablos": "All projects",
"upcoming": "Upcoming",
"past": "Past"
},
@ -87,10 +101,10 @@
},
"tasks": {
"title": "My Tasks",
"subtitle": "Manage all your tasks across all your tablos",
"subtitle": "Manage all your tasks across all your projects",
"search": "Search for a task...",
"filters": {
"allTablos": "All boards",
"allTablos": "All projects",
"allAssignees": "All assignees",
"assignedToMe": "Assigned to me",
"unassigned": "Unassigned"
@ -98,7 +112,7 @@
"emptyState": {
"title": "No tasks found",
"noResults": "Try changing your search filters.",
"noTasks": "Start by creating your first task in a tablo."
"noTasks": "Start by creating your first task in a project."
},
"unassigned": "Unassigned",
"pagination": {
@ -115,7 +129,7 @@
},
"view": {
"kanban": "Kanban View",
"aggregated": "By Tablo View"
"aggregated": "By Project View"
},
"createTask": "New Task"
},
@ -150,5 +164,35 @@
"title": "Thank you for your feedback!",
"description": "Your feedback has been sent successfully. We appreciate you taking the time to help us improve."
}
},
"dashboard": {
"actionCards": {
"createProject": {
"label": "Create Project",
"description": "Set goals and scope"
},
"createTask": {
"label": "Create Task",
"description": "Break work into actions"
},
"inviteTeam": {
"label": "Invite Team",
"description": "Add collaborators instantly"
},
"sendMessage": {
"label": "Send Message",
"description": "Communicate updates fast"
}
},
"taskList": {
"title": "My Tasks",
"addTask": "Add Task",
"status": {
"todo": "To Do",
"inProgress": "In Progress",
"inReview": "In Review",
"done": "Done"
}
}
}
}

View file

@ -1,8 +1,8 @@
{
"title": "Planning",
"allEvents": "All events",
"allTablos": "All tablos",
"selectTablo": "Select a tablo",
"allTablos": "All projects",
"selectTablo": "Select a project",
"createEvent": "Create event",
"importPlanning": "Import planning",
"today": "Today",

View file

@ -1,7 +1,7 @@
{
"overview": {
"title": "Overview",
"description": "Configure the Stages of the tablo to clarify the major phases of your tablo.",
"description": "Configure the Stages of the project to clarify the major phases of your project.",
"overallProgress": "Overall Progress",
"progressSummary": "{{done}} of {{total}} task(s) completed"
},
@ -9,10 +9,10 @@
"nameRequired": "The Stage name is required",
"namePlaceholder": "Stage Name",
"deleteConfirm": "Are you sure you want to delete the Stage \"{{name}}\"? Associated tasks will remain available.",
"noEtapes": "No Stages have been defined for this tablo yet.",
"createFirstEtape": "Create your first Stage to structure the tablo tasks.",
"onlyOwnerCanAdd": "Only the tablo owner can add Stages.",
"onlyOwnerCanModify": "Only the tablo owner can modify Stages. Contact the administrator if you need a new Stage.",
"noEtapes": "No Stages have been defined for this project yet.",
"createFirstEtape": "Create your first Stage to structure the project tasks.",
"onlyOwnerCanAdd": "Only the project owner can add Stages.",
"onlyOwnerCanModify": "Only the project owner can modify Stages. Contact the administrator if you need a new Stage.",
"stepNumber": "Stage {{number}}",
"addNew": "New Stage"
},
@ -25,6 +25,6 @@
},
"events": {
"title": "Upcoming events",
"description": "Manage the future events of this tablo"
"description": "Manage the future events of this project"
}
}

View file

@ -13,7 +13,13 @@
"forgotPassword": "Mot de passe oublié ?",
"loginButton": "Se connecter",
"noAccount": "Pas encore de compte ?",
"signupLink": "S'inscrire"
"signupLink": "S'inscrire",
"newExperienceLink": "Découvrez la nouvelle expérience de connexion",
"asideTitle": "Centralisez vos projets avec Xtablo",
"asideDescription": "Planifiez tâches, événements et collaboration depuis un espace unique.",
"feature1": "Suivez chaque échéance avec des timelines partagées",
"feature2": "Gardez fichiers, discussions et décisions au même endroit",
"feature3": "Donnez à votre équipe une source unique de vérité"
},
"signup": {
"title": "Créer un compte Xtablo",
@ -29,6 +35,11 @@
"signupButton": "Créer mon compte",
"alreadyAccount": "Déjà un compte ?",
"loginLink": "Se connecter",
"asideTitle": "Créez un workflow plus serein dès le premier jour",
"asideDescription": "Créez votre espace, invitez votre équipe et synchronisez vos projets.",
"feature1": "Des tableaux de tâches partagés avec des responsabilités claires",
"feature2": "Des vues planning pour les jalons et échéances",
"feature3": "Une collaboration rapide autour des fichiers et mises à jour",
"termsAccept": "J'accepte les",
"termsLink": "mentions légales",
"termsAnd": "et la",

View file

@ -24,7 +24,7 @@
},
"labels": {
"title": "Titre *",
"tablo": "Tablo *",
"tablo": "Projet *",
"date": "Date *",
"startTime": "Début *",
"endTime": "Fin",
@ -32,7 +32,7 @@
},
"placeholders": {
"title": "Ajouter un titre",
"tablo": "Sélectionner un tablo",
"tablo": "Sélectionner un projet",
"description": "Ajouter une description (optionnel)"
},
"buttons": {
@ -80,10 +80,10 @@
"required": "*"
},
"deleteTabloModal": {
"title": "Supprimer le tablo",
"title": "Supprimer le projet",
"subtitle": "Cette action est irréversible",
"confirmQuestion": "Êtes-vous sûr de vouloir supprimer le tablo",
"warning": "Toutes les données associées à ce tablo seront perdues définitivement.",
"confirmQuestion": "Êtes-vous sûr de vouloir supprimer le projet",
"warning": "Toutes les données associées à ce projet seront perdues définitivement.",
"buttons": {
"cancel": "Annuler",
"delete": "Supprimer",
@ -105,11 +105,11 @@
"importing": "Import en cours..."
},
"checkbox": {
"createNewTablo": "Créer un nouveau tablo"
"createNewTablo": "Créer un nouveau projet"
},
"placeholders": {
"newTabloName": "Nom du nouveau tablo",
"selectTablo": "Sélectionner un tablo existant"
"newTabloName": "Nom du nouveau projet",
"selectTablo": "Sélectionner un projet existant"
},
"messages": {
"eventsFound": "{{count}} événement(s) trouvé(s)",
@ -133,12 +133,12 @@
"description": "Aucun événement à importer"
},
"tabloRequired": {
"title": "Tablo requis",
"description": "Veuillez sélectionner un tablo ou créer un nouveau"
"title": "Projet requis",
"description": "Veuillez sélectionner un projet ou en créer un nouveau"
},
"tabloNameRequired": {
"title": "Nom du tablo requis",
"description": "Veuillez saisir un nom pour le nouveau tablo"
"title": "Nom du projet requis",
"description": "Veuillez saisir un nom pour le nouveau projet"
},
"importSuccess": {
"title": "Import réussi",

View file

@ -1,7 +1,7 @@
{
"createTablo": {
"title": "Créer un nouveau tablo",
"nameLabel": "Nom du tablo",
"namePlaceholder": "Entrez le nom du tablo"
"title": "Créer un nouveau projet",
"nameLabel": "Nom du projet",
"namePlaceholder": "Entrez le nom du projet"
}
}

View file

@ -1,10 +1,13 @@
{
"projects": "Tablos",
"home": "Aperçu",
"tablos": "Projets",
"projects": "Projets",
"myEvents": "Mes Événements",
"planning": "Planning",
"tasks": "Tâches",
"discussions": "Discussions",
"notes": "Notes",
"files": "Fichiers",
"feedback": "Feedback",
"settings": "Paramètres",
"availabilities": "Disponibilités",

View file

@ -41,8 +41,8 @@
"shareNoteDescription": "Contrôlez qui peut accéder à cette note",
"publicAccess": "Accès public",
"publicAccessDescription": "Toute personne avec le lien peut voir cette note",
"shareWithAllTablos": "Partager avec tous les tablos",
"shareWithAllTablosDescription": "Les membres de tous vos tablos peuvent voir cette note",
"shareWithAllTablos": "Partager avec tous les projets",
"shareWithAllTablosDescription": "Les membres de tous vos projets peuvent voir cette note",
"close": "Fermer",
"saveNoteBeforeSharing": "Veuillez enregistrer la note avant de la partager",
"sharingSettingsUpdated": "Paramètres de partage mis à jour",

View file

@ -3,14 +3,14 @@
"skipTutorial": "Passer l'onboarding",
"back": "Retour",
"next": "Suivant",
"getStarted": "Créer le Tablo",
"getStarted": "Créer le projet",
"steps": {
"welcome": {
"title": "Bienvenue sur XTablo",
"description": "Ici, vous allez créer votre premier Tablo - un espace de suivi projet, clair, élégant et partageable avec vos clients.",
"description": "Ici, vous allez créer votre premier projet - un espace de suivi clair, élégant et partageable avec vos clients.",
"content": {
"intro": "En moins de 2 minutes, vous aurez un Tablo prêt à l'emploi, structuré autour de vos services.",
"cta": "Créer mon premier Tablo"
"intro": "En moins de 2 minutes, vous aurez un projet prêt à l'emploi, structuré autour de vos services.",
"cta": "Créer mon premier projet"
}
},
"profile": {
@ -24,7 +24,7 @@
},
"service": {
"title": "Quel est votre service principal ?",
"description": "Définissez le service que vous souhaitez suivre dans ce Tablo",
"description": "Définissez le service que vous souhaitez suivre dans ce projet",
"question": "Quel est votre service ?",
"placeholder": "Ex: Création de site web, Consulting..."
},
@ -36,9 +36,9 @@
"step_placeholder": "Étape {{index}}"
},
"project": {
"title": "Nom du projet (tablo)",
"description": "Donnez un nom à votre premier Tablo",
"question": "Comment souhaitez-vous appeler ce tablo ?",
"title": "Nom du projet",
"description": "Donnez un nom à votre premier projet",
"question": "Comment souhaitez-vous appeler ce projet ?",
"placeholder": "ex : Landing Page - Client X"
}
}

View file

@ -1,12 +1,17 @@
{
"tablo": {
"title": "Tablos",
"subtitle": "Gérez vos tablos et collaborations",
"createButton": "Nouveau tablo",
"greeting": {
"morning": "Bonjour",
"afternoon": "Bonjour",
"evening": "Bonsoir"
},
"title": "Projets",
"subtitle": "Gérez vos projets et collaborations",
"createButton": "Nouveau projet",
"emptyState": {
"title": "Aucun tablo trouvé",
"description": "Créez votre premier tablo pour commencer à organiser votre travail",
"button": "Créer votre premier tablo"
"title": "Aucun projet trouvé",
"description": "Créez votre premier projet pour commencer à organiser votre travail",
"button": "Créer votre premier projet"
},
"filter": {
"all": "Tous",
@ -30,7 +35,16 @@
"contextMenu": {
"openDiscussions": "Ouvrir les discussions",
"openPlanning": "Ouvrir le planning",
"delete": "Supprimer le tablo"
"delete": "Supprimer le projet"
},
"card": {
"progress": "Progression"
},
"projectList": {
"title": "Mes Projets",
"seeAll": "Voir tout",
"showAll": "Voir {{count}} de plus",
"showLess": "Réduire"
},
"kpis": {
"total": "Total",
@ -53,7 +67,7 @@
"createEventType": "Nouveau type",
"search": "Rechercher un événement...",
"filters": {
"allTablos": "Tous les tablos",
"allTablos": "Tous les projets",
"upcoming": "À venir",
"past": "Passés"
},
@ -87,10 +101,10 @@
},
"tasks": {
"title": "Mes Tâches",
"subtitle": "Gérez toutes vos tâches à travers tous vos tablos",
"subtitle": "Gérez toutes vos tâches à travers tous vos projets",
"search": "Rechercher une tâche...",
"filters": {
"allTablos": "Tous les tablos",
"allTablos": "Tous les projets",
"allAssignees": "Tous les assignés",
"assignedToMe": "Assignées à moi",
"unassigned": "Non assignées"
@ -98,7 +112,7 @@
"emptyState": {
"title": "Aucune tâche trouvée",
"noResults": "Essayez de modifier vos filtres de recherche.",
"noTasks": "Commencez par créer votre première tâche dans un tablo."
"noTasks": "Commencez par créer votre première tâche dans un projet."
},
"unassigned": "Non assignée",
"pagination": {
@ -115,7 +129,7 @@
},
"view": {
"kanban": "Vue Kanban",
"aggregated": "Vue par tablo"
"aggregated": "Vue par projet"
},
"createTask": "Nouvelle tâche"
},
@ -150,5 +164,35 @@
"title": "Merci pour votre commentaire !",
"description": "Votre commentaire a été envoyé avec succès. Nous apprécions que vous ayez pris le temps de nous aider à nous améliorer."
}
},
"dashboard": {
"actionCards": {
"createProject": {
"label": "Créer un projet",
"description": "Définir les objectifs et le périmètre"
},
"createTask": {
"label": "Créer une tâche",
"description": "Découper le travail en actions"
},
"inviteTeam": {
"label": "Inviter l'équipe",
"description": "Ajouter des collaborateurs"
},
"sendMessage": {
"label": "Envoyer un message",
"description": "Communiquer rapidement"
}
},
"taskList": {
"title": "Mes Tâches",
"addTask": "Ajouter",
"status": {
"todo": "À faire",
"inProgress": "En cours",
"inReview": "En revue",
"done": "Terminé"
}
}
}
}

View file

@ -1,8 +1,8 @@
{
"title": "Planning",
"allEvents": "Tous les événements",
"allTablos": "Tous les tablos",
"selectTablo": "Sélectionner un tablo",
"allTablos": "Tous les projets",
"selectTablo": "Sélectionner un projet",
"createEvent": "Créer un événement",
"importPlanning": "Importer un planning",
"today": "Aujourd'hui",

View file

@ -1,7 +1,7 @@
{
"overview": {
"title": "Vue d'ensemble",
"description": "Configurez les Étapes du tablo pour clarifier les grandes phases de votre tablo.",
"description": "Configurez les Étapes du projet pour clarifier les grandes phases de votre projet.",
"overallProgress": "Progression globale",
"progressSummary": "{{done}} sur {{total}} tâche(s) terminée(s)"
},
@ -9,10 +9,10 @@
"nameRequired": "Le nom de l'Étape est requis",
"namePlaceholder": "Nom de l'Étape",
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer l'Étape \"{{name}}\" ? Les tâches associées resteront disponibles.",
"noEtapes": "Aucune Étape n'a encore été définie pour ce tablo.",
"createFirstEtape": "Créez votre première Étape pour structurer les tâches du tablo.",
"onlyOwnerCanAdd": "Seul le propriétaire du tablo peut ajouter des Étapes.",
"onlyOwnerCanModify": "Seul le propriétaire du tablo peut modifier les Étapes. Contactez l'administrateur si vous avez besoin d'une nouvelle Étape.",
"noEtapes": "Aucune Étape n'a encore été définie pour ce projet.",
"createFirstEtape": "Créez votre première Étape pour structurer les tâches du projet.",
"onlyOwnerCanAdd": "Seul le propriétaire du projet peut ajouter des Étapes.",
"onlyOwnerCanModify": "Seul le propriétaire du projet peut modifier les Étapes. Contactez l'administrateur si vous avez besoin d'une nouvelle Étape.",
"stepNumber": "Étape {{number}}",
"addNew": "Ajouter l'Étape"
},
@ -25,7 +25,7 @@
},
"events": {
"title": "Événements à venir",
"description": "Gérez les événements futurs de ce tablo",
"description": "Gérez les événements futurs de ce projet",
"createEvent": "Créer un événement"
}
}

File diff suppressed because it is too large Load diff

View file

@ -36,7 +36,11 @@ export function ChatPage() {
}, [channelFromUrl]);
return (
<div className="flex h-screen bg-gray-50 dark:bg-background">
<div className="flex flex-col h-[calc(100vh-75px)] bg-gray-50 dark:bg-background">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/40 shrink-0">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Discussions</h1>
</div>
<div className="flex flex-1 overflow-hidden">
<div
className={`border-r border-gray-200 dark:border-gray-600/50 bg-white dark:bg-gray-700/40 transition-all duration-300 ease-in-out overflow-hidden ${
isChannelListExpanded ? "w-80" : "w-0"
@ -78,6 +82,7 @@ export function ChatPage() {
</Window>
</Channel>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,501 @@
import { toast } from "@xtablo/shared";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { Button } from "@xtablo/ui/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@xtablo/ui/components/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@xtablo/ui/components/dropdown-menu";
import {
DownloadIcon,
EllipsisVerticalIcon,
FileTextIcon,
FolderIcon,
LayersIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useSearchParams } from "react-router-dom";
import {
extractFolderIdFromFileName,
getFileNameWithoutFolder,
getFolderFilePrefix,
useTabloFolders,
} from "../hooks/tablo_folders";
import {
useAllTablosFileNames,
useCreateTabloFile,
useDeleteTabloFile,
useDownloadTabloFile,
} from "../hooks/tablo_data";
import { useTablosList } from "../hooks/tablos";
// Derive icon color from file extension
function getFileIconColor(fileName: string): string {
const ext = fileName.split(".").pop()?.toLowerCase() ?? "";
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) return "bg-purple-500";
if (ext === "pdf") return "bg-red-500";
if (["xlsx", "xls", "csv"].includes(ext)) return "bg-green-600";
if (["doc", "docx"].includes(ext)) return "bg-blue-500";
return "bg-gray-500";
}
// ─── Upload Modal ────────────────────────────────────────────────────────────
function UploadModal({
isOpen,
onClose,
tablos,
}: {
isOpen: boolean;
onClose: () => void;
tablos: { id: string; name: string; color: string | null }[];
}) {
const [selectedTabloId, setSelectedTabloId] = useState<string>(tablos[0]?.id ?? "");
const [selectedFolderId, setSelectedFolderId] = useState<string>("");
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { data: foldersData } = useTabloFolders(selectedTabloId);
const folders = foldersData?.folders ?? [];
const createFile = useCreateTabloFile();
const handleTabloChange = (tabloId: string) => {
setSelectedTabloId(tabloId);
setSelectedFolderId("");
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !selectedTabloId) return;
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;
}
setIsUploading(true);
try {
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = (ev) => resolve(ev.target?.result as string);
reader.onerror = reject;
if (file.type.startsWith("text/") || file.type === "application/json") {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
});
const fileName = selectedFolderId
? `${getFolderFilePrefix(selectedFolderId)}${file.name}`
: file.name;
await createFile.mutateAsync({
tabloId: selectedTabloId,
fileName,
data: { content, contentType: file.type || "application/octet-stream" },
});
onClose();
} catch {
// error handled by hook
} finally {
setIsUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Upload a file</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
{/* Project selector */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Project</label>
<div className="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto">
{tablos.map((tablo) => (
<button
key={tablo.id}
type="button"
onClick={() => handleTabloChange(tablo.id)}
className={`flex items-center gap-3 px-3 py-2 rounded-lg border text-left transition-colors ${
selectedTabloId === tablo.id
? "border-primary bg-primary/5 dark:bg-primary/10"
: "border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<span
className={`w-6 h-6 rounded-md shrink-0 flex items-center justify-center text-xs font-bold text-white ${tablo.color ?? "bg-gray-500"}`}
>
{tablo.name.charAt(0).toUpperCase()}
</span>
<span className="text-sm text-gray-900 dark:text-gray-100 truncate">{tablo.name}</span>
</button>
))}
</div>
</div>
{/* Folder selector (optional) */}
{folders.length > 0 && (
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Folder (optional)</label>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setSelectedFolderId("")}
className={`px-3 py-1.5 rounded-lg border text-sm transition-colors ${
selectedFolderId === ""
? "border-primary bg-primary/5 dark:bg-primary/10 text-primary"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
No folder
</button>
{folders.map((folder) => (
<button
key={folder.id}
type="button"
onClick={() => setSelectedFolderId(folder.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm transition-colors ${
selectedFolderId === folder.id
? "border-primary bg-primary/5 dark:bg-primary/10 text-primary"
: "border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<FolderIcon className="w-3.5 h-3.5" />
{folder.name}
</button>
))}
</div>
</div>
)}
{/* File picker */}
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileSelect} />
<Button
className="w-full gap-2"
onClick={() => fileInputRef.current?.click()}
disabled={!selectedTabloId || isUploading}
>
{isUploading ? (
<>
<LoadingSpinner />
Uploading...
</>
) : (
<>
<PlusIcon className="w-4 h-4" />
Choose file &amp; upload
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
// ─── Folder cards grid ───────────────────────────────────────────────────────
function FolderGrid({
folders,
folderMap,
}: {
folders: { id: string; name: string; description?: string }[];
folderMap: Map<string, string[]>;
}) {
const visibleFolders = folders.filter((f) => (folderMap.get(f.id) ?? []).length > 0);
if (visibleFolders.length === 0) return null;
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mb-5">
{visibleFolders.map((folder) => {
const count = folderMap.get(folder.id)?.length ?? 0;
return (
<div
key={folder.id}
className="bg-white dark:bg-gray-800 rounded-xl p-4 border border-[#F2F4F7] dark:border-gray-700 hover:shadow-sm transition-all"
>
<div className="flex flex-col items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center">
<FolderIcon className="w-5 h-5 text-amber-500 dark:text-amber-400" />
</div>
<div className="min-w-0">
<p className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-tight truncate">
{folder.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{count} file{count !== 1 ? "s" : ""}
</p>
</div>
</div>
</div>
);
})}
</div>
);
}
// ─── Per-project section ─────────────────────────────────────────────────────
function TabloFilesSection({
tabloId,
tabloName,
tabloColor,
fileNames,
}: {
tabloId: string;
tabloName: string;
tabloColor: string | null;
fileNames: string[];
}) {
const { data: foldersData } = useTabloFolders(tabloId);
const { mutate: downloadFile } = useDownloadTabloFile();
const { mutate: deleteFile } = useDeleteTabloFile();
const allFileNames = fileNames.filter((f) => !f.startsWith("."));
const folders = foldersData?.folders ?? [];
const folderMap = new Map<string, string[]>();
const rootFiles: string[] = [];
for (const fileName of allFileNames) {
const folderId = extractFolderIdFromFileName(fileName);
if (folderId) {
if (!folderMap.has(folderId)) folderMap.set(folderId, []);
folderMap.get(folderId)!.push(fileName);
} else {
rootFiles.push(fileName);
}
}
if (allFileNames.length === 0) return null;
return (
<div className="mb-10">
{/* Project header */}
<div className="flex items-center gap-3 mb-4">
<span
className={`w-7 h-7 rounded-lg shrink-0 flex items-center justify-center text-sm font-bold text-white ${tabloColor ?? "bg-gray-500"}`}
>
{tabloName.charAt(0).toUpperCase()}
</span>
<Link
to={`/tablos/${tabloId}`}
className="text-lg font-semibold text-gray-900 dark:text-gray-100 hover:underline"
>
{tabloName}
</Link>
<span className="text-sm text-gray-400 dark:text-gray-500">
{allFileNames.length} file{allFileNames.length !== 1 ? "s" : ""}
</span>
</div>
{/* Folder cards */}
<FolderGrid folders={folders} folderMap={folderMap} />
{/* Files per folder */}
{folders.map((folder) => {
const folderFiles = folderMap.get(folder.id) ?? [];
if (folderFiles.length === 0) return null;
return (
<div key={folder.id} className="mb-4">
<div className="flex items-center gap-2 mb-2 px-1">
<FolderIcon className="w-4 h-4 text-amber-500 dark:text-amber-400" />
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{folder.name}</span>
<span className="text-xs text-gray-400 dark:text-gray-500">({folderFiles.length})</span>
</div>
<FileTable
fileNames={folderFiles}
onDownload={(fileName) => downloadFile({ tabloId, fileName })}
onDelete={(fileName) => deleteFile({ tabloId, fileName })}
/>
</div>
);
})}
{/* Root files */}
{rootFiles.length > 0 && (
<>
{folders.length > 0 && (
<div className="flex items-center gap-2 mb-2 px-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Other files</span>
<span className="text-xs text-gray-400 dark:text-gray-500">({rootFiles.length})</span>
</div>
)}
<FileTable
fileNames={rootFiles}
onDownload={(fileName) => downloadFile({ tabloId, fileName })}
onDelete={(fileName) => deleteFile({ tabloId, fileName })}
/>
</>
)}
</div>
);
}
// ─── File table ───────────────────────────────────────────────────────────────
function FileTable({
fileNames,
onDownload,
onDelete,
}: {
fileNames: string[];
onDownload: (fileName: string) => void;
onDelete: (fileName: string) => void;
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-2xl border border-gray-100 dark:border-gray-700 overflow-hidden mb-4">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-y border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/80">
<tr>
<th className="px-6 py-3 text-left text-sm font-normal text-gray-900 dark:text-gray-300">File name</th>
<th className="px-6 py-3 w-12" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{fileNames.map((fileName) => {
const displayName = getFileNameWithoutFolder(fileName);
const iconColor = getFileIconColor(displayName);
return (
<tr key={fileName} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors group">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 ${iconColor} rounded-lg flex items-center justify-center flex-shrink-0`}
>
<FileTextIcon className="w-5 h-5 text-white" />
</div>
<p className="text-sm font-normal text-gray-900 dark:text-gray-100">{displayName}</p>
</div>
</td>
<td className="px-6 py-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors opacity-0 group-hover:opacity-100"
aria-label={`Actions for ${displayName}`}
>
<EllipsisVerticalIcon className="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onDownload(fileName)}>
<DownloadIcon className="w-4 h-4 mr-2" />
Download
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onDelete(fileName)}
className="text-red-600 dark:text-red-400 focus:text-red-600 dark:focus:text-red-400"
>
<Trash2Icon className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export function FilesPage() {
const { t } = useTranslation("navigation");
const [searchParams] = useSearchParams();
const searchQuery = searchParams.get("q")?.toLowerCase() ?? "";
const { data: tablos, isLoading: tablosLoading } = useTablosList();
const { data: allFiles, isLoading: filesLoading } = useAllTablosFileNames();
const [uploadOpen, setUploadOpen] = useState(false);
const isLoading = tablosLoading || filesLoading;
const filesByTabloId = new Map<string, string[]>(
(allFiles?.tablos ?? []).map(({ tabloId, fileNames }) => [tabloId, fileNames])
);
const tablosWithFiles = (tablos ?? []).filter((tablo) => {
const files = filesByTabloId.get(tablo.id) ?? [];
const visibleFiles = files.filter((f) => !f.startsWith("."));
if (searchQuery) {
return visibleFiles.some((f) =>
f.toLowerCase().includes(searchQuery) || tablo.name.toLowerCase().includes(searchQuery)
);
}
return visibleFiles.length > 0;
});
return (
<div className="py-6 px-6">
{/* Header */}
<div className="flex items-center justify-between pb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">{t("files", "Files")}</h1>
<Button
className="gap-2 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
onClick={() => setUploadOpen(true)}
disabled={!tablos || tablos.length === 0}
>
<PlusIcon className="w-5 h-5" />
Upload
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-24">
<LoadingSpinner />
</div>
) : tablosWithFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<LayersIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
<p className="text-gray-500 dark:text-gray-400">No files found.</p>
</div>
) : (
<div>
{tablosWithFiles.map((tablo) => (
<TabloFilesSection
key={tablo.id}
tabloId={tablo.id}
tabloName={tablo.name}
tabloColor={tablo.color}
fileNames={filesByTabloId.get(tablo.id) ?? []}
/>
))}
</div>
)}
{tablos && tablos.length > 0 && (
<UploadModal
isOpen={uploadOpen}
onClose={() => setUploadOpen(false)}
tablos={tablos}
/>
)}
</div>
);
}

View file

@ -0,0 +1,278 @@
import { AnimatedBackground } from "@ui/components/AnimatedBackground";
import { LoginWithGoogle } from "@ui/components/BrandButtons/LoginWithGoogle";
import { useTheme } from "@xtablo/shared/contexts/ThemeContext";
import { Button } from "@xtablo/ui/components/button";
import { FieldError } from "@xtablo/ui/components/field";
import { Input } from "@xtablo/ui/components/input";
import { Label } from "@xtablo/ui/components/label";
import {
ArrowLeftIcon,
MonitorIcon,
MoonIcon,
SparklesIcon,
SunIcon,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link, useSearchParams } from "react-router-dom";
import { useLoginEmail } from "../hooks/auth";
export function LoginV2Page() {
const [searchParams] = useSearchParams();
const emailParam = searchParams.get("email");
const { t } = useTranslation(["auth", "common"]);
const redirectUrl = localStorage.getItem("redirectUrl");
const {
mutate: login,
isPending,
errors,
} = useLoginEmail({
redirectUrl: redirectUrl ?? null,
});
const [formData, setFormData] = useState({
email: emailParam ?? "",
password: "",
});
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
if (theme === "light") {
setTheme("dark");
} else if (theme === "dark") {
setTheme("system");
} else {
setTheme("light");
}
};
const getThemeIcon = () => {
switch (theme) {
case "light":
return <SunIcon className="w-5 h-5" />;
case "dark":
return <MoonIcon className="w-5 h-5" />;
case "system":
return <MonitorIcon className="w-5 h-5" />;
default:
return <SunIcon className="w-5 h-5" />;
}
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
login({
email: formData.email,
password: formData.password,
});
};
return (
<div className="relative min-h-screen overflow-hidden bg-background text-foreground">
<AnimatedBackground />
<div className="relative z-10 min-h-screen md:grid md:grid-cols-[minmax(0,560px)_1fr]">
<main className="flex min-h-screen items-center px-6 py-10 md:px-10 lg:px-14">
<div className="w-full max-w-md mx-auto">
<div className="mb-7 flex items-center justify-between">
<a
href="https://www.xtablo.com"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeftIcon className="w-4 h-4" />
{t("auth:common.backHome")}
</a>
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="text-muted-foreground hover:text-foreground"
aria-label={t("auth:common.themeToggle", { theme })}
>
{getThemeIcon()}
</Button>
</div>
<div className="mb-6">
<img
src="/logo_dark.png"
alt="Xtablo"
className="h-10 w-auto block dark:hidden"
/>
<img
src="/logo_white.png"
alt="Xtablo"
className="h-10 w-auto hidden dark:block"
/>
</div>
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("auth:login.title")}
</h1>
<p className="text-sm text-muted-foreground mb-8">
{t("auth:login.noAccount")}{" "}
<Link
to="/signup-v2"
className="text-[#804EEC] hover:text-[#6f3fd4] font-semibold"
>
{t("auth:login.signupLink")}
</Link>
</p>
<form className="space-y-4" onSubmit={onSubmit}>
<div className="space-y-2">
<Label htmlFor="email">
{t("common:labels.email")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
placeholder={t("auth:login.emailPlaceholder")}
className="h-11"
/>
{errors?.email && (
<FieldError errors={[{ message: errors.email }]} />
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">
{t("common:labels.password")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="password"
name="password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required
placeholder={t("auth:login.passwordPlaceholder")}
className="h-11"
/>
{errors?.password && (
<FieldError errors={[{ message: errors.password }]} />
)}
</div>
<div className="flex items-center justify-end">
<Link
to="/reset-password"
className="text-sm text-[#804EEC] hover:text-[#6f3fd4] transition-colors"
>
{t("auth:login.forgotPassword")}
</Link>
</div>
<Button
className="w-full h-11 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
type="submit"
>
{isPending
? t("auth:common.connecting")
: t("auth:login.loginButton")}
</Button>
</form>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center">
<span className="px-3 bg-background text-xs text-muted-foreground">
{t("auth:common.orContinue")}
</span>
</div>
</div>
<LoginWithGoogle />
<div className="mt-10 text-xs text-muted-foreground space-y-2">
<p>
© {new Date().getFullYear()} Xtablo. {t("auth:common.backHome")}
</p>
<p className="flex gap-3">
<Link
to="/legal-notice"
target="_blank"
className="hover:text-foreground transition-colors"
>
{t("auth:signup.termsLink")}
</Link>
<span>|</span>
<Link
to="/privacy-policy"
target="_blank"
className="hover:text-foreground transition-colors"
>
{t("auth:signup.privacyLink")}
</Link>
</p>
</div>
</div>
</main>
<aside className="hidden md:flex min-h-screen items-center justify-center px-8 lg:px-14 border-l border-border bg-gradient-to-br from-[#F5F0FF] via-[#ECE4FF] to-[#DCCEFF] dark:from-[#201933] dark:via-[#271F3E] dark:to-[#2F2548]">
<div className="relative w-full max-w-xl rounded-3xl border border-white/50 dark:border-white/10 bg-white/80 dark:bg-[#171224]/85 backdrop-blur-sm shadow-2xl p-10">
<SparklesIcon className="absolute top-5 left-5 w-5 h-5 text-[#804EEC] opacity-70" />
<SparklesIcon className="absolute top-5 right-5 w-5 h-5 text-[#804EEC] opacity-70" />
<SparklesIcon className="absolute bottom-5 left-5 w-5 h-5 text-[#804EEC] opacity-70" />
<SparklesIcon className="absolute bottom-5 right-5 w-5 h-5 text-[#804EEC] opacity-70" />
<div className="flex justify-center mb-6">
<div className="w-20 h-20 rounded-full border border-border bg-card flex items-center justify-center shadow-sm">
<img
src="/logo_dark.png"
alt="Xtablo"
className="w-12 h-12 object-contain block dark:hidden"
/>
<img
src="/logo_white.png"
alt="Xtablo"
className="w-12 h-12 object-contain hidden dark:block"
/>
</div>
</div>
<h2 className="text-2xl font-bold text-center mb-3">
{t("auth:login.asideTitle")}
</h2>
<p className="text-center text-muted-foreground mb-8">
{t("auth:login.asideDescription")}
</p>
<div className="space-y-3 mb-8">
<div className="rounded-xl border border-border/70 bg-background/80 px-4 py-3 text-sm">
{t("auth:login.feature1")}
</div>
<div className="rounded-xl border border-border/70 bg-background/80 px-4 py-3 text-sm">
{t("auth:login.feature2")}
</div>
<div className="rounded-xl border border-border/70 bg-background/80 px-4 py-3 text-sm">
{t("auth:login.feature3")}
</div>
</div>
<Link
to="/signup-v2"
className="inline-flex w-full items-center justify-center rounded-xl bg-[#804EEC] px-5 py-3 text-sm font-semibold text-white hover:bg-[#6f3fd4] transition-colors"
>
{t("auth:login.signupLink")}
</Link>
</div>
</aside>
</div>
</div>
);
}

View file

@ -50,7 +50,7 @@ export function LoginPage() {
const rotateY = ((x - centerX) / centerX) * 1;
setTransform(
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.002, 1.002, 1.002)`
`perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.002, 1.002, 1.002)`,
);
setIsHovered(true);
};
@ -101,7 +101,7 @@ export function LoginPage() {
ref={cardRef}
className={twMerge(
"w-full max-w-lg rounded-2xl relative",
"transition-transform duration-200 ease-out will-change-transform"
"transition-transform duration-200 ease-out will-change-transform",
)}
style={{ transform }}
onMouseMove={handleMouseMove}
@ -116,7 +116,7 @@ export function LoginPage() {
"relative w-full h-full p-8 bg-card/80 backdrop-blur-md rounded-2xl border border-border z-10 transition-shadow duration-200",
isHovered
? "shadow-[0_15px_35px_rgba(0,0,0,0.15)] dark:shadow-[0_15px_35px_rgba(0,0,0,0.3)]"
: "shadow-xl shadow-black/10 dark:shadow-black/25"
: "shadow-xl shadow-black/10 dark:shadow-black/25",
)}
>
<div className="mb-6 flex items-center justify-between">
@ -124,7 +124,12 @@ export function LoginPage() {
href="https://www.xtablo.com"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
@ -165,38 +170,60 @@ export function LoginPage() {
{t("auth:login.title")}
</h1>
<div className="mb-6 text-center">
<Link
to="/login-v2"
className="inline-flex items-center text-sm text-[#804EEC] hover:text-[#6f3fd4] font-medium transition-colors"
>
{t("auth:login.newExperienceLink")}
</Link>
</div>
<div className="space-y-4 flex flex-col items-center">
<form className="space-y-4 w-95 max-w-md mx-auto" onSubmit={onSubmit}>
<form
className="space-y-4 w-95 max-w-md mx-auto"
onSubmit={onSubmit}
>
<div className="space-y-2">
<Label htmlFor="email">
{t("common:labels.email")} <span className="text-red-500">*</span>
{t("common:labels.email")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
placeholder={t("auth:login.emailPlaceholder")}
/>
{errors?.email && <FieldError errors={[{ message: errors.email }]} />}
{errors?.email && (
<FieldError errors={[{ message: errors.email }]} />
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">
{t("common:labels.password")} <span className="text-red-500">*</span>
{t("common:labels.password")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="password"
name="password"
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required
placeholder={t("auth:login.passwordPlaceholder")}
/>
{errors?.password && <FieldError errors={[{ message: errors.password }]} />}
{errors?.password && (
<FieldError errors={[{ message: errors.password }]} />
)}
</div>
<div className="flex items-center justify-end">
@ -209,7 +236,9 @@ export function LoginPage() {
</div>
<Button className="w-full" type="submit">
{isPending ? t("auth:common.connecting") : t("auth:login.loginButton")}
{isPending
? t("auth:common.connecting")
: t("auth:login.loginButton")}
</Button>
</form>
@ -226,7 +255,7 @@ export function LoginPage() {
"rounded-full",
"relative z-10",
"before:absolute before:w-[100px] before:h-px before:bg-border before:left-[-110px] before:top-1/2",
"after:absolute after:w-[100px] after:h-px after:bg-border after:right-[-110px] after:top-1/2"
"after:absolute after:w-[100px] after:h-px after:bg-border after:right-[-110px] after:top-1/2",
)}
>
{t("auth:common.orContinue")}

View file

@ -3,6 +3,13 @@ import { WebcalModal } from "@ui/components/WebcalModal";
import { downloadICSFile, generateICSFromEvents, toast } from "@xtablo/shared";
import { EventAndTablo } from "@xtablo/shared/types/events.types";
import { Button } from "@xtablo/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@xtablo/ui/components/dropdown-menu";
import {
Select,
SelectContent,
@ -11,16 +18,77 @@ import {
SelectValue,
} from "@xtablo/ui/components/select";
import { TypographyH3, TypographyH4 } from "@xtablo/ui/components/typography";
import { Download, FolderInputIcon, PlusIcon, RefreshCcw } from "lucide-react";
import {
ClockIcon,
Compass,
Download,
EllipsisVerticalIcon,
Flame,
FolderIcon,
FolderInputIcon,
Gem,
Heart,
Leaf,
PlusIcon,
RefreshCcw,
Sparkles,
Star,
Sun,
Waves,
Zap,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Outlet, useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
Outlet,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { useDeleteEvent, useEventsByTablo } from "../hooks/events";
import { useGetAllTabloAccess, useTablosList } from "../hooks/tablos";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
import { EventModal } from "../components/EventModal";
type ViewType = "month" | "week" | "day";
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 {
switch (color) {
case "bg-yellow-500":
case "bg-cyan-500":
return "text-gray-700";
default:
return "text-white";
}
}
export const PlanningPage = () => {
const { t } = useTranslation(["planning", "common"]);
const { tablo_id } = useParams();
@ -32,21 +100,27 @@ export const PlanningPage = () => {
// Initialize view from URL search params, default to "month"
const viewFromUrl = searchParams.get("view") as ViewType | null;
const initialView: ViewType =
viewFromUrl && ["month", "week", "day"].includes(viewFromUrl) ? viewFromUrl : "month";
viewFromUrl && ["month", "week", "day"].includes(viewFromUrl)
? viewFromUrl
: "month";
const [currentView, setCurrentView] = useState<ViewType>(initialView);
const [selectedTabloId, setSelectedTabloId] = useState<string>(tablo_id || "all");
const [selectedTabloId, setSelectedTabloId] = useState<string>(
tablo_id || "all",
);
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
const [isWebcalModalOpen, setIsWebcalModalOpen] = useState(false);
const isReadOnly = useIsReadOnlyUser();
const currentTab = searchParams.get("tab") ?? "calendar";
const [showAllEvents, setShowAllEvents] = useState(false);
const [isCreateEventOpen, setIsCreateEventOpen] = useState(false);
// Fetch tablos
const { data: tablos, isLoading: tablosLoading } = useTablosList();
// Fetch events for selected tablo or all tablos
const { data: tabloEvents = [], isLoading: tabloEventsLoading } = useEventsByTablo(
selectedTabloId !== "all" ? selectedTabloId : null
);
const { data: tabloEvents = [], isLoading: tabloEventsLoading } =
useEventsByTablo(selectedTabloId !== "all" ? selectedTabloId : null);
// Fetch all tablo accesses
const { data: tabloAccess } = useGetAllTabloAccess();
@ -57,7 +131,7 @@ export const PlanningPage = () => {
if (
tabloAccess?.find(
(access: { tablo_id: string; is_admin: boolean }) =>
access.tablo_id === event.tablo_id && access.is_admin
access.tablo_id === event.tablo_id && access.is_admin,
)
) {
return true;
@ -80,13 +154,17 @@ export const PlanningPage = () => {
return newParams;
});
},
[setSearchParams]
[setSearchParams],
);
// Sync view with URL on mount and when URL changes
useEffect(() => {
const viewParam = searchParams.get("view") as ViewType | null;
if (viewParam && ["month", "week", "day"].includes(viewParam) && viewParam !== currentView) {
if (
viewParam &&
["month", "week", "day"].includes(viewParam) &&
viewParam !== currentView
) {
setCurrentView(viewParam);
} else if (!viewParam) {
// If no view param in URL, set it to current view
@ -96,7 +174,7 @@ export const PlanningPage = () => {
newParams.set("view", currentView);
return newParams;
},
{ replace: true }
{ replace: true },
);
}
}, [searchParams, currentView, setSearchParams]);
@ -147,7 +225,8 @@ export const PlanningPage = () => {
const calendarName =
selectedTabloId === "all"
? t("planning:allEvents")
: tablos?.find((t) => t.id === selectedTabloId)?.name || t("planning:title");
: tablos?.find((t) => t.id === selectedTabloId)?.name ||
t("planning:title");
const icsContent = generateICSFromEvents(tabloEvents, calendarName);
const filename =
@ -172,6 +251,18 @@ export const PlanningPage = () => {
}
};
const navigateToEditEvent = (event: EventAndTablo) => {
if (!canEditEvent(event) || isReadOnly) return;
const params = new URLSearchParams(searchParams);
params.set("tab", "events");
navigate({
pathname: `/planning/${event.tablo_id}/events/${event.event_id}/edit`,
search: `?${params.toString()}`,
});
};
const monthNames = [
t("planning:months.january"),
t("planning:months.february"),
@ -264,7 +355,12 @@ export const PlanningPage = () => {
// const nowMinute = now.getMinutes();
const nowDay = now.getDate();
fullDate.setHours(Number(time.split(":")[0]), Number(time.split(":")[1]), 0, 0);
fullDate.setHours(
Number(time.split(":")[0]),
Number(time.split(":")[1]),
0,
0,
);
const hour = fullDate.getHours();
// const minute = fullDate.getMinutes();
@ -332,7 +428,8 @@ export const PlanningPage = () => {
const daysInMonth = lastDay.getDate();
// Adjust for Monday as first day of week
const startingDayOfWeek = firstDay.getDay();
const mondayStartingDay = startingDayOfWeek === 0 ? 6 : startingDayOfWeek - 1;
const mondayStartingDay =
startingDayOfWeek === 0 ? 6 : startingDayOfWeek - 1;
const days = [];
for (let i = 0; i < mondayStartingDay; i++) {
@ -369,7 +466,9 @@ export const PlanningPage = () => {
if (currentView === "week") {
const weekDays = getWeekDays();
const weekDateStrings = weekDays.map(formatDate);
return tabloEvents.filter((event) => weekDateStrings.includes(event.start_date));
return tabloEvents.filter((event) =>
weekDateStrings.includes(event.start_date),
);
} else if (currentView === "day") {
const dateString = formatDate(currentDate);
return tabloEvents.filter((event) => event.start_date === dateString);
@ -390,7 +489,7 @@ export const PlanningPage = () => {
...visibleEvents.map((event) => {
const [hour] = event.start_time.split(":").map(Number);
return hour;
})
}),
);
// Return the earlier of 8am or the earliest event hour
@ -414,7 +513,7 @@ export const PlanningPage = () => {
}
const [hour] = event.end_time.split(":").map(Number);
return hour;
})
}),
);
// Return the later of 7pm or the latest event hour
@ -441,7 +540,7 @@ export const PlanningPage = () => {
return Array.from(
{ length: numSlots },
(_, i) => `${(startHour + i).toString().padStart(2, "0")}:00`
(_, i) => `${(startHour + i).toString().padStart(2, "0")}:00`,
);
};
@ -469,7 +568,9 @@ export const PlanningPage = () => {
className={`min-h-[120px] border-b border-border ${
(index + 1) % 7 !== 0 ? "border-r border-border" : ""
} ${day ? "cursor-pointer hover:bg-muted" : "bg-muted"} ${
day && formatDate(day) === formatDate(new Date()) ? "bg-primary/10" : ""
day && formatDate(day) === formatDate(new Date())
? "bg-primary/10"
: ""
}`}
onClick={() => {
if (day) {
@ -487,7 +588,9 @@ export const PlanningPage = () => {
<div className="p-2">
<div
className={`text-sm font-medium mb-1 ${
formatDate(day) === formatDate(new Date()) ? "text-primary" : "text-foreground"
formatDate(day) === formatDate(new Date())
? "text-primary"
: "text-foreground"
}`}
>
{day.getDate()}
@ -514,14 +617,18 @@ export const PlanningPage = () => {
onClick={(e) => {
e.stopPropagation();
if (canEditEvent(event)) {
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
navigate(
`/planning/${event.tablo_id}/events/${event.event_id}/edit`,
);
}
}}
>
<div className="truncate">
{formatTime(event.start_time)} {event.title}
{selectedTabloId === "all" && event.tablo_name && (
<span className="opacity-75 ml-1"> {event.tablo_name}</span>
<span className="opacity-75 ml-1">
{event.tablo_name}
</span>
)}
</div>
{canDeleteEvent(event) && (
@ -569,7 +676,9 @@ export const PlanningPage = () => {
</div>
<div
className={`text-lg font-medium mt-1 ${
formatDate(day) === formatDate(new Date()) ? "text-primary" : "text-foreground"
formatDate(day) === formatDate(new Date())
? "text-primary"
: "text-foreground"
}`}
>
{day.getDate()}
@ -611,10 +720,18 @@ export const PlanningPage = () => {
)}
{getEventsForDate(day)
.filter((event) => event.start_time.startsWith(time.split(":")[0]))
.filter((event) =>
event.start_time.startsWith(time.split(":")[0]),
)
.map((event) => {
const eventHeight = calculateEventHeight(event.start_time, event.end_time);
const eventOffset = calculateEventOffset(event.start_time, time);
const eventHeight = calculateEventHeight(
event.start_time,
event.end_time,
);
const eventOffset = calculateEventOffset(
event.start_time,
time,
);
return (
<div
key={event.event_id}
@ -631,7 +748,7 @@ export const PlanningPage = () => {
minHeight: "30px",
}}
title={`${formatTime(event.start_time)} - ${formatTime(
event.end_time
event.end_time,
)} ${event.title}${
selectedTabloId === "all" && event.tablo_name
? ` - ${event.tablo_name}`
@ -640,19 +757,24 @@ export const PlanningPage = () => {
onClick={(e) => {
e.stopPropagation();
if (canEditEvent(event)) {
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
navigate(
`/planning/${event.tablo_id}/events/${event.event_id}/edit`,
);
}
}}
>
<div className="text-[10px] font-medium leading-tight">
{event.title}
{selectedTabloId === "all" && event.tablo_name && (
<span className="opacity-75 ml-1"> {event.tablo_name}</span>
<span className="opacity-75 ml-1">
{event.tablo_name}
</span>
)}
</div>
{eventHeight >= 30 && (
<div className="text-[9px] opacity-75 leading-tight">
{formatTime(event.start_time)} - {formatTime(event.end_time)}
{formatTime(event.start_time)} -{" "}
{formatTime(event.end_time)}
</div>
)}
{canDeleteEvent(event) && (
@ -685,7 +807,9 @@ export const PlanningPage = () => {
<div className="text-sm text-muted-foreground uppercase">
{dayNames[currentDate.getDay()]}
</div>
<div className="text-2xl font-medium text-foreground mt-1">{currentDate.getDate()}</div>
<div className="text-2xl font-medium text-foreground mt-1">
{currentDate.getDate()}
</div>
</div>
{/* Time slots */}
@ -718,10 +842,18 @@ export const PlanningPage = () => {
)}
{getEventsForDate(currentDate)
.filter((event) => event.start_time.startsWith(time.split(":")[0]))
.filter((event) =>
event.start_time.startsWith(time.split(":")[0]),
)
.map((event) => {
const eventHeight = calculateEventHeight(event.start_time, event.end_time);
const eventOffset = calculateEventOffset(event.start_time, time);
const eventHeight = calculateEventHeight(
event.start_time,
event.end_time,
);
const eventOffset = calculateEventOffset(
event.start_time,
time,
);
return (
<div
key={event.event_id}
@ -738,7 +870,7 @@ export const PlanningPage = () => {
minHeight: "30px",
}}
title={`${formatTime(event.start_time)} - ${formatTime(
event.end_time
event.end_time,
)} ${event.title}${
selectedTabloId === "all" && event.tablo_name
? ` - ${event.tablo_name}`
@ -747,19 +879,24 @@ export const PlanningPage = () => {
onClick={(e) => {
e.stopPropagation();
if (canEditEvent(event)) {
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
navigate(
`/planning/${event.tablo_id}/events/${event.event_id}/edit`,
);
}
}}
>
<div className="text-[10px] font-medium truncate leading-tight">
{event.title}
{selectedTabloId === "all" && event.tablo_name && (
<span className="opacity-75 ml-1"> {event.tablo_name}</span>
<span className="opacity-75 ml-1">
{event.tablo_name}
</span>
)}
</div>
{eventHeight >= 30 && (
<div className="text-[9px] opacity-75 leading-tight">
{formatTime(event.start_time)} - {formatTime(event.end_time)}
{formatTime(event.start_time)} -{" "}
{formatTime(event.end_time)}
</div>
)}
{eventHeight >= 75 && event.description && (
@ -789,6 +926,226 @@ export const PlanningPage = () => {
</div>
);
// ── Events card view ──────────────────────────────────────────────────────
const renderEventsView = () => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const filtered = tabloEvents.filter((e) => {
if (showAllEvents) return true;
const eventDate = e.start_date
? new Date(e.start_date + "T00:00:00")
: null;
return !eventDate || eventDate >= today;
});
const months = [
"JAN",
"FÉV",
"MAR",
"AVR",
"MAI",
"JUN",
"JUL",
"AOÛ",
"SEP",
"OCT",
"NOV",
"DÉC",
];
return (
<div className="py-6 px-2">
{/* Header */}
<div className="flex items-center justify-between mb-6 flex-wrap gap-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{t("planning:events", "Événements")}
</h1>
<button
type="button"
onClick={() => {
if (!isReadOnly) setIsCreateEventOpen(true);
}}
disabled={isReadOnly}
className="flex items-center gap-2 px-5 py-3 bg-[#804EEC] hover:bg-[#6f3fd4] text-white rounded-xl transition-colors font-medium shadow-sm disabled:opacity-50"
>
<PlusIcon className="w-5 h-5" />
<span>{t("planning:createEvent")}</span>
</button>
</div>
{/* Filter toggle */}
<div className="flex items-center gap-2 mb-8">
<button
type="button"
onClick={() => setShowAllEvents(false)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors border ${
!showAllEvents
? "bg-[#804EEC] text-white border-[#804EEC]"
: "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
À venir
</button>
<button
type="button"
onClick={() => setShowAllEvents(true)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors border ${
showAllEvents
? "bg-[#804EEC] text-white border-[#804EEC]"
: "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700"
}`}
>
Tous les événements
</button>
</div>
{/* Event cards grid */}
{tabloEventsLoading ? (
<div className="flex items-center justify-center py-24">
<img
src="/icon.jpg"
alt="Loading..."
className="animate-spin rounded-full h-8 w-8 object-cover"
/>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">
Aucun événement trouvé
</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
{showAllEvents
? "Aucun événement trouvé"
: "Aucun événement à venir"}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{filtered.map((event) => {
const date = event.start_date
? new Date(event.start_date + "T00:00:00")
: null;
const monthLabel = date ? months[date.getMonth()] : "";
const dayLabel = date
? String(date.getDate()).padStart(2, "0")
: "";
const TabloIcon = getTabloIcon(event.tablo_color);
const iconColor = getTabloIconColor(event.tablo_color);
const timeLabel = event.start_time
? `${event.start_time.slice(0, 5)}${event.end_time ? ` ${event.end_time.slice(0, 5)}` : ""}`
: null;
return (
<div
key={event.event_id}
className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-all hover:border-gray-300 dark:hover:border-gray-600 relative group"
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="absolute top-6 right-6 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
aria-label="Options de l'événement"
>
<EllipsisVerticalIcon className="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={() => navigateToEditEvent(event)}
disabled={!canEditEvent(event) || isReadOnly}
>
Replanifier
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => deleteEvent.mutate(event.event_id)}
disabled={
!canDeleteEvent(event) ||
isReadOnly ||
deleteEvent.isPending
}
className="text-destructive focus:text-destructive"
>
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex items-start gap-4 mb-4">
{/* Date badge */}
<div className="flex-shrink-0">
<div className="w-14 h-14 rounded-[8px] bg-[#F4F3FF] dark:bg-purple-900/20 flex flex-col items-center justify-center">
<span className="text-[10px] font-semibold text-[#7F56D9] dark:text-purple-400 uppercase tracking-wide leading-none">
{monthLabel}
</span>
<span className="text-xl font-bold text-[#7F56D9] dark:text-purple-400 leading-none mt-0.5">
{dayLabel}
</span>
</div>
</div>
{/* Title + description */}
<div className="flex-1 min-w-0 pt-1 pr-8">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1 leading-tight">
{event.title}
</h3>
{event.description && (
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
{event.description}
</p>
)}
</div>
</div>
{/* Meta */}
<div className="space-y-2.5 mt-3">
{timeLabel && (
<div className="flex items-center gap-2.5 text-[#344054] dark:text-gray-300">
<ClockIcon className="w-4 h-4 flex-shrink-0" />
<span className="text-sm">{timeLabel}</span>
</div>
)}
{event.tablo_name && (
<div className="flex items-center gap-2.5 text-[#344054] dark:text-gray-300">
<div
className={`w-5 h-5 rounded-[5px] flex-shrink-0 flex items-center justify-center ${event.tablo_color || "bg-gray-400"}`}
>
<TabloIcon className={`w-3 h-3 ${iconColor}`} />
</div>
<span className="text-sm truncate">
{event.tablo_name}
</span>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
};
if (currentTab === "events") {
return (
<div className="min-h-screen bg-background px-4">
{renderEventsView()}
<EventModal
mode="create"
isOpen={isCreateEventOpen}
onClose={() => setIsCreateEventOpen(false)}
defaultTabloId={
selectedTabloId !== "all" ? selectedTabloId : undefined
}
defaultDate={currentDate}
/>
<Outlet />
</div>
);
}
return (
<div className="min-h-screen bg-background">
<div className="flex">
@ -802,10 +1159,15 @@ export const PlanningPage = () => {
onValueChange={(value) => setSelectedTabloId(value)}
disabled={tablosLoading}
>
<SelectTrigger className="w-full" aria-label={t("planning:selectTablo")}>
<SelectTrigger
className="w-full"
aria-label={t("planning:selectTablo")}
>
<SelectValue
placeholder={
tablosLoading ? t("common:actions.loading") : t("planning:selectTablo")
tablosLoading
? t("common:actions.loading")
: t("planning:selectTablo")
}
/>
</SelectTrigger>
@ -830,19 +1192,21 @@ export const PlanningPage = () => {
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer d'événement.",
type: "error",
},
{ timeout: 5000 }
{ timeout: 5000 },
);
return;
}
if (selectedTabloId === "all") {
navigate(`/planning/create?date=${currentDate.toISOString()}`);
navigate(
`/planning/create?date=${currentDate.toISOString()}`,
);
} else {
navigate(
`/planning/create?tablo_id=${selectedTabloId}&date=${currentDate.toISOString()}`
`/planning/create?tablo_id=${selectedTabloId}&date=${currentDate.toISOString()}`,
);
}
}}
className="w-full"
className="w-full bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
disabled={isReadOnly}
>
<PlusIcon className="w-5 h-5 mr-2" />
@ -859,7 +1223,7 @@ export const PlanningPage = () => {
"Vous êtes en mode lecture seule. Vous ne pouvez pas importer de calendrier.",
type: "error",
},
{ timeout: 5000 }
{ timeout: 5000 },
);
return;
}
@ -892,7 +1256,10 @@ export const PlanningPage = () => {
</div>
<div className="grid grid-cols-7 gap-1 text-xs">
{dayNamesShort.map((day) => (
<div key={day} className="text-center text-muted-foreground p-1">
<div
key={day}
className="text-center text-muted-foreground p-1"
>
{day.slice(0, 1)}
</div>
))}
@ -903,7 +1270,7 @@ export const PlanningPage = () => {
day ? "hover:bg-muted" : ""
} ${
day && formatDate(day) === formatDate(new Date())
? "bg-primary text-primary-foreground"
? "bg-[#804EEC] text-white"
: day
? "text-foreground"
: ""
@ -928,12 +1295,25 @@ export const PlanningPage = () => {
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<TypographyH3>{t("planning:title")}</TypographyH3>
<Button onClick={goToToday} variant="outline" size="sm">
<Button
onClick={goToToday}
variant="outline"
size="sm"
className="border-[#804EEC] text-[#804EEC] hover:bg-[#804EEC]/10"
>
{t("planning:today")}
</Button>
<div className="flex items-center space-x-2">
<button onClick={() => navigateDate(-1)} className="p-2 hover:bg-muted rounded">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button
onClick={() => navigateDate(-1)}
className="p-2 hover:bg-muted rounded"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
@ -942,8 +1322,16 @@ export const PlanningPage = () => {
/>
</svg>
</button>
<button onClick={() => navigateDate(1)} className="p-2 hover:bg-muted rounded">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button
onClick={() => navigateDate(1)}
className="p-2 hover:bg-muted rounded"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
@ -974,7 +1362,7 @@ export const PlanningPage = () => {
title={t(`planning:views.${view}Title`)}
className={`px-3 py-1.5 text-sm rounded-md transition-colors capitalize ${
currentView === view
? "bg-background text-foreground shadow-sm"
? "bg-white dark:bg-gray-800 text-[#804EEC] shadow-sm font-semibold"
: "text-muted-foreground hover:text-foreground"
}`}
>
@ -998,7 +1386,9 @@ export const PlanningPage = () => {
alt="Loading..."
className="animate-spin rounded-full h-8 w-8 object-cover"
/>
<span className="ml-2 text-muted-foreground">{t("planning:loadingEvents")}</span>
<span className="ml-2 text-muted-foreground">
{t("planning:loadingEvents")}
</span>
</div>
) : (
<>
@ -1013,9 +1403,14 @@ export const PlanningPage = () => {
<Outlet />
{isImportModalOpen && <ImportICSModal onClose={() => setIsImportModalOpen(false)} />}
{isImportModalOpen && (
<ImportICSModal onClose={() => setIsImportModalOpen(false)} />
)}
<WebcalModal open={isWebcalModalOpen} onOpenChange={setIsWebcalModalOpen} />
<WebcalModal
open={isWebcalModalOpen}
onOpenChange={setIsWebcalModalOpen}
/>
</div>
);
};

View file

@ -0,0 +1,384 @@
import { AnimatedBackground } from "@ui/components/AnimatedBackground";
import { LoginWithGoogle } from "@ui/components/BrandButtons/LoginWithGoogle";
import { useTheme } from "@xtablo/shared/contexts/ThemeContext";
import { Button } from "@xtablo/ui/components/button";
import { FieldError } from "@xtablo/ui/components/field";
import { Input } from "@xtablo/ui/components/input";
import { Label } from "@xtablo/ui/components/label";
import {
ArrowLeftIcon,
MonitorIcon,
MoonIcon,
SparklesIcon,
SunIcon,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useSignUp } from "../hooks/auth";
export function SignUpV2Page() {
const { t } = useTranslation(["auth", "common"]);
const redirectUrl = localStorage.getItem("redirectUrl");
const { mutate: signUp, isPending } = useSignUp({
redirectUrl: redirectUrl ?? null,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [formData, setFormData] = useState({
email: "",
password: "",
confirmPassword: "",
username: "",
first_name: "",
last_name: "",
business_name: "",
});
const [termsAccepted, setTermsAccepted] = useState(false);
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
if (theme === "light") {
setTheme("dark");
} else if (theme === "dark") {
setTheme("system");
} else {
setTheme("light");
}
};
const getThemeIcon = () => {
switch (theme) {
case "light":
return <SunIcon className="w-5 h-5" />;
case "dark":
return <MoonIcon className="w-5 h-5" />;
case "system":
return <MonitorIcon className="w-5 h-5" />;
default:
return <SunIcon className="w-5 h-5" />;
}
};
const validateForm = () => {
const nextErrors: Record<string, string> = {};
if (formData.password.length < 8) {
nextErrors.password = t("auth:signup.errors.passwordLength");
}
if (formData.password !== formData.confirmPassword) {
nextErrors.confirmPassword = t("auth:signup.errors.passwordMatch");
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
nextErrors.email = t("auth:signup.errors.invalidEmail");
}
if (!termsAccepted) {
nextErrors.terms = t("auth:signup.errors.termsRequired");
}
return Object.keys(nextErrors).length === 0 ? null : nextErrors;
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
const validationErrors = validateForm();
if (validationErrors) {
setErrors(validationErrors);
return;
}
setErrors({});
signUp({
email: formData.email,
password: formData.password,
first_name: formData.first_name,
last_name: formData.last_name,
confirm_password: formData.confirmPassword,
business_name: formData.business_name,
});
};
return (
<div className="relative min-h-screen overflow-hidden bg-background text-foreground">
<AnimatedBackground />
<div className="relative z-10 min-h-screen md:grid md:grid-cols-[minmax(0,640px)_1fr]">
<main className="flex min-h-screen items-center px-6 py-10 md:px-10 lg:px-14">
<div className="w-full max-w-lg mx-auto">
<div className="mb-6 flex items-center justify-between">
<a
href="https://www.xtablo.com"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeftIcon className="w-4 h-4" />
{t("auth:common.backHome")}
</a>
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="text-muted-foreground hover:text-foreground"
aria-label={t("auth:common.themeToggle", { theme })}
>
{getThemeIcon()}
</Button>
</div>
<div className="mb-5">
<img
src="/logo_dark.png"
alt="Xtablo"
className="h-10 w-auto block dark:hidden"
/>
<img
src="/logo_white.png"
alt="Xtablo"
className="h-10 w-auto hidden dark:block"
/>
</div>
<h1 className="text-3xl font-bold tracking-tight mb-2">
{t("auth:signup.title")}
</h1>
<p className="text-sm text-muted-foreground mb-7">
{t("auth:signup.alreadyAccount")}{" "}
<Link
to="/login-v2"
className="text-[#804EEC] hover:text-[#6f3fd4] font-semibold"
>
{t("auth:signup.loginLink")}
</Link>
</p>
<form className="space-y-4" onSubmit={onSubmit}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">
{t("auth:signup.firstName")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="first_name"
name="first_name"
type="text"
value={formData.first_name}
onChange={(e) =>
setFormData({ ...formData, first_name: e.target.value })
}
required
placeholder={t("auth:signup.firstNamePlaceholder")}
className="h-11"
/>
{errors?.first_name && (
<FieldError errors={[{ message: errors.first_name }]} />
)}
</div>
<div className="space-y-2">
<Label htmlFor="last_name">
{t("auth:signup.lastName")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="last_name"
name="last_name"
type="text"
value={formData.last_name}
onChange={(e) =>
setFormData({ ...formData, last_name: e.target.value })
}
required
placeholder={t("auth:signup.lastNamePlaceholder")}
className="h-11"
/>
{errors?.last_name && (
<FieldError errors={[{ message: errors.last_name }]} />
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email">
{t("auth:signup.email")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
placeholder={t("auth:signup.emailPlaceholder")}
className="h-11"
/>
{errors?.email && (
<FieldError errors={[{ message: errors.email }]} />
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">
{t("common:labels.password")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="password"
name="password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required
placeholder={t("auth:signup.passwordPlaceholder")}
className="h-11"
/>
{errors?.password && (
<FieldError errors={[{ message: errors.password }]} />
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">
{t("auth:signup.confirmPassword")}{" "}
<span className="text-red-500">*</span>
</Label>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={(e) =>
setFormData({
...formData,
confirmPassword: e.target.value,
})
}
required
placeholder={t("auth:signup.confirmPasswordPlaceholder")}
className="h-11"
/>
{errors?.confirmPassword && (
<FieldError errors={[{ message: errors.confirmPassword }]} />
)}
</div>
<div className="space-y-2">
<div className="flex items-start gap-2">
<input
type="checkbox"
id="terms"
name="terms"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="mt-1 h-4 w-4 rounded border border-border bg-background"
required
/>
<Label
htmlFor="terms"
className="text-xs text-muted-foreground leading-relaxed"
>
{t("auth:signup.termsAccept")}{" "}
<Link
to="/legal-notice"
target="_blank"
className="text-foreground hover:text-foreground/80 underline"
>
{t("auth:signup.termsLink")}
</Link>{" "}
{t("auth:signup.termsAnd")}{" "}
<Link
to="/privacy-policy"
target="_blank"
className="text-foreground hover:text-foreground/80 underline"
>
{t("auth:signup.privacyLink")}
</Link>
</Label>
</div>
{errors?.terms && (
<FieldError errors={[{ message: errors.terms }]} />
)}
</div>
<Button
className="w-full h-11 bg-[#804EEC] hover:bg-[#6f3fd4] text-white"
type="submit"
>
{isPending
? t("auth:common.creatingAccount")
: t("auth:signup.signupButton")}
</Button>
</form>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center">
<span className="px-3 bg-background text-xs text-muted-foreground">
{t("auth:common.orContinue")}
</span>
</div>
</div>
<LoginWithGoogle />
</div>
</main>
<aside className="hidden md:flex min-h-screen items-center justify-center px-8 lg:px-14 border-l border-border bg-gradient-to-br from-[#F5F0FF] via-[#ECE4FF] to-[#DCCEFF] dark:from-[#201933] dark:via-[#271F3E] dark:to-[#2F2548]">
<div className="relative w-full max-w-xl rounded-3xl border border-white/50 dark:border-white/10 bg-white/80 dark:bg-[#171224]/85 backdrop-blur-sm shadow-2xl p-10">
<SparklesIcon className="absolute top-5 left-5 w-5 h-5 text-[#804EEC] opacity-70" />
<SparklesIcon className="absolute top-5 right-5 w-5 h-5 text-[#804EEC] opacity-70" />
<SparklesIcon className="absolute bottom-5 left-5 w-5 h-5 text-[#804EEC] opacity-70" />
<SparklesIcon className="absolute bottom-5 right-5 w-5 h-5 text-[#804EEC] opacity-70" />
<div className="flex justify-center mb-6">
<div className="w-20 h-20 rounded-full border border-border bg-card flex items-center justify-center shadow-sm">
<img
src="/logo_dark.png"
alt="Xtablo"
className="w-12 h-12 object-contain block dark:hidden"
/>
<img
src="/logo_white.png"
alt="Xtablo"
className="w-12 h-12 object-contain hidden dark:block"
/>
</div>
</div>
<h2 className="text-2xl font-bold text-center mb-3">
{t("auth:signup.asideTitle")}
</h2>
<p className="text-center text-muted-foreground mb-8">
{t("auth:signup.asideDescription")}
</p>
<div className="space-y-3">
<div className="rounded-xl border border-border/70 bg-background/80 px-4 py-3 text-sm">
{t("auth:signup.feature1")}
</div>
<div className="rounded-xl border border-border/70 bg-background/80 px-4 py-3 text-sm">
{t("auth:signup.feature2")}
</div>
<div className="rounded-xl border border-border/70 bg-background/80 px-4 py-3 text-sm">
{t("auth:signup.feature3")}
</div>
</div>
</div>
</aside>
</div>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -11,38 +11,73 @@ import {
EmptyHeader,
EmptyTitle,
} from "@xtablo/ui/components/empty";
// shadcn components
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@xtablo/ui/components/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@xtablo/ui/components/tooltip";
import { Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@xtablo/ui/components/tooltip";
import { Text } from "@xtablo/ui/components/typography";
import {
CheckCircle2,
Clock,
LayoutGrid,
List,
Compass,
Flame,
FolderIcon,
Gem,
Heart,
Leaf,
ListTodo,
MessageSquare,
Plus,
Shield,
Sparkles,
Star,
Sun,
Trash2,
Users,
Waves,
Zap,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
import { useIsReadOnlyUser } from "../providers/UserStoreProvider";
import {
useCanCreateTablo,
useCreateTablo,
useDeleteTablo,
useTablosList,
} from "../hooks/tablos";
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
import { DashboardActionCards } from "src/components/DashboardActionCards";
import { DashboardTaskList } from "src/components/DashboardTaskList";
import { TaskModal } from "src/components/kanban/TaskModal";
import { ProjectCardList } from "src/components/ProjectCardList";
type FilterOption = {
id: "all" | "todo" | "inProgress" | "done";
name: string;
};
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 {
switch (color) {
case "bg-yellow-500":
case "bg-cyan-500":
return "text-gray-700";
default:
return "text-white";
}
}
export const TabloPage = () => {
const { t } = useTranslation(["pages", "common"]);
@ -55,48 +90,53 @@ export const TabloPage = () => {
y: number;
} | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [deletingTablo, setDeletingTablo] = useState<UserTablo | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [filterType, setFilterType] = useState<"all" | "todo" | "inProgress" | "done">("all");
const [filterType] = useState<"all" | "todo" | "inProgress" | "done">("all");
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const isReadOnlyUser = useIsReadOnlyUser();
const canCreateTablo = useCanCreateTablo();
const user = useUser();
const isReadOnly = isReadOnlyUser || !canCreateTablo;
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return t("pages:tablo.greeting.morning", "Good Morning");
if (hour < 18) return t("pages:tablo.greeting.afternoon", "Good Afternoon");
return t("pages:tablo.greeting.evening", "Good Evening");
};
const formattedDate = new Intl.DateTimeFormat(undefined, {
weekday: "long",
month: "long",
day: "numeric",
}).format(new Date());
// Get view mode from URL params, default to "list"
const viewMode = (searchParams.get("view") as "grid" | "list") || "list";
const searchQuery = searchParams.get("q")?.toLowerCase() ?? "";
const filterOptions: FilterOption[] = [
{ id: "all", name: t("pages:tablo.filter.all") },
{ id: "todo", name: t("pages:tablo.filter.todo") },
{ id: "inProgress", name: t("pages:tablo.filter.inProgress") },
{ id: "done", name: t("pages:tablo.filter.done") },
];
// Function to update view mode in URL
const setViewMode = (mode: "grid" | "list") => {
const newParams = new URLSearchParams(searchParams);
newParams.set("view", mode);
setSearchParams(newParams);
};
const { data: tablos, isLoading, error } = useTablosList();
const createTabloMutation = useCreateTablo();
// const { mutateAsync: updateTablo } = useUpdateTablo();
const { mutateAsync: deleteTablo } = useDeleteTablo();
// Filter tablos based on status
// Filter tablos based on status and search query
const filteredTablos = tablos?.filter((tablo) => {
if (filterType === "todo") {
return tablo.status === "todo";
} else if (filterType === "inProgress") {
return tablo.status === "inProgress";
} else if (filterType === "done") {
return tablo.status === "done";
}
return true; // 'all' case
const matchesStatus =
filterType === "all" ||
(filterType === "todo" && tablo.status === "todo") ||
(filterType === "inProgress" && tablo.status === "inProgress") ||
(filterType === "done" && tablo.status === "done");
const matchesSearch =
!searchQuery || tablo.name.toLowerCase().includes(searchQuery);
return matchesStatus && matchesSearch;
});
const menuItems = [
@ -106,7 +146,8 @@ export const TabloPage = () => {
},
{
name: "Membres",
action: (tabloId: string) => navigate(`/tablos/${tabloId}?section=members`),
action: (tabloId: string) =>
navigate(`/tablos/${tabloId}?section=members`),
},
];
@ -115,10 +156,11 @@ export const TabloPage = () => {
toast.add(
{
title: t("common:error"),
description: "Vous êtes en mode lecture seule. Vous ne pouvez pas créer de tablo.",
description:
"Vous êtes en mode lecture seule. Vous ne pouvez pas créer de projet.",
type: "error",
},
{ timeout: 5000 }
{ timeout: 5000 },
);
return;
}
@ -130,7 +172,7 @@ export const TabloPage = () => {
};
const createNewTablo = async (
tabloData: Pick<TabloInsert, "name" | "color" | "image" | "status">
tabloData: Pick<TabloInsert, "name" | "color" | "image" | "status">,
) => {
try {
await createTabloMutation.mutateAsync(tabloData);
@ -199,7 +241,9 @@ export const TabloPage = () => {
};
const getUserRole = (tablo: UserTablo) => {
return tablo.is_admin ? t("pages:tablo.role.admin") : t("pages:tablo.role.guest");
return tablo.is_admin
? t("pages:tablo.role.admin")
: t("pages:tablo.role.guest");
};
const getRoleColor = (tablo: UserTablo) => {
@ -212,11 +256,14 @@ export const TabloPage = () => {
const totalTablos = tablos.length;
const todoCount = tablos.filter((t) => t.status === "todo").length;
const inProgressCount = tablos.filter((t) => t.status === "inProgress").length;
const inProgressCount = tablos.filter(
(t) => t.status === "inProgress",
).length;
const doneCount = tablos.filter((t) => t.status === "done").length;
const adminCount = tablos.filter((t) => t.is_admin).length;
const guestCount = tablos.filter((t) => !t.is_admin).length;
const completionRate = totalTablos > 0 ? Math.round((doneCount / totalTablos) * 100) : 0;
const completionRate =
totalTablos > 0 ? Math.round((doneCount / totalTablos) * 100) : 0;
return {
totalTablos,
@ -235,9 +282,15 @@ export const TabloPage = () => {
const isCreateDisabled = createTabloMutation.isPending || isReadOnly;
const button = (
<Button id="create-tablo-button" onClick={openCreateModal} disabled={isCreateDisabled}>
<Button
id="create-tablo-button"
onClick={openCreateModal}
disabled={isCreateDisabled}
>
<Plus />
{createTabloMutation.isPending ? t("common:actions.saving") : t("pages:tablo.createButton")}
{createTabloMutation.isPending
? t("common:actions.saving")
: t("pages:tablo.createButton")}
</Button>
);
@ -254,9 +307,15 @@ export const TabloPage = () => {
</TooltipTrigger>
<TooltipContent>
{isReadOnlyUser ? (
<p>Vous ne pouvez pas créer de tablo car vous êtes en mode lecture seule.</p>
<p>
Vous ne pouvez pas créer de tablo car vous êtes en mode lecture
seule.
</p>
) : (
<p>Vous ne pouvez pas créer de tablo car vous avez atteint votre limite de tablos.</p>
<p>
Vous ne pouvez pas créer de tablo car vous avez atteint votre
limite de tablos.
</p>
)}
</TooltipContent>
</Tooltip>
@ -271,8 +330,12 @@ export const TabloPage = () => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
<h1 className="text-3xl font-bold text-foreground">
{t("pages:tablo.title")}
</h1>
<Text className="text-muted-foreground mt-1">
{t("pages:tablo.subtitle")}
</Text>
</div>
{createTabloButton()}
</div>
@ -295,11 +358,15 @@ export const TabloPage = () => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-start">
<div>
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
<h1 className="text-3xl font-bold text-foreground">
{t("pages:tablo.title")}
</h1>
<Text className="text-muted-foreground mt-1">
{t("pages:tablo.subtitle")}
</Text>
</div>
<Button onClick={openCreateModal} disabled={isReadOnly}>
<Plus /> Nouveau tablo
<Plus /> Nouveau projet
</Button>
</div>
</div>
@ -307,9 +374,13 @@ export const TabloPage = () => {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-center items-center min-h-64">
<div className="text-center">
<p className="text-destructive mb-2">Erreur lors du chargement des tablos</p>
<p className="text-destructive mb-2">
Erreur lors du chargement des projets
</p>
<p className="text-muted-foreground text-sm">
{error instanceof Error ? error.message : "Une erreur inconnue s'est produite"}
{error instanceof Error
? error.message
: "Une erreur inconnue s'est produite"}
</p>
</div>
</div>
@ -320,6 +391,8 @@ export const TabloPage = () => {
const renderTablo = (tablo: UserTablo) => {
const isAdmin = tablo.is_admin;
const TabloIcon = getTabloIcon(tablo.color);
const iconColor = getTabloIconColor(tablo.color);
return (
<div
@ -335,7 +408,9 @@ export const TabloPage = () => {
>
<div
className={`bg-card rounded-lg shadow-lg transition-all duration-300 w-56 overflow-hidden border border-border ${
isAdmin ? "hover:shadow-xl cursor-pointer" : "hover:shadow-xl cursor-pointer opacity-75"
isAdmin
? "hover:shadow-xl cursor-pointer"
: "hover:shadow-xl cursor-pointer opacity-75"
}`}
onClick={(e) => {
e.stopPropagation();
@ -345,14 +420,18 @@ export const TabloPage = () => {
{/* Image or Color */}
<div className="relative h-40 group">
{tablo.image ? (
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
<img
src={tablo.image}
alt={tablo.name}
className="w-full h-full object-cover"
/>
) : (
<div
className={`w-full h-full ${
tablo.color || "bg-gray-400"
} flex items-center justify-center`}
>
<h3 className="text-white font-bold text-xl text-center px-4">{tablo.name}</h3>
<TabloIcon className={`w-12 h-12 ${iconColor}`} />
</div>
)}
@ -368,17 +447,21 @@ export const TabloPage = () => {
<div className="p-3">
<div className="space-y-2">
<div className="flex items-center gap-1">
<h3 className="text-foreground font-semibold text-base truncate">{tablo.name}</h3>
<h3 className="text-foreground font-semibold text-base truncate">
{tablo.name}
</h3>
{/* Status badge */}
<div
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeColor(
tablo.status
tablo.status,
)} shrink-0`}
>
<span>{getStatusLabel(tablo.status)}</span>
</div>
</div>
<div className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(tablo)}`}>
<div
className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(tablo)}`}
>
<Shield className="w-3 h-3" />
<span>{getUserRole(tablo)}</span>
</div>
@ -454,140 +537,6 @@ export const TabloPage = () => {
);
};
const renderTabloListView = (tablo: UserTablo) => {
const isAdmin = tablo.is_admin;
return (
<div
key={tablo.id}
className="relative"
data-tablo-id={tablo.id}
onContextMenu={(e) => {
e.preventDefault();
setContextMenuTablo(contextMenuTablo === tablo.id ? null : tablo.id);
setContextMenuPosition({ x: e.clientX, y: e.clientY });
}}
>
<div
className={`bg-card rounded-lg shadow-md transition-all duration-300 overflow-hidden border border-border ${
isAdmin ? "hover:shadow-lg cursor-pointer" : "hover:shadow-lg cursor-pointer opacity-75"
}`}
onClick={(e) => {
e.stopPropagation();
openTablo(tablo.id);
}}
>
<div className="flex items-center p-4 gap-4">
{/* Image or Color - smaller in list view */}
<div className="relative h-16 w-16 shrink-0 rounded-lg overflow-hidden group">
{tablo.image ? (
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
) : (
<div
className={`w-full h-full ${
tablo.color || "bg-gray-400"
} flex items-center justify-center`}
>
<span className="text-white font-bold text-sm">
{tablo.name.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<h3 className="text-foreground font-semibold text-base truncate">{tablo.name}</h3>
<div
className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusBadgeColor(
tablo.status
)} shrink-0`}
>
<span>{getStatusLabel(tablo.status)}</span>
</div>
</div>
<div
className={`flex items-center gap-1 text-xs font-medium ${getRoleColor(
tablo
)} mt-1`}
>
<Shield className="w-3 h-3" />
<span>{getUserRole(tablo)}</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 shrink-0">
{/* Quick action buttons */}
<Button
variant="outline"
size="icon"
className="p-2"
onClick={(e) => {
e.stopPropagation();
navigate(`/chat/${tablo.id}`);
}}
title="Discussions"
>
<MessageSquare className="w-5 h-5 color-foreground" />
</Button>
<Button
variant="outline"
size="icon"
className="p-2"
onClick={(e) => {
e.stopPropagation();
navigate(`/tablos/${tablo.id}?section=members`);
}}
title="Members"
>
<Users className="w-5 h-5" />
</Button>
<Button
variant="outline"
size="icon"
className="p-2 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteTablo(tablo.id);
}}
title={t("pages:tablo.contextMenu.delete")}
>
<Trash2 className="w-5 h-5" />
</Button>
</div>
</div>
</div>
{/* Contextual Menu - same as grid view */}
{contextMenuTablo === tablo.id && contextMenuPosition && (
<div
className="fixed bg-card rounded-lg shadow-lg border border-border py-2 z-30 min-w-36"
style={{
left: contextMenuPosition.x,
top: contextMenuPosition.y,
}}
onClick={(e) => e.stopPropagation()}
>
{menuItems.map((item, index) => (
<button
key={index}
className="w-full px-4 py-2 text-left text-sm text-foreground hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
item.action(tablo.id);
}}
>
<span>{item.name}</span>
</button>
))}
</div>
)}
</div>
);
};
return (
<div
className="min-h-screen"
@ -596,64 +545,18 @@ export const TabloPage = () => {
setContextMenuPosition(null);
}}
>
<header className="bg-card shadow-sm border-b border-border">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex justify-between items-start">
<div>
<TypographyH3>{t("pages:tablo.title")}</TypographyH3>
<TypographyMuted>{t("pages:tablo.subtitle")}</TypographyMuted>
</div>
<div className="flex items-center gap-3">
{/* Filter Controls */}
<div className="flex items-center gap-2">
<Select
value={filterType}
onValueChange={(value) =>
setFilterType(value as "all" | "todo" | "inProgress" | "done")
}
>
<SelectTrigger className="min-w-36 h-8">
<SelectValue placeholder="Filtrer" />
</SelectTrigger>
<SelectContent>
{filterOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{t(`pages:tablo.filter.${option.id}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* View Mode Toggle */}
<div className="flex items-center gap-1 bg-muted rounded-lg p-1 border border-border">
<button
onClick={() => setViewMode("grid")}
className={`p-1.5 rounded transition-colors ${
viewMode === "grid"
? "bg-background text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
title={t("pages:tablo.view.grid")}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode("list")}
className={`p-1.5 rounded transition-colors ${
viewMode === "list"
? "bg-background text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
title={t("pages:tablo.view.list")}
>
<List className="w-4 h-4" />
</button>
</div>
{createTabloButton()}
</div>
</div>
<header className="px-6 pt-6 pb-4">
<p className="text-base text-[#475467] dark:text-gray-400 mb-2 font-medium">
{formattedDate}
</p>
<div className="flex items-center justify-between flex-wrap gap-4">
<h1 className="text-[24px] font-medium text-[#475467] dark:text-gray-400">
{getGreeting()},{" "}
<span className="text-gray-900 dark:text-gray-100 font-medium">
{user.first_name ?? user.name}
</span>
!
</h1>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
@ -665,8 +568,12 @@ export const TabloPage = () => {
<div className="bg-card rounded-lg shadow-md p-4 border border-border">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">Total</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.totalTablos}</p>
<p className="text-sm font-medium text-muted-foreground">
Total
</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.totalTablos}
</p>
</div>
<div className="p-2 bg-primary/10 rounded-lg">
<Users className="w-5 h-5 text-primary" />
@ -681,7 +588,9 @@ export const TabloPage = () => {
<p className="text-sm font-medium text-muted-foreground">
{t("pages:tablo.kpis.todo")}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.todoCount}</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.todoCount}
</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<ListTodo className="w-5 h-5 text-muted-foreground" />
@ -713,7 +622,9 @@ export const TabloPage = () => {
<p className="text-sm font-medium text-muted-foreground">
{t("pages:tablo.kpis.done")}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.doneCount}</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.doneCount}
</p>
</div>
<div className="p-2 bg-secondary/50 rounded-lg">
<CheckCircle2 className="w-5 h-5 text-secondary-foreground" />
@ -745,7 +656,9 @@ export const TabloPage = () => {
<p className="text-sm font-medium text-muted-foreground">
{t("pages:tablo.kpis.admin")}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.adminCount}</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.adminCount}
</p>
</div>
<div className="p-2 bg-primary/10 rounded-lg">
<Shield className="w-5 h-5 text-primary" />
@ -760,7 +673,9 @@ export const TabloPage = () => {
<p className="text-sm font-medium text-muted-foreground">
{t("pages:tablo.kpis.guest")}
</p>
<p className="text-2xl font-bold text-foreground mt-1">{kpis.guestCount}</p>
<p className="text-2xl font-bold text-foreground mt-1">
{kpis.guestCount}
</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<Users className="w-5 h-5 text-muted-foreground" />
@ -771,25 +686,32 @@ export const TabloPage = () => {
</div>
)}
<DashboardActionCards
onCreateProject={openCreateModal}
onCreateTask={() => setIsTaskModalOpen(true)}
onSendMessage={() => navigate("/chat")}
/>
<div className="container mx-auto px-4 py-8">
{filteredTablos && filteredTablos.length > 0 ? (
viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{/* Render tablos in grid view */}
{filteredTablos.map((tablo) => renderTablo(tablo))}
</div>
) : (
<div className="flex flex-col gap-3">
{/* Render tablos in list view */}
{filteredTablos.map((tablo) => renderTabloListView(tablo))}
</div>
<ProjectCardList
tablos={filteredTablos}
onTabloClick={openTablo}
onTabloMenuClick={handleDeleteTablo}
/>
)
) : (
<Empty>
<EmptyHeader>
<EmptyTitle>{t("pages:tablo.emptyState.title")}</EmptyTitle>
<EmptyDescription>
{filterType === "all" && t("pages:tablo.emptyState.description")}
{filterType === "all" &&
t("pages:tablo.emptyState.description")}
</EmptyDescription>
</EmptyHeader>
{filterType === "all" && (
@ -807,11 +729,16 @@ export const TabloPage = () => {
</Empty>
)}
</div>
<DashboardTaskList />
</main>
{/* Create Tablo Modal */}
{isCreateModalOpen && (
<CreateTabloModal onClose={closeCreateModal} onCreate={createNewTablo} />
<CreateTabloModal
onClose={closeCreateModal}
onCreate={createNewTablo}
/>
)}
{/* Delete Tablo Modal */}
@ -824,12 +751,14 @@ export const TabloPage = () => {
/>
)}
{/* Tutorial - Hidden */}
{/* <TabloTutorial
isOpen={isTutorialOpen}
onClose={handleCloseTutorial}
onCreateTablo={handleTutorialCreateTablo}
/> */}
{/* Create Task Modal */}
<TaskModal
isOpen={isTaskModalOpen}
onClose={() => setIsTaskModalOpen(false)}
tablos={tablos}
allowTabloSelection={true}
initialStatus="todo"
/>
</div>
);
};

View file

@ -0,0 +1,419 @@
import { cn } from "@xtablo/shared";
import type { UserTablo } from "@xtablo/shared/types/tablos.types";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import {
CalendarIcon,
Compass,
FilterIcon,
Flame,
FolderIcon,
Gem,
Grid3x3Icon,
Heart,
Leaf,
ListIcon,
PlusIcon,
SearchIcon,
Sparkles,
Star,
Sun,
Trash2Icon,
Waves,
Zap,
} from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { CreateTabloModal } from "../components/CreateTabloModal";
import { DeleteTabloModal } from "../components/DeleteTabloModal";
import { useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
// ─── Icon helpers ─────────────────────────────────────────────────────────────
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 {
switch (color) {
case "bg-yellow-500":
case "bg-cyan-500":
return "text-gray-700";
default:
return "text-white";
}
}
// ─── Status helpers ───────────────────────────────────────────────────────────
function getStatusConfig(status: string) {
switch (status) {
case "in_progress":
return {
label: "En cours",
badgeClass: "bg-[#FFF4E2] text-[#DB9729] border border-[#DB9729]",
progress: 50,
};
case "done":
return {
label: "Terminé",
badgeClass: "bg-green-50 text-green-600 border border-green-200 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800",
progress: 100,
};
default:
return {
label: "À faire",
badgeClass: "bg-blue-50 text-blue-600 border border-blue-200 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800",
progress: 0,
};
}
}
function formatDate(dateStr: string) {
return new Intl.DateTimeFormat("fr-FR", {
month: "short",
day: "2-digit",
year: "numeric",
}).format(new Date(dateStr));
}
// ─── Card view ────────────────────────────────────────────────────────────────
function TabloCard({
tablo,
onClick,
onDelete,
}: {
tablo: UserTablo;
onClick: (id: string) => void;
onDelete: (id: string) => void;
}) {
const { t } = useTranslation("pages");
const { label, badgeClass, progress } = getStatusConfig(tablo.status);
const TabloIcon = getTabloIcon(tablo.color);
const iconColor = getTabloIconColor(tablo.color);
return (
<div
className="bg-white dark:bg-gray-800 rounded-xl p-6 border border-[#EAECF0] dark:border-gray-700 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => onClick(tablo.id)}
>
{/* Status + delete */}
<div className="flex items-start justify-between mb-4">
<span className={cn("px-3 py-1 rounded-full text-sm font-medium", badgeClass)}>
{label}
</span>
<button
type="button"
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors"
onClick={(e) => { e.stopPropagation(); onDelete(tablo.id); }}
>
<Trash2Icon className="w-4 h-4" />
</button>
</div>
{/* Icon + name */}
<div className="flex items-center gap-3 mb-4">
<div
className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden",
!tablo.image && (tablo.color || "bg-gray-400")
)}
>
{tablo.image ? (
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
) : (
<TabloIcon className={cn("w-4 h-4", iconColor)} />
)}
</div>
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 flex-1 line-clamp-2">
{tablo.name}
</h3>
</div>
{/* Date */}
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400 mb-4">
<CalendarIcon className="w-4 h-4 shrink-0" />
<span className="text-sm">{formatDate(tablo.created_at)}</span>
</div>
{/* Progress */}
<div className="mb-4">
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-gray-600 dark:text-gray-400">{t("tablo.card.progress")} :</span>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100">{progress}%</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div className="bg-green-500 h-2 rounded-full transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
{/* Footer */}
<div className="pt-4 border-t border-dashed border-[#D0D5DD] dark:border-gray-600">
<span className="text-sm text-gray-500 dark:text-gray-400">
Créé le <span className="font-semibold text-gray-900 dark:text-gray-100">{formatDate(tablo.created_at)}</span>
</span>
</div>
</div>
);
}
// ─── List row ─────────────────────────────────────────────────────────────────
function TabloRow({
tablo,
onClick,
onDelete,
}: {
tablo: UserTablo;
onClick: (id: string) => void;
onDelete: (id: string) => void;
}) {
const { label, badgeClass, progress } = getStatusConfig(tablo.status);
const TabloIcon = getTabloIcon(tablo.color);
const iconColor = getTabloIconColor(tablo.color);
return (
<tr
className="border-t border-[#EAECF0] dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
onClick={() => onClick(tablo.id)}
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div
className={cn(
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 overflow-hidden",
!tablo.image && (tablo.color || "bg-gray-400")
)}
>
{tablo.image ? (
<img src={tablo.image} alt={tablo.name} className="w-full h-full object-cover" />
) : (
<TabloIcon className={cn("w-4 h-4", iconColor)} />
)}
</div>
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">{tablo.name}</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={cn("px-3 py-1 rounded-full text-sm font-medium", badgeClass)}>{label}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center gap-1.5">
<CalendarIcon className="w-4 h-4 shrink-0" />
{formatDate(tablo.created_at)}
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2 min-w-[80px]">
<div className="bg-green-500 h-2 rounded-full transition-all" style={{ width: `${progress}%` }} />
</div>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 w-8 text-right">{progress}%</span>
</div>
</td>
<td className="px-6 py-4 text-right">
<button
type="button"
className="text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400 transition-colors p-1 rounded"
onClick={(e) => { e.stopPropagation(); onDelete(tablo.id); }}
>
<Trash2Icon className="w-4 h-4" />
</button>
</td>
</tr>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export function TablosPage() {
const { t } = useTranslation("pages");
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const searchQuery = searchParams.get("q")?.toLowerCase() ?? "";
const [viewMode, setViewMode] = useState<"card" | "list">("card");
const [statusFilter, setStatusFilter] = useState("all");
const [showCreateModal, setShowCreateModal] = useState(false);
const [deleteTabloId, setDeleteTabloId] = useState<string | null>(null);
const { data: tablos = [], isLoading } = useTablosList();
const createTablo = useCreateTablo();
const { mutateAsync: deleteTablo, isPending: isDeleting } = useDeleteTablo();
const deleteTarget = tablos.find((t) => t.id === deleteTabloId) ?? null;
const filteredTablos = tablos.filter((tablo) => {
const matchesSearch = !searchQuery || tablo.name.toLowerCase().includes(searchQuery);
const matchesStatus = statusFilter === "all" || tablo.status === statusFilter;
return matchesSearch && matchesStatus;
});
const statusFilters = [
{ value: "all", label: t("tablo.filter.all") },
{ value: "todo", label: t("tablo.filter.todo") },
{ value: "in_progress", label: t("tablo.filter.inProgress") },
{ value: "done", label: t("tablo.filter.done") },
];
return (
<div className="px-4 pt-8 pb-6">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-8 gap-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{t("tablo.projectList.title")}
</h1>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center gap-2 transition-colors"
>
<PlusIcon className="w-5 h-5" />
{t("tablo.createButton")}
</button>
</div>
{/* View tabs */}
<div className="flex items-center gap-6 mb-6 border-b border-[#EAECF0] dark:border-gray-700">
{[
{ id: "card" as const, label: t("tablo.view.grid"), Icon: Grid3x3Icon },
{ id: "list" as const, label: t("tablo.view.list"), Icon: ListIcon },
].map(({ id, label, Icon }) => (
<button
key={id}
type="button"
onClick={() => setViewMode(id)}
className={cn(
"flex items-center gap-2 pb-3 border-b-2 transition-colors",
viewMode === id
? "border-purple-600 text-purple-600 dark:border-purple-400 dark:text-purple-400 font-semibold"
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
)}
>
<Icon className="w-5 h-5" />
<span className="font-medium">{label}</span>
</button>
))}
</div>
{/* Search + status filter */}
<div className="flex flex-col md:flex-row gap-4 mb-6">
<div className="relative md:w-[350px]">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5 pointer-events-none" />
<input
type="text"
readOnly
value={searchQuery}
placeholder="Rechercher..."
className="w-full pl-10 pr-4 py-3 border border-[#EAECF0] dark:border-gray-700 rounded-[8px] focus:outline-none focus:ring-2 focus:ring-purple-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400"
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
{statusFilters.map((f) => (
<button
key={f.value}
type="button"
onClick={() => setStatusFilter(f.value)}
className={cn(
"flex items-center gap-1.5 px-4 py-2.5 border rounded-[8px] font-medium text-sm transition-colors",
statusFilter === f.value
? "border-purple-600 bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400"
: "border-[#EAECF0] dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300"
)}
>
{f.value === "all" && <FilterIcon className="w-4 h-4" />}
{f.label}
</button>
))}
</div>
</div>
{/* Content */}
{isLoading ? (
<div className="flex items-center justify-center py-24">
<LoadingSpinner />
</div>
) : filteredTablos.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="w-16 h-16 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center mb-4">
<Grid3x3Icon className="w-8 h-8 text-gray-400" />
</div>
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">Aucun projet trouvé</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
{searchQuery ? "Essayez un autre terme de recherche" : "Créez votre premier projet"}
</p>
</div>
) : viewMode === "card" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{filteredTablos.map((tablo) => (
<TabloCard
key={tablo.id}
tablo={tablo}
onClick={(id) => navigate(`/tablos/${id}`)}
onDelete={setDeleteTabloId}
/>
))}
</div>
) : (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-[#EAECF0] dark:border-gray-700 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-800/80 border-b border-[#EAECF0] dark:border-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Projet</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Statut</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Créé le</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Progression</th>
<th className="px-6 py-3 w-12" />
</tr>
</thead>
<tbody>
{filteredTablos.map((tablo) => (
<TabloRow
key={tablo.id}
tablo={tablo}
onClick={(id) => navigate(`/tablos/${id}`)}
onDelete={setDeleteTabloId}
/>
))}
</tbody>
</table>
</div>
)}
{/* Create modal */}
{showCreateModal && (
<CreateTabloModal
onClose={() => setShowCreateModal(false)}
onCreate={(tabloData) => {
createTablo.mutate({ ...tabloData, status: "todo" });
setShowCreateModal(false);
}}
/>
)}
{/* Delete modal */}
<DeleteTabloModal
tablo={deleteTarget}
onClose={() => setDeleteTabloId(null)}
onConfirm={async (id) => {
await deleteTablo(id);
setDeleteTabloId(null);
}}
isDeleting={isDeleting}
/>
</div>
);
}

File diff suppressed because it is too large Load diff

View file

@ -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"

View file

@ -607,6 +607,7 @@ export type Database = {
assignee_id: string | null;
created_at: string;
description: string | null;
due_date: string | null;
id: string;
is_parent: boolean;
parent_task_id: string | null;
@ -620,6 +621,7 @@ export type Database = {
assignee_id?: string | null;
created_at?: string;
description?: string | null;
due_date?: string | null;
id?: string;
is_parent?: boolean;
parent_task_id?: string | null;
@ -633,6 +635,7 @@ export type Database = {
assignee_id?: string | null;
created_at?: string;
description?: string | null;
due_date?: string | null;
id?: string;
is_parent?: boolean;
parent_task_id?: string | null;
@ -725,6 +728,7 @@ export type Database = {
assignee_name: string | null;
created_at: string | null;
description: string | null;
due_date: string | null;
id: string | null;
is_parent: boolean | null;
parent_task_id: string | null;

View file

@ -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 {

View file

@ -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;
}

View file

@ -0,0 +1,32 @@
-- Add due_date column to tasks table for roadmap feature
ALTER TABLE "public"."tasks"
ADD COLUMN "due_date" date;
COMMENT ON COLUMN "public"."tasks"."due_date" IS 'Optional due date for tasks and Etapes, used in roadmap views';
-- Index for roadmap queries (filtering/sorting by due_date within a tablo)
CREATE INDEX "tasks_tablo_due_date_idx" ON "public"."tasks" USING btree ("tablo_id", "due_date");
-- Update tasks_with_assignee view to include due_date
CREATE OR REPLACE VIEW "public"."tasks_with_assignee" WITH ("security_invoker"='true') AS
SELECT
t.id,
t.tablo_id,
t.title,
t.description,
t.status,
t.assignee_id,
t.position,
t.created_at,
t.updated_at,
p.name AS assignee_name,
p.avatar_url AS assignee_avatar,
t.is_parent,
t.parent_task_id,
t.due_date
FROM "public"."tasks" t
LEFT JOIN "public"."profiles" p ON t.assignee_id = p.id;
ALTER TABLE "public"."tasks_with_assignee" OWNER TO "postgres";
COMMENT ON VIEW "public"."tasks_with_assignee" IS 'View that returns tasks with assignee information from profiles';