feat(clients): add layout, auth callback, tablo page, and list page

Adds SessionProvider to main.tsx, creates ClientLayout with minimal top bar,
AuthCallback for magic link handling, ClientTabloPage with all 7 tabs using
tablo-views components, and ClientTabloListPage with auto-redirect for single tablo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-15 14:30:55 +02:00
parent 118b23bfb1
commit f3fb08c9b2
No known key found for this signature in database
8 changed files with 536 additions and 9 deletions

View file

@ -0,0 +1,67 @@
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { Avatar, AvatarFallback } from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button";
import { Outlet } from "react-router-dom";
import { supabase } from "../lib/supabase";
function getInitials(email: string): string {
const parts = email.split("@")[0].split(/[._-]/);
return parts
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
}
export function ClientLayout() {
const { session } = useSession();
if (!session) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-3">
<p className="text-lg font-medium text-foreground">Accès non autorisé</p>
<p className="text-sm text-muted-foreground">
Veuillez utiliser le lien reçu dans votre email pour accéder à cette page.
</p>
</div>
</div>
);
}
const email = session.user.email ?? "";
const initials = email ? getInitials(email) : "?";
const handleLogout = async () => {
await supabase.auth.signOut();
};
return (
<div className="min-h-screen bg-background">
{/* Top bar */}
<header className="border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-14 items-center justify-between px-4 max-w-7xl mx-auto">
{/* Brand */}
<span className="text-lg font-bold text-foreground">Xtablo</span>
{/* User info + logout */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
</Avatar>
<span className="text-sm text-muted-foreground hidden sm:block">{email}</span>
</div>
<Button variant="outline" size="sm" onClick={handleLogout}>
Déconnexion
</Button>
</div>
</div>
</header>
{/* Page content */}
<main className="max-w-7xl mx-auto px-4 py-6">
<Outlet />
</main>
</div>
);
}

View file

@ -0,0 +1,10 @@
import { createSupabaseClient } from "@xtablo/shared";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing Supabase environment variables");
}
export const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey);

View file

@ -1,11 +1,13 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient } from "@xtablo/shared";
import { SessionProvider } from "@xtablo/shared/contexts/SessionContext";
import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { Toaster } from "@xtablo/ui/components/sonner";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter as Router } from "react-router-dom";
import App from "./App";
import { supabase } from "./lib/supabase";
import "@xtablo/ui/styles/globals.css";
import "./main.css";
@ -14,12 +16,14 @@ import "./i18n";
createRoot(document.getElementById("client-root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<Toaster />
<Router>
<App />
</Router>
</ThemeProvider>
<SessionProvider supabase={supabase}>
<ThemeProvider>
<Toaster />
<Router>
<App />
</Router>
</ThemeProvider>
</SessionProvider>
</QueryClientProvider>
</StrictMode>
);

View file

@ -0,0 +1,66 @@
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import { useEffect, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
export function AuthCallback() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token");
const { session } = useSession();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const hasAccepted = useRef(false);
useEffect(() => {
if (!session || !token || hasAccepted.current) {
return;
}
hasAccepted.current = true;
const apiUrl = import.meta.env.VITE_API_URL as string;
fetch(`${apiUrl}/api/v1/client-invites/accept/${token}`, {
method: "POST",
headers: {
Authorization: `Bearer ${session.access_token}`,
"Content-Type": "application/json",
},
})
.then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as { message?: string }).message ?? "Erreur lors de l'acceptation de l'invitation");
}
return res.json() as Promise<{ tabloId: string }>;
})
.then((data) => {
navigate(`/tablo/${data.tabloId}`, { replace: true });
})
.catch((err: unknown) => {
console.error("Accept invite error:", err);
setError(
"Une erreur est survenue lors de l'acceptation de l'invitation. Veuillez contacter la personne qui vous a invité."
);
});
}, [session, token, navigate]);
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-3 max-w-md px-4">
<p className="text-lg font-medium text-destructive">Erreur</p>
<p className="text-sm text-muted-foreground">{error}</p>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto" />
<p className="text-sm text-muted-foreground">Authentification en cours...</p>
</div>
</div>
);
}

View file

@ -0,0 +1,63 @@
import { useQuery } from "@tanstack/react-query";
import type { UserTablo } from "@xtablo/shared-types";
import { Navigate, Link } from "react-router-dom";
import { supabase } from "../lib/supabase";
function useClientTablosList() {
return useQuery<UserTablo[]>({
queryKey: ["client-tablos-list"],
queryFn: async () => {
const { data, error } = await supabase.from("user_tablos").select("*");
if (error) throw error;
return (data ?? []) as UserTablo[];
},
});
}
export function ClientTabloListPage() {
const { data: tablos, isLoading } = useClientTablosList();
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!tablos || tablos.length === 0) {
return (
<div className="text-center py-16">
<p className="text-muted-foreground">Aucun projet disponible.</p>
</div>
);
}
if (tablos.length === 1) {
return <Navigate to={`/tablo/${tablos[0].id}`} replace />;
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Mes projets</h1>
<p className="text-muted-foreground mt-1">Sélectionnez un projet pour y accéder.</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tablos.map((tablo) => (
<Link
key={tablo.id}
to={`/tablo/${tablo.id}`}
className="block p-5 rounded-lg border border-border bg-card hover:bg-muted/50 transition-colors space-y-2"
>
{tablo.color && (
<div className={`w-8 h-8 rounded-lg ${tablo.color}`} />
)}
<h2 className="font-semibold text-foreground">{tablo.name}</h2>
</Link>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,310 @@
import { useQuery } from "@tanstack/react-query";
import { buildApi } from "@xtablo/shared";
import { useSession } from "@xtablo/shared/contexts/SessionContext";
import type { Etape, KanbanTask, TabloFolder, UserTablo } from "@xtablo/shared-types";
import { CalendarIcon, FolderIcon, KanbanIcon, ListChecksIcon, MapIcon, MessageCircleIcon } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import {
EtapesSection,
RoadmapSection,
TabloDiscussionSection,
TabloEventsSection,
TabloFilesSection,
TabloTasksSection,
} from "@xtablo/tablo-views";
import { supabase } from "../lib/supabase";
const API_URL = import.meta.env.VITE_API_URL as string;
// ─── Local hooks ──────────────────────────────────────────────────────────────
function useAuthedApi(accessToken: string | undefined) {
return buildApi(API_URL).create({
headers: {
Authorization: `Bearer ${accessToken ?? ""}`,
},
});
}
function useClientTablo(tabloId: string) {
return useQuery<UserTablo>({
queryKey: ["client-tablo", tabloId],
queryFn: async () => {
const { data, error } = await supabase
.from("user_tablos")
.select("*")
.eq("id", tabloId)
.single();
if (error) throw error;
return data as UserTablo;
},
enabled: !!tabloId,
});
}
function useClientTabloTasks(tabloId: string) {
return useQuery<KanbanTask[]>({
queryKey: ["client-tasks", tabloId],
queryFn: async () => {
const { data, error } = await supabase
.from("tasks_with_assignee")
.select("*")
.eq("tablo_id", tabloId)
.eq("is_parent", false)
.order("updated_at", { ascending: false });
if (error) throw error;
return (data ?? []) as KanbanTask[];
},
enabled: !!tabloId,
});
}
function useClientTabloEtapes(tabloId: string) {
return useQuery<Etape[]>({
queryKey: ["client-etapes", tabloId],
queryFn: async () => {
const { data, error } = await supabase
.from("tasks")
.select("*")
.eq("tablo_id", tabloId)
.eq("is_parent", true)
.order("position", { ascending: true });
if (error) throw error;
return (data ?? []) as Etape[];
},
enabled: !!tabloId,
});
}
function useClientTabloEvents(tabloId: string) {
return useQuery({
queryKey: ["client-events", tabloId],
queryFn: async () => {
const { data, error } = await supabase
.from("events_and_tablos")
.select("*")
.eq("tablo_id", tabloId)
.order("start_date", { ascending: false });
if (error) throw error;
return data ?? [];
},
enabled: !!tabloId,
});
}
function useClientTabloMembers(tabloId: string, accessToken: string | undefined) {
const api = useAuthedApi(accessToken);
return useQuery({
queryKey: ["client-members", tabloId],
queryFn: async () => {
const { data } = await api.get<{
members: {
id: string;
name: string;
is_admin: boolean;
email: string;
avatar_url: string | null;
}[];
}>(`/api/v1/tablos/members/${tabloId}`);
return data.members;
},
enabled: !!tabloId && !!accessToken,
});
}
function useClientTabloFiles(tabloId: string, accessToken: string | undefined) {
const api = useAuthedApi(accessToken);
return useQuery<{ fileNames: string[] }>({
queryKey: ["client-tablo-files", tabloId],
queryFn: async () => {
const { data } = await api.get(`/api/v1/tablo-data/${tabloId}/filenames`);
return data as { fileNames: string[] };
},
enabled: !!tabloId && !!accessToken,
});
}
function useClientTabloFolders(tabloId: string, accessToken: string | undefined) {
const api = useAuthedApi(accessToken);
return useQuery<TabloFolder[]>({
queryKey: ["client-tablo-folders", tabloId],
queryFn: async () => {
const { data } = await api.get<{ folders: TabloFolder[] }>(`/api/v1/tablo-folders/${tabloId}`);
return data.folders ?? [];
},
enabled: !!tabloId && !!accessToken,
});
}
// ─── Tabs ─────────────────────────────────────────────────────────────────────
type TabId = "overview" | "etapes" | "tasks" | "files" | "discussion" | "events" | "roadmap";
const TABS: { id: TabId; label: string; icon: React.ElementType }[] = [
{ id: "overview", label: "Aperçu", icon: ListChecksIcon },
{ id: "etapes", label: "Étapes", icon: ListChecksIcon },
{ id: "tasks", label: "Tâches", icon: KanbanIcon },
{ id: "files", label: "Fichiers", icon: FolderIcon },
{ id: "discussion", label: "Discussion", icon: MessageCircleIcon },
{ id: "events", label: "Événements", icon: CalendarIcon },
{ id: "roadmap", label: "Roadmap", icon: MapIcon },
];
// ─── Page ─────────────────────────────────────────────────────────────────────
export function ClientTabloPage() {
const { tabloId } = useParams<{ tabloId: string }>();
const { session } = useSession();
const [activeTab, setActiveTab] = useState<TabId>("overview");
const accessToken = session?.access_token;
const currentUserId = session?.user.id ?? "";
const { data: tablo, isLoading: tabloLoading } = useClientTablo(tabloId ?? "");
const { data: tasks = [] } = useClientTabloTasks(tabloId ?? "");
const { data: etapes = [] } = useClientTabloEtapes(tabloId ?? "");
const { data: events, isLoading: eventsLoading, error: eventsError } = useClientTabloEvents(tabloId ?? "");
const { data: members = [] } = useClientTabloMembers(tabloId ?? "", accessToken);
const { data: filesData, isLoading: filesLoading, error: filesError } = useClientTabloFiles(tabloId ?? "", accessToken);
const { data: folders = [], isLoading: foldersLoading, error: foldersError } = useClientTabloFolders(tabloId ?? "", accessToken);
const fileNames = (filesData?.fileNames ?? []).filter((f) => !f.startsWith("."));
const currentUser = { id: currentUserId, avatar_url: null };
if (tabloLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!tablo) {
return (
<div className="text-center py-16">
<p className="text-muted-foreground">Projet introuvable.</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Tablo header */}
<div>
<h1 className="text-2xl font-bold text-foreground">{tablo.name}</h1>
</div>
{/* Tab bar */}
<div className="border-b border-border">
<nav className="flex gap-1 overflow-x-auto">
{TABS.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 whitespace-nowrap transition-colors ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground"
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</nav>
</div>
{/* Tab content */}
<div>
{activeTab === "overview" && (
<div className="space-y-6">
{/* Simple overview: list etapes with progress */}
<EtapesSection
etapes={etapes}
tabloTasks={tasks}
tabloId={tablo.id}
isAdmin={false}
onCreateTask={() => {}}
onCreateEtape={async () => {}}
/>
</div>
)}
{activeTab === "etapes" && (
<EtapesSection
etapes={etapes}
tabloTasks={tasks}
tabloId={tablo.id}
isAdmin={false}
onCreateTask={() => {}}
onCreateEtape={async () => {}}
/>
)}
{activeTab === "tasks" && (
<TabloTasksSection
tablo={tablo}
isAdmin={false}
tasks={tasks}
members={members}
etapes={etapes}
currentUser={currentUser}
/>
)}
{activeTab === "files" && (
<TabloFilesSection
tablo={tablo}
isAdmin={false}
isReadOnly={true}
currentUserId={currentUserId}
fileNames={fileNames}
filesLoading={filesLoading}
filesError={filesError instanceof Error ? filesError : null}
folders={folders}
foldersLoading={foldersLoading}
foldersError={foldersError instanceof Error ? foldersError : null}
currentUser={currentUser}
members={members}
/>
)}
{activeTab === "discussion" && (
<TabloDiscussionSection
tablo={tablo}
isAdmin={false}
currentUserId={currentUserId}
members={members}
/>
)}
{activeTab === "events" && (
<TabloEventsSection
tablo={tablo}
isAdmin={false}
isReadOnly={true}
events={events as Parameters<typeof TabloEventsSection>[0]["events"]}
isLoading={eventsLoading}
error={eventsError instanceof Error ? eventsError : null}
currentUser={currentUser}
members={members}
/>
)}
{activeTab === "roadmap" && (
<RoadmapSection
tabloTasks={tasks}
onDateClick={() => {}}
onTaskStatusChange={() => {}}
/>
)}
</div>
</div>
);
}

View file

@ -1,11 +1,17 @@
import { Route, Routes } from "react-router-dom";
import { ClientLayout } from "./components/ClientLayout";
import { AuthCallback } from "./pages/AuthCallback";
import { ClientTabloListPage } from "./pages/ClientTabloListPage";
import { ClientTabloPage } from "./pages/ClientTabloPage";
export default function AppRoutes() {
return (
<Routes>
<Route path="/auth/callback" element={<div>Auth callback placeholder</div>} />
<Route path="/tablo/:tabloId" element={<div>Tablo view placeholder</div>} />
<Route path="/" element={<div>Client portal placeholder</div>} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route element={<ClientLayout />}>
<Route path="/tablo/:tabloId" element={<ClientTabloPage />} />
<Route path="/" element={<ClientTabloListPage />} />
</Route>
</Routes>
);
}

View file

@ -3,6 +3,7 @@
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",