From 515fee98cd33782c03d97aa434a4299216696a29 Mon Sep 17 00:00:00 2001
From: Arthur Belleville
Date: Sat, 21 Feb 2026 18:05:32 +0100
Subject: [PATCH] Redesign tasks page and add files page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Rework tasks page with new board/list views, header with view tabs (Tableau, Liste, Roadmap, Calendrier), search and filter dropdown
- Board view: new card style with tablo row, assignee avatar, kebab menu (ouvrir/déplacer)
- List view: grouped by status, table with fixed column layout, kebab actions
- Add files page (/fichiers) with per-project file tables, folder cards, upload modal, download/delete actions
- Add /all-filenames API endpoint to fetch all tablo file names in a single call
- Add files nav item and recent projects section in NavigationBar
- Translate all UI strings to French
Co-Authored-By: Claude Sonnet 4.6 (1M context)
---
apps/api/src/routers/tablo_data.ts | 36 +
apps/main/src/components/NavigationBar.tsx | 65 +-
apps/main/src/hooks/tablo_data.ts | 19 +
apps/main/src/lib/routes.tsx | 5 +
apps/main/src/locales/en/navigation.json | 5 +-
apps/main/src/locales/fr/navigation.json | 5 +-
apps/main/src/pages/files.tsx | 493 ++++++++++++
apps/main/src/pages/tasks.tsx | 865 +++++++++++++--------
8 files changed, 1146 insertions(+), 347 deletions(-)
create mode 100644 apps/main/src/pages/files.tsx
diff --git a/apps/api/src/routers/tablo_data.ts b/apps/api/src/routers/tablo_data.ts
index 2bb8546..2e18619 100644
--- a/apps/api/src/routers/tablo_data.ts
+++ b/apps/api/src/routers/tablo_data.ts
@@ -36,6 +36,39 @@ 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");
// Get the file path - supports both wildcard (*) and named parameter (:fileName)
@@ -338,6 +371,9 @@ 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);
diff --git a/apps/main/src/components/NavigationBar.tsx b/apps/main/src/components/NavigationBar.tsx
index bf3bac3..b633b23 100644
--- a/apps/main/src/components/NavigationBar.tsx
+++ b/apps/main/src/components/NavigationBar.tsx
@@ -20,6 +20,7 @@ import {
ConstructionIcon,
CreditCard,
// FileTextIcon, // Notes feature temporarily hidden
+ FolderIcon,
Kanban,
LayersIcon,
ListTodo,
@@ -33,6 +34,7 @@ import {
Sparkles,
SquareKanban,
} from "lucide-react";
+import { useTablosList } from "../hooks/tablos";
import { useState } from "react";
import { Separator } from "react-aria-components";
import { useTranslation } from "react-i18next";
@@ -288,6 +290,52 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
);
};
+function RecentProjectsSection() {
+ const { t } = useTranslation("navigation");
+ const location = useLocation();
+ const { data: tablos } = useTablosList();
+ const recentTablos = (tablos ?? []).slice(0, 4);
+
+ if (recentTablos.length === 0) return null;
+
+ return (
+
+
+
+
+ {t("projects", "Projects")}
+
+
+
+ {recentTablos.map((tablo) => {
+ const isActive = location.pathname === `/tablos/${tablo.id}`;
+ return (
+ -
+
+
+ {tablo.name.charAt(0).toUpperCase()}
+
+ {tablo.name}
+
+
+ );
+ })}
+
+
+ );
+}
+
export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
const location = useLocation();
const isReadOnly = useIsReadOnlyUser();
@@ -370,6 +418,11 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
label: t("discussions"),
icon: ,
},
+ {
+ path: "/files",
+ label: t("files", "Fichiers"),
+ icon: ,
+ },
// Notes feature temporarily hidden
// {
// path: "/notes",
@@ -418,6 +471,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
) : null;
})}
+
+ {/* Recent projects section */}
+ {!isCollapsed && }
+
{/* Trial upsell message */}
{shouldShowTrialUpsell && !isCollapsed && (
@@ -501,7 +558,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
Plan Freemium
- Passer au plan Starter pour profiter de tablos illimités.
+ Passer au plan Starter pour profiter de projets illimités.
@@ -573,9 +630,9 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
-
-
-
+
+
+
diff --git a/apps/main/src/hooks/tablo_data.ts b/apps/main/src/hooks/tablo_data.ts
index 1112459..0aa8578 100644
--- a/apps/main/src/hooks/tablo_data.ts
+++ b/apps/main/src/hooks/tablo_data.ts
@@ -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({
+ 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();
diff --git a/apps/main/src/lib/routes.tsx b/apps/main/src/lib/routes.tsx
index 7fff7be..5bf3124 100644
--- a/apps/main/src/lib/routes.tsx
+++ b/apps/main/src/lib/routes.tsx
@@ -23,6 +23,7 @@ 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";
@@ -101,6 +102,10 @@ export const routes: RouteObject[] = [
path: "tasks",
element: ,
},
+ {
+ path: "files",
+ element: ,
+ },
{
path: "tablos",
element: ,
diff --git a/apps/main/src/locales/en/navigation.json b/apps/main/src/locales/en/navigation.json
index b4848dc..424b0eb 100644
--- a/apps/main/src/locales/en/navigation.json
+++ b/apps/main/src/locales/en/navigation.json
@@ -1,12 +1,13 @@
{
"home": "Home",
- "tablos": "Tablos",
- "projects": "Home",
+ "tablos": "Projects",
+ "projects": "Projects",
"myEvents": "My Events",
"planning": "Planning",
"tasks": "Tasks",
"discussions": "Discussions",
"notes": "Notes",
+ "files": "Files",
"feedback": "Feedback",
"settings": "Settings",
"availabilities": "Availabilities",
diff --git a/apps/main/src/locales/fr/navigation.json b/apps/main/src/locales/fr/navigation.json
index 159098d..43881cb 100644
--- a/apps/main/src/locales/fr/navigation.json
+++ b/apps/main/src/locales/fr/navigation.json
@@ -1,12 +1,13 @@
{
"home": "Aperçu",
- "tablos": "Tablos",
- "projects": "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",
diff --git a/apps/main/src/pages/files.tsx b/apps/main/src/pages/files.tsx
new file mode 100644
index 0000000..14289b6
--- /dev/null
+++ b/apps/main/src/pages/files.tsx
@@ -0,0 +1,493 @@
+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 } 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(tablos[0]?.id ?? "");
+ const [selectedFolderId, setSelectedFolderId] = useState("");
+ const [isUploading, setIsUploading] = useState(false);
+ const fileInputRef = useRef(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) => {
+ 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((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 (
+
+ );
+}
+
+// ─── Folder cards grid ───────────────────────────────────────────────────────
+
+function FolderGrid({
+ folders,
+ folderMap,
+}: {
+ folders: { id: string; name: string; description?: string }[];
+ folderMap: Map;
+}) {
+ const visibleFolders = folders.filter((f) => (folderMap.get(f.id) ?? []).length > 0);
+ if (visibleFolders.length === 0) return null;
+
+ return (
+
+ {visibleFolders.map((folder) => {
+ const count = folderMap.get(folder.id)?.length ?? 0;
+ return (
+
+
+
+
+
+
+
+ {folder.name}
+
+
+ {count} file{count !== 1 ? "s" : ""}
+
+
+
+
+ );
+ })}
+
+ );
+}
+
+// ─── 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();
+ 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 (
+
+ {/* Project header */}
+
+
+ {tabloName.charAt(0).toUpperCase()}
+
+
+ {tabloName}
+
+
+ {allFileNames.length} file{allFileNames.length !== 1 ? "s" : ""}
+
+
+
+ {/* Folder cards */}
+
+
+ {/* Files per folder */}
+ {folders.map((folder) => {
+ const folderFiles = folderMap.get(folder.id) ?? [];
+ if (folderFiles.length === 0) return null;
+ return (
+
+
+
+ {folder.name}
+ ({folderFiles.length})
+
+
downloadFile({ tabloId, fileName })}
+ onDelete={(fileName) => deleteFile({ tabloId, fileName })}
+ />
+
+ );
+ })}
+
+ {/* Root files */}
+ {rootFiles.length > 0 && (
+ <>
+ {folders.length > 0 && (
+
+ Other files
+ ({rootFiles.length})
+
+ )}
+
downloadFile({ tabloId, fileName })}
+ onDelete={(fileName) => deleteFile({ tabloId, fileName })}
+ />
+ >
+ )}
+
+ );
+}
+
+// ─── File table ───────────────────────────────────────────────────────────────
+
+function FileTable({
+ fileNames,
+ onDownload,
+ onDelete,
+}: {
+ fileNames: string[];
+ onDownload: (fileName: string) => void;
+ onDelete: (fileName: string) => void;
+}) {
+ return (
+
+
+
+
+
+ | File name |
+ |
+
+
+
+ {fileNames.map((fileName) => {
+ const displayName = getFileNameWithoutFolder(fileName);
+ const iconColor = getFileIconColor(displayName);
+ return (
+
+ |
+
+ |
+
+
+
+
+
+
+ onDownload(fileName)}>
+
+ Download
+
+ onDelete(fileName)}
+ className="text-red-600 dark:text-red-400 focus:text-red-600 dark:focus:text-red-400"
+ >
+
+ Delete
+
+
+
+ |
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+// ─── Page ─────────────────────────────────────────────────────────────────────
+
+export function FilesPage() {
+ const { t } = useTranslation("navigation");
+ 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(
+ (allFiles?.tablos ?? []).map(({ tabloId, fileNames }) => [tabloId, fileNames])
+ );
+
+ const tablosWithFiles = (tablos ?? []).filter((tablo) => {
+ const files = filesByTabloId.get(tablo.id) ?? [];
+ return files.some((f) => !f.startsWith("."));
+ });
+
+ return (
+
+ {/* Header */}
+
+
{t("files", "Files")}
+
+
+
+ {isLoading ? (
+
+
+
+ ) : tablosWithFiles.length === 0 ? (
+
+ ) : (
+
+ {tablosWithFiles.map((tablo) => (
+
+ ))}
+
+ )}
+
+ {tablos && tablos.length > 0 && (
+
setUploadOpen(false)}
+ tablos={tablos}
+ />
+ )}
+
+ );
+}
diff --git a/apps/main/src/pages/tasks.tsx b/apps/main/src/pages/tasks.tsx
index b99e4d4..6d5cf38 100644
--- a/apps/main/src/pages/tasks.tsx
+++ b/apps/main/src/pages/tasks.tsx
@@ -1,16 +1,31 @@
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
-import { getTextColorFromTabloColor } from "@xtablo/shared";
-import { KanbanColumn, KanbanTask } from "@xtablo/shared-types";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@xtablo/ui/components/select";
-import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
+import type { KanbanColumn, KanbanTask } from "@xtablo/shared-types";
import { Button } from "@xtablo/ui/components/button";
-import { Kanban, LayoutGrid, ListTodo, PlusIcon, UserIcon } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuCheckboxItem,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@xtablo/ui/components/dropdown-menu";
+import {
+ CalendarIcon,
+ CircleCheckIcon,
+ CircleIcon,
+ EllipsisVerticalIcon,
+ KanbanIcon,
+ ListIcon,
+ ListTodo,
+ MessageSquareIcon,
+ MapIcon,
+ PaperclipIcon,
+ PlusIcon,
+ SearchIcon,
+ Settings2Icon,
+ UserIcon,
+} from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
@@ -50,9 +65,11 @@ export function TasksPage() {
const [statusFilter, setStatusFilter] = useState("all");
const [assigneeFilter, setAssigneeFilter] = useState("all");
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
+ const [searchQuery, setSearchQuery] = useState("");
// Get view mode from URL params, default to "kanban"
- const viewMode = (searchParams.get("view") as "kanban" | "aggregated") || "kanban";
+ const viewMode =
+ (searchParams.get("view") as "kanban" | "aggregated") || "kanban";
// Function to update view mode in URL
const setViewMode = (mode: "kanban" | "aggregated") => {
@@ -89,12 +106,31 @@ export function TasksPage() {
} else if (assigneeFilter === "unassigned") {
filtered = filtered.filter((task) => !task.assignee_id);
} else {
- filtered = filtered.filter((task) => task.assignee_id === assigneeFilter);
+ filtered = filtered.filter(
+ (task) => task.assignee_id === assigneeFilter,
+ );
}
}
+ // Search query
+ if (searchQuery.trim()) {
+ const q = searchQuery.toLowerCase();
+ filtered = filtered.filter(
+ (task) =>
+ task.title?.toLowerCase().includes(q) ||
+ task.description?.toLowerCase().includes(q),
+ );
+ }
+
return filtered;
- }, [allTasks, selectedTabloId, statusFilter, assigneeFilter, user.id]);
+ }, [
+ allTasks,
+ selectedTabloId,
+ statusFilter,
+ assigneeFilter,
+ user.id,
+ searchQuery,
+ ]);
// Initialize Kanban columns from filtered tasks
const columns = useMemo((): KanbanColumn[] => {
@@ -178,7 +214,7 @@ export function TasksPage() {
const handleDrop = (
e: React.DragEvent,
- targetStatus: "todo" | "in_progress" | "in_review" | "done"
+ targetStatus: "todo" | "in_progress" | "in_review" | "done",
) => {
e.preventDefault();
const taskId = e.dataTransfer.getData("taskId");
@@ -196,132 +232,177 @@ export function TasksPage() {
});
};
+ const viewTabs = [
+ { id: "kanban" as const, label: "Tableau", icon: KanbanIcon },
+ { id: "aggregated" as const, label: "Liste", icon: ListIcon },
+ {
+ id: "gantt" as const,
+ label: "Roadmap",
+ icon: MapIcon,
+ disabled: true,
+ comingSoon: true,
+ },
+ {
+ id: "calendar" as const,
+ label: "Calendrier",
+ icon: CalendarIcon,
+ disabled: true,
+ comingSoon: true,
+ },
+ ];
+
return (