Merge pull request #21 from artslidd/develop

Develop
This commit is contained in:
Arthur Belleville 2025-10-24 18:59:07 +02:00 committed by GitHub
commit cdd6180ecf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 11251 additions and 1742 deletions

View file

@ -6,7 +6,11 @@ import type { StreamChat } from "stream-chat";
import { config } from "./config.js";
import type { Tables } from "./database.types.ts";
import { writeCalendarFileToR2 } from "./helpers.js";
import { authMiddleware, r2Middleware, streamChatMiddleware } from "./middleware.js";
import {
authMiddleware,
r2Middleware,
streamChatMiddleware,
} from "./middleware.js";
import { generateToken } from "./token.js";
import { transporter } from "./transporter.js";
import type { EventInsertInTablo, TabloInsert } from "./types.ts";
@ -166,7 +170,9 @@ tabloRouter.post("/create-and-invite", async (c) => {
const { data: insertedTablo, error } = await supabase
.from("tablos")
.insert({
name: `${invitedUserDataTyped.name || "Invité"} / ${ownerDataTyped.name || "Propriétaire"}`,
name: `${invitedUserDataTyped.name || "Invité"} / ${
ownerDataTyped.name || "Propriétaire"
}`,
color: "bg-blue-500",
status: "todo",
owner_id: ownerId,
@ -184,20 +190,22 @@ tabloRouter.post("/create-and-invite", async (c) => {
}
// Grant access to the current user (invited user) as a non-admin member
const { error: tabloAccessError } = await supabase.from("tablo_access").insert(
{
tablo_id: tabloData.id,
user_id: user.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: ownerId,
}
// {
// onConflict: "tablo_id, user_id",
// }
);
const { error: tabloAccessError } = await supabase
.from("tablo_access")
.insert(
{
tablo_id: tabloData.id,
user_id: user.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: ownerId,
}
// {
// onConflict: "tablo_id, user_id",
// }
);
if (tabloAccessError) {
console.error("tabloAccessError", tabloAccessError);
@ -290,7 +298,8 @@ tabloRouter.patch("/update", async (c) => {
const updatedTablo = update as Tables<"tablos">;
const isUpdatingName = tablo.name !== undefined && tablo.name !== updatedTablo.name;
const isUpdatingName =
tablo.name !== undefined && tablo.name !== updatedTablo.name;
if (error) {
return c.json({ error: error.message }, 500);
@ -335,7 +344,10 @@ tabloRouter.delete("/delete", async (c) => {
.single();
if (accessError || !tabloAccess || !tabloAccess.is_admin) {
return c.json({ error: "You are not authorized to delete this tablo" }, 403);
return c.json(
{ error: "You are not authorized to delete this tablo" },
403
);
}
if (error) {
@ -376,7 +388,10 @@ tabloRouter.post("/invite", async (c) => {
}
if (tablo.owner_id !== sender.id) {
return c.json({ error: "You are not allowed to invite users to this tablo" }, 400);
return c.json(
{ error: "You are not allowed to invite users to this tablo" },
400
);
}
const { data: introConfigData, error: introError } = await supabase
@ -410,8 +425,8 @@ tabloRouter.post("/invite", async (c) => {
<p>Cliquez sur <a href="${
config.XTABLO_URL
}/join/${encodeURIComponent(tablo.name)}?token=${encodeURIComponent(
token
)}">ce lien</a> pour accepter l'invitation.</p>
token
)}">ce lien</a> pour accepter l'invitation.</p>
<br>
<p>Cordialement.</p>
`,
@ -447,15 +462,17 @@ tabloRouter.post("/join", async (c) => {
const { id: invite_id, tablo_id, invited_by } = inviteData;
const { error: tabloAccessError } = await supabase.from("tablo_access").insert({
tablo_id,
user_id: joiner.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: invited_by,
});
const { error: tabloAccessError } = await supabase
.from("tablo_access")
.insert({
tablo_id,
user_id: joiner.id,
// ** IMPORTANT **
is_admin: false,
// -------------
is_active: true,
granted_by: invited_by,
});
if (tabloAccessError) {
console.error("tabloAccessError", tabloAccessError);
@ -471,7 +488,7 @@ tabloRouter.post("/join", async (c) => {
console.error("error adding member to channel", error);
}
return c.json({ message: "Tablo joined successfully" });
return c.json({ tablo_id });
});
tabloRouter.get("/members/:tablo_id", async (c) => {

7
apps/external/.env.production vendored Normal file
View file

@ -0,0 +1,7 @@
VITE_SUPABASE_URL=https://mhcafqvzbrrwvahpvvzd.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1oY2FmcXZ6YnJyd3ZhaHB2dnpkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDEyNDEzMjEsImV4cCI6MjA1NjgxNzMyMX0.Otxn5BWCPD2ABlMM59hCgeur9Tf_Q7PndAbTkqXDPtM
VITE_SUPABASE_ID=mhcafqvzbrrwvahpvvzd
VITE_STREAM_CHAT_API_KEY="t5vvvddteapa"
VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app

View file

@ -3,7 +3,7 @@
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": false },
"files": {
"ignoreUnknown": true,
"includes": ["src/**/*", "*.{ts,tsx,js,jsx,json}"]
"includes": ["src/**/*", "*.{tsx,js,jsx,json}", "vite.config.ts"]
},
"formatter": {
"enabled": true,

View file

@ -4,8 +4,8 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "tsc -b && vite build",
"dev": "vite dev --port 5174",
"build": "tsc -b && vite build --mode production",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
@ -17,6 +17,7 @@
},
"devDependencies": {
"@biomejs/biome": "2.2.5",
"@cloudflare/vite-plugin": "^1.9.4",
"@tailwindcss/vite": "^4.0.14",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",

View file

@ -213,11 +213,7 @@ export function EmbeddedBookingPage() {
const shortUserId = userInfo?.substring(userInfo.lastIndexOf("-") + 1);
const { data: publicSlots } = usePublicSlots(
api,
shortUserId || "",
eventTypeStandardName || ""
);
const { data: publicSlots } = usePublicSlots(api, shortUserId || "", eventTypeStandardName || "");
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(api, () => {
handleCloseModal();
@ -497,7 +493,7 @@ export function EmbeddedBookingPage() {
{/* Event Type Info */}
<div className="flex-1">
<h3 className="text-xl font-bold mb-3">{eventType?.name || "Type d'événement"}</h3>
<h3 className="text-xl font-bold mb-3">{eventType?.name || "Type d'appel"}</h3>
{eventType?.description && (
<TypographyMuted className={twMerge("mb-6 text-sm leading-relaxed", mutedTxtColor)}>

View file

@ -119,29 +119,31 @@ export function FloatingBookingWidget() {
const eventTypeStandardName = params.eventTypeStandardName as string;
const shortUserId = userInfo?.substring(userInfo.lastIndexOf("-") + 1);
// Get variants from URL params with fallback to purple
// Get view mode and variants from URL params
const view = searchParams.get("view") || "default"; // 'button', 'modal', or 'default'
const buttonVariant = (searchParams.get("buttonVariant") as ColorVariant) || "purple";
// Get color schemes based on variants
const btnColors = buttonColors[buttonVariant];
const { data: publicSlots } = usePublicSlots(
api,
shortUserId || "",
eventTypeStandardName || ""
);
const { data: publicSlots } = usePublicSlots(api, shortUserId || "", eventTypeStandardName || "");
const { mutateAsync: createTabloWithOwner } = useCreateTabloWithOwner(api, () => {
handleCloseModal();
setIsWidgetOpen(false);
if (view === "modal") {
// Send message to parent to close the modal
window.parent.postMessage("xtablo:close", "*");
} else {
setIsWidgetOpen(false);
}
});
const userProfile = publicSlots?.user;
const eventType = publicSlots?.eventType;
const slotsData = publicSlots?.slots || {};
// Widget state
const [isWidgetOpen, setIsWidgetOpen] = useState(false);
// Widget state - auto-open if in modal view
const [isWidgetOpen, setIsWidgetOpen] = useState(view === "modal");
// Calendar state
const [currentDate, setCurrentDate] = useState(new Date());
@ -370,27 +372,61 @@ export function FloatingBookingWidget() {
}
};
return (
<div className="fixed inset-0 pointer-events-none">
{/* Floating Button */}
<div className="fixed bottom-6 right-6 z-50 pointer-events-auto">
// If view is 'button', only show the button
if (view === "button") {
return (
<div className="fixed inset-0 flex items-center justify-center">
<Button
size="lg"
className={twMerge(
"rounded-full h-14 w-14 shadow-lg hover:shadow-xl transition-all duration-200",
btnColors.floating,
isWidgetOpen && "scale-0 opacity-0"
"rounded-full h-14 w-14 shadow-lg hover:shadow-xl border-0 transition-all duration-200",
btnColors.floating
)}
onClick={() => setIsWidgetOpen(true)}
onClick={() => window.parent.postMessage("xtablo:open", "*")}
>
<CalendarIcon className="w-6 h-6" />
</Button>
</div>
);
}
return (
<div className="fixed inset-0 pointer-events-none">
{/* Backdrop for modal view */}
{view === "modal" && isWidgetOpen && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm pointer-events-auto animate-in fade-in duration-200"
onClick={() => window.parent.postMessage("xtablo:close", "*")}
/>
)}
{/* Floating Button - only show in default view */}
{view === "default" && (
<div className="fixed bottom-6 right-6 z-50 pointer-events-auto">
<Button
size="lg"
className={twMerge(
"rounded-full h-14 w-14 shadow-lg hover:shadow-xl transition-all duration-200",
btnColors.floating,
isWidgetOpen && "scale-0 opacity-0"
)}
onClick={() => setIsWidgetOpen(true)}
>
<CalendarIcon className="w-6 h-6" />
</Button>
</div>
)}
{/* Floating Widget Popup */}
{isWidgetOpen && (
<div className="fixed bottom-6 right-6 z-50 w-[450px] max-h-[650px] bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden animate-in slide-in-from-bottom-4 duration-300 pointer-events-auto">
<div
className={twMerge(
"z-50 w-[450px] max-h-[650px] bg-white dark:bg-gray-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 flex flex-col overflow-hidden pointer-events-auto",
view === "modal"
? "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-in fade-in zoom-in-95 duration-200"
: "fixed bottom-6 right-6 animate-in slide-in-from-bottom-4 duration-300"
)}
>
{/* Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
@ -407,7 +443,7 @@ export function FloatingBookingWidget() {
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-white text-sm truncate">
{eventType?.name || "Type d'événement"}
{eventType?.name || "Type d'appel"}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{userProfile?.name || "Professionnel"}
@ -419,8 +455,12 @@ export function FloatingBookingWidget() {
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => {
setIsWidgetOpen(false);
setSelectedDate(null);
if (view === "modal") {
window.parent.postMessage("xtablo:close", "*");
} else {
setIsWidgetOpen(false);
setSelectedDate(null);
}
}}
>
<XIcon className="w-4 h-4" />

View file

@ -9,4 +9,3 @@
}
}
}

View file

@ -1,26 +1,44 @@
/// <reference types="vite/client" />
import { cloudflare } from "@cloudflare/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { dirname, resolve } from "path";
import { dirname } from "path";
import { fileURLToPath } from "url";
import { defineConfig } from "vite";
import { defineConfig, PluginOption } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
const __dirname = dirname(fileURLToPath(import.meta.url));
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(), tsconfigPaths()],
server: {
cors: false,
port: 5174,
},
build: {
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
export default defineConfig(({ mode }) => {
const plugins: PluginOption[] = [
react(),
// visualizer() as PluginOption,
tailwindcss(),
tsconfigPaths(),
];
// Only include cloudflare plugin when not in test mode
if (mode !== "test" && process.env.VITEST !== "true") {
plugins.push(cloudflare());
}
return {
plugins,
server: {
cors: false,
},
},
define: process.env.VITEST
? {
"import.meta.env.VITE_SUPABASE_URL": JSON.stringify("https://test.supabase.co"),
"import.meta.env.VITE_SUPABASE_ANON_KEY": JSON.stringify("test-anon-key"),
}
: undefined,
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/setupTests.ts",
},
};
});

9363
apps/external/worker-configuration.d.ts vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -12,6 +12,5 @@ enabled = true
[vars]
PYTHON_VERSION = "3.11.5"
[[routes]]
pattern = "embed.xtablo.com"
custom_domain = true
[env.production]
route = { pattern = "embed.xtablo.com", custom_domain = true }

View file

@ -4,7 +4,7 @@
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev": "vite dev --port 5173",
"build": "tsc -b && vite build",
"typecheck": "tsc --noEmit",
"lint": "biome check .",

View file

@ -6,18 +6,20 @@ interface CustomChannelHeaderProps {
tablos: UserTablo[];
onToggleChannelList?: () => void;
isChannelListExpanded?: boolean;
showToggleButton?: boolean;
}
export const CustomChannelHeader = ({
tablos,
onToggleChannelList,
isChannelListExpanded = false,
showToggleButton = true,
}: CustomChannelHeaderProps) => {
const { channel } = useChannelStateContext();
return (
<div className="flex items-center">
{onToggleChannelList && (
{showToggleButton && onToggleChannelList && (
<button
onClick={onToggleChannelList}
className="mr-2 p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"

View file

@ -61,17 +61,49 @@ export function EmbedConfigModal({ isOpen, onClose, buildPublicLink }: EmbedConf
if (embedConfig.embedType === "floating") {
return `<!-- Xtablo Floating Widget -->
<div id="xtablo-widget-container"></div>
<div id="xtablo-widget-root"></div>
<script>
(function() {
var container = document.getElementById('xtablo-widget-container');
var iframe = document.createElement('iframe');
iframe.src = '${embedUrl}';
iframe.style.cssText = 'position: fixed; bottom: 0; right: 0; width: 100%; height: 100%; border: none; z-index: 999999; background: transparent;';
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('allow', 'clipboard-write');
var root = document.getElementById('xtablo-widget-root');
var isOpen = false;
var modal = null;
container.appendChild(iframe);
// Create floating button
var button = document.createElement('div');
button.style.cssText = 'position: fixed; bottom: 24px; right: 24px; z-index: 999998; cursor: pointer; width: 60px; height: 60px;';
button.innerHTML = '<iframe src="${embedUrl}&view=button" style="width: 100%; height: 100%; border: none; border-radius: 50%;" frameborder="0" scrolling="no"></iframe>';
// Create modal container (hidden by default)
modal = document.createElement('div');
modal.style.cssText = 'position: fixed; bottom: 0; right: 0; width: 100%; height: 100%; z-index: 999999; display: none; pointer-events: none;';
var modalIframe = document.createElement('iframe');
modalIframe.src = '${embedUrl}&view=modal';
modalIframe.style.cssText = 'position: absolute; bottom: 0; right: 0; width: 100%; height: 100%; border: none; background: transparent; pointer-events: auto;';
modalIframe.setAttribute('frameborder', '0');
modalIframe.setAttribute('allow', 'clipboard-write');
modal.appendChild(modalIframe);
// Toggle modal on button click
button.addEventListener('click', function() {
isOpen = !isOpen;
modal.style.display = isOpen ? 'block' : 'none';
});
// Listen for close messages from iframe
window.addEventListener('message', function(event) {
if (event.data === 'xtablo:close') {
isOpen = false;
modal.style.display = 'none';
} else if (event.data === 'xtablo:open') {
isOpen = true;
modal.style.display = 'block';
}
});
root.appendChild(button);
root.appendChild(modal);
})();
</script>`;
}

View file

@ -19,6 +19,7 @@ import {
import { useState } from "react";
import { match } from "ts-pattern";
import { EventType, EventTypeConfig, useEventTypes } from "../hooks/event-types";
import { isDev } from "../lib/env";
import { useUser } from "../providers/UserStoreProvider";
export function EventTypeCard({
@ -41,16 +42,16 @@ export function EventTypeCard({
const shortUserId = user.id.substring(0, 6);
// Construct the public booking URL
const baseUrl = window.location.origin;
const baseUrl = isDev ? "http://localhost:5174" : "https://embed.xtablo.com";
return match(type)
.with("embed", () => {
return `${baseUrl}/external/?mode=embed&userInfo=${sanitizedUserName}-${shortUserId}&eventTypeStandardName=${standardName}`;
return `${baseUrl}/embed/${sanitizedUserName}-${shortUserId}/${standardName}`;
})
.with("floating", () => {
return `${baseUrl}/external/?mode=widget&userInfo=${sanitizedUserName}-${shortUserId}&eventTypeStandardName=${standardName}`;
return `${baseUrl}/widget/${sanitizedUserName}-${shortUserId}/${standardName}`;
})
.with("normal", () => {
return `${baseUrl}/book/${sanitizedUserName}-${shortUserId}/${standardName}`;
return `${window.location.origin}/book/${sanitizedUserName}-${shortUserId}/${standardName}`;
})
.exhaustive();
};

View file

@ -39,7 +39,7 @@ export function EventTypeModal({
<DialogContent className="max-w-xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{editingEventType ? "Modifier le type d'événement" : "Nouveau type d'événement"}
{editingEventType ? "Modifier le Type d'appel" : "Nouveau Type d'appel"}
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
@ -47,7 +47,7 @@ export function EventTypeModal({
<div className="space-y-2">
<div className="space-y-2">
<Label>
Nom du type d&apos;événement <span className="text-destructive">*</span>
Nom du type d&apos;appel <span className="text-destructive">*</span>
</Label>
<Input
type="text"
@ -64,7 +64,7 @@ export function EventTypeModal({
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={2}
required
placeholder="Décrivez ce type d'événement et son objectif..."
placeholder="Décrivez ce Type d'appel et son objectif..."
/>
</div>
</div>
@ -98,9 +98,7 @@ export function EventTypeModal({
max={60}
step={5}
/>
<FieldDescription>
Temps de battement avant et après l&apos;événement
</FieldDescription>
<FieldDescription>Temps de battement avant et après l&apos;appel</FieldDescription>
</div>
</div>
</div>
@ -192,7 +190,7 @@ export function EventTypeModal({
>
<Label>Prix ()</Label>
<Description>
Prix de ce type d&apos;événement. Laissez à 0 pour gratuit.
Prix de ce type d&apos;appel. Laissez à 0 pour gratuit.
</Description>
</NumberField>
</div> */}
@ -210,9 +208,9 @@ export function EventTypeModal({
onChange={(isActive) => setFormData({ ...formData, isActive })}
>
<div>
<div className="font-medium">Type d&apos;événement actif</div>
<div className="font-medium">Type d&apos;appel actif</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Les clients peuvent réserver ce type d&apos;événement
Les clients peuvent réserver ce type d&apos;appel
</div>
</div>
</Checkbox>

View file

@ -18,11 +18,10 @@ import {
Circle,
ConstructionIcon,
Kanban,
LayoutDashboardIcon,
ListCheckIcon,
LogOutIcon,
MessageCircleIcon,
MinusIcon,
PanelsTopLeft,
PlusIcon,
SendIcon,
SettingsIcon,
@ -181,6 +180,14 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
/>
</RouterLink>
<RouterLink to="/availabilities">
<MenuDropdownItem
icon={<CalendarIcon className="w-5 h-5" aria-hidden="true" />}
label="Disponibilités"
variant="default"
/>
</RouterLink>
<MenuSeparator />
<div className="flex flex-row my-2 ml-1 items-center">
<ThemeSwitcher />
@ -283,23 +290,13 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
> = [
{
path: "/",
label: "Dashboard",
icon: <LayoutDashboardIcon className="w-5 h-5" />,
label: "Tablos",
icon: <PanelsTopLeft className="w-5 h-5" />,
},
{ isHorizontalBar: true },
{
path: "/event-types",
label: "Types d'événements",
icon: <ListCheckIcon className="w-5 h-5" />,
},
{
path: "/availabilities",
label: "Disponibilités",
icon: <CalendarIcon className="w-5 h-5" />,
},
{
path: "/bookings",
label: "Réservations",
path: "/events",
label: "Mes Événements",
icon: <CalendarCheckIcon className="w-5 h-5" />,
},
{

View file

@ -0,0 +1,65 @@
import { CustomChannelHeader } from "@ui/components/CustomChannelHeader";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { useEffect, useState } from "react";
import { Channel as StreamChannel } from "stream-chat";
import { Channel, MessageInput, MessageList, useChatContext, Window } from "stream-chat-react";
import ChatProvider from "../providers/ChatProvider";
import { LoadingSpinner } from "./LoadingSpinner";
interface TabloDiscussionSectionProps {
tablo: UserTablo;
isAdmin: boolean;
}
const TabloChat = ({ tablo }: { tablo: UserTablo }) => {
const { client, setActiveChannel } = useChatContext();
const [channel, setChannel] = useState<StreamChannel | null>(null);
useEffect(() => {
const initChannel = async () => {
if (client && tablo.id) {
const newChannel = client.channel("messaging", tablo.id);
await newChannel.watch();
setChannel(newChannel);
setActiveChannel(newChannel);
}
};
initChannel();
}, [client, tablo.id, setActiveChannel]);
if (!channel) {
return (
<div className="flex items-center justify-center h-96">
<LoadingSpinner />
</div>
);
}
return (
<Channel channel={channel}>
<Window>
<CustomChannelHeader tablos={[tablo]} showToggleButton={false} />
<MessageList />
<MessageInput />
</Window>
</Channel>
);
};
export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) => {
return (
<div className="flex flex-col h-full">
<div className="mb-6">
<h1 className="text-3xl font-bold text-foreground">Discussion</h1>
<p className="text-muted-foreground mt-1">Conversations liées à ce tablo</p>
</div>
<div className="flex-1 bg-card rounded-lg border border-border overflow-hidden min-h-0">
<ChatProvider>
<TabloChat tablo={tablo} />
</ChatProvider>
</div>
</div>
);
};

View file

@ -0,0 +1,500 @@
import { toast } from "@xtablo/shared";
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { DownloadIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import { FileTrigger } from "react-aria-components";
import {
useCreateTabloFile,
useDeleteTabloFile,
useDownloadTabloFile,
useTabloFileNames,
} from "../hooks/tablo_data";
interface TabloFilesSectionProps {
tablo: UserTablo;
isAdmin: boolean;
}
export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => {
const {
data: fileData,
isLoading: filesLoading,
error: filesError,
} = useTabloFileNames(tablo.id);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [deletingFile, setDeletingFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
const [error, setError] = useState("");
const createFile = useCreateTabloFile();
const deleteFile = useDeleteTabloFile();
const downloadFile = useDownloadTabloFile();
const handleFileSelect = (files: FileList | null) => {
const file = files?.[0];
if (!file) return;
// Validate file size (20MB limit)
const maxSize = 20 * 1024 * 1024; // 20MB in bytes
if (file.size > maxSize) {
setError("Le fichier ne peut pas dépasser 20MB");
return;
}
setError("");
setSelectedFile(file);
};
const handleFileUpload = async () => {
if (!selectedFile || !tablo.id) return;
setIsUploading(true);
try {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
await createFile.mutateAsync({
tabloId: tablo.id,
fileName: selectedFile.name,
data: {
content,
contentType: selectedFile.type || "application/octet-stream",
},
});
// Reset upload state
setSelectedFile(null);
setIsUploading(false);
} catch (uploadError) {
setIsUploading(false);
console.error("Upload error:", uploadError);
}
};
reader.onerror = () => {
setIsUploading(false);
toast.add(
{
title: "Erreur de lecture",
description: "Impossible de lire le fichier sélectionné",
type: "error",
},
{
timeout: 5000,
}
);
};
// Read file as base64 data URL for binary files, or as text for text files
if (selectedFile.type.startsWith("text/") || selectedFile.type === "application/json") {
reader.readAsText(selectedFile);
} else {
reader.readAsDataURL(selectedFile);
}
} catch (error) {
setIsUploading(false);
console.error("Upload error:", error);
}
};
const cancelFileUpload = () => {
setSelectedFile(null);
};
const handleDeleteFile = async (fileName: string) => {
if (!tablo.id) return;
// Simple confirmation
if (!window.confirm(`Êtes-vous sûr de vouloir supprimer le fichier "${fileName}" ?`)) {
return;
}
setDeletingFile(fileName);
try {
await deleteFile.mutateAsync({
tabloId: tablo.id,
fileName,
});
} catch (error) {
console.error("Delete error:", error);
} finally {
setDeletingFile(null);
}
};
const handleDownloadFile = async (fileName: string) => {
if (!tablo.id) return;
setDownloadingFile(fileName);
try {
await downloadFile.mutateAsync({
tabloId: tablo.id,
fileName,
});
} catch (error) {
console.error("Download error:", error);
} finally {
setDownloadingFile(null);
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Fichiers</h1>
<p className="text-muted-foreground mt-1">Gérez les fichiers attachés à ce tablo</p>
</div>
{/* Error Banner */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center space-x-2">
<svg
className="w-5 h-5 text-red-500 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-red-700 dark:text-red-300 text-sm">{error}</span>
<button
onClick={() => setError("")}
className="ml-auto text-red-500 hover:text-red-700 dark:hover:text-red-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
)}
{/* File Upload Section - Only for Admins */}
{isAdmin && (
<div className="bg-card rounded-lg border border-border p-6">
<div className="flex items-center space-x-2 mb-4">
<svg
className="w-5 h-5 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
<h3 className="text-lg font-semibold text-foreground">Ajouter un fichier</h3>
</div>
{!selectedFile ? (
<div className="space-y-3">
<FileTrigger allowsMultiple={false} onSelect={handleFileSelect}>
<Button
variant="outline"
className="w-full justify-center py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 bg-gray-50 dark:bg-gray-800/50 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
>
<div className="flex flex-col items-center space-y-2">
<svg
className="w-8 h-8 text-gray-400 dark:text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<div className="text-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Cliquez pour sélectionner un fichier
</span>
</div>
</div>
</Button>
</FileTrigger>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center space-x-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{selectedFile.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={handleFileUpload}
disabled={isUploading}
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center justify-center space-x-2 shadow-sm"
>
{isUploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Ajout en cours...</span>
</>
) : (
<>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<span>Ajouter le fichier</span>
</>
)}
</button>
<button
type="button"
onClick={cancelFileUpload}
disabled={isUploading}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
>
Annuler
</button>
</div>
</div>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Taille maximale par fichier: 20MB
</p>
</div>
)}
{/* File List */}
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
Liste des fichiers
{fileData?.fileNames && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
({fileData.fileNames.length})
</span>
)}
</h3>
{filesLoading ? (
<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>
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
Chargement des fichiers...
</span>
</div>
) : filesError ? (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center space-x-2">
<svg
className="w-5 h-5 text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-sm text-red-700 dark:text-red-300">
Erreur lors du chargement des fichiers
</span>
</div>
</div>
) : fileData && fileData.fileNames && fileData.fileNames.length > 0 ? (
<div className="space-y-2">
{fileData.fileNames.map((fileName, index) => {
const fileExtension = fileName.split(".").pop()?.toLowerCase() || "";
const isImage = ["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(fileExtension);
const isPdf = fileExtension === "pdf";
const isText = ["txt", "md", "json", "csv"].includes(fileExtension);
return (
<div
key={index}
className="flex items-center space-x-3 p-3 bg-muted rounded-lg hover:bg-muted/80 transition-colors group"
>
<button
onClick={() => handleDownloadFile(fileName)}
disabled={downloadingFile === fileName}
className={`w-10 h-10 rounded-lg flex items-center justify-center text-white text-sm font-medium transition-all hover:scale-105 ${
isImage
? "bg-purple-500 hover:bg-purple-600"
: isPdf
? "bg-red-500 hover:bg-red-600"
: isText
? "bg-blue-500 hover:bg-blue-600"
: "bg-gray-500 hover:bg-gray-600"
} ${
downloadingFile === fileName
? "opacity-50 cursor-not-allowed"
: "cursor-pointer"
}`}
title={`Télécharger ${fileName}`}
>
{downloadingFile === fileName ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
) : isImage ? (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
) : isPdf ? (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
) : (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
)}
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground truncate" title={fileName}>
{fileName}
</p>
<p className="text-xs text-muted-foreground uppercase">
{fileExtension || "Fichier"}
</p>
</div>
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="ghost"
onClick={() => handleDownloadFile(fileName)}
disabled={downloadingFile === fileName}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20 transition-colors"
aria-label={`Télécharger ${fileName}`}
>
{downloadingFile === fileName ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
) : (
<DownloadIcon className="w-4 h-4" />
)}
</Button>
{isAdmin && (
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteFile(fileName)}
disabled={deletingFile === fileName}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20 transition-colors"
aria-label={`Supprimer ${fileName}`}
>
{deletingFile === fileName ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-500"></div>
) : (
<Trash2Icon className="w-4 h-4" />
)}
</Button>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-8">
<svg
className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 5a2 2 0 012-2h2a2 2 0 012 2v0H8v0z"
/>
</svg>
<p className="text-sm text-gray-500 dark:text-gray-400">Aucun fichier dans ce tablo</p>
{isAdmin && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Ajoutez votre premier fichier ci-dessus
</p>
)}
</div>
)}
</div>
</div>
);
};

View file

@ -1,911 +0,0 @@
import { toast } from "@xtablo/shared";
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { DownloadIcon, Trash2Icon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { FileTrigger } from "react-aria-components";
import { useInviteUser } from "../hooks/invite";
import {
useCreateTabloFile,
useDeleteTabloFile,
useDownloadTabloFile,
useTabloFileNames,
} from "../hooks/tablo_data";
import { useTabloMembers } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
import { ClickOutside } from "./ClickOutside";
import { ImageColorPicker } from "./ImageColorPicker";
import { StatusPicker } from "./StatusPicker";
type StatusType = "todo" | "in_progress" | "done";
interface TabloModalProps {
tablo: UserTablo | null;
onEdit: (updatedTablo: TabloUpdate & { id: string }) => void;
onClose: () => void;
readOnly?: boolean;
}
export const TabloModal = ({ tablo, onClose, onEdit }: TabloModalProps) => {
const currentUser = useUser();
const isAdmin = tablo?.is_admin ?? false;
const [editData, setEditData] = useState<UserTablo | null>(tablo);
const [isEditingName, setIsEditingName] = useState(false);
const [creationMode, setCreationMode] = useState<"image" | "color">("color");
const [selectedColor, setSelectedColor] = useState(tablo?.color || "bg-blue-500");
const [error, setError] = useState("");
const { data: members } = useTabloMembers(tablo?.id ?? "");
const [showMembers, setShowMembers] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
const {
data: fileData,
isLoading: filesLoading,
error: filesError,
} = useTabloFileNames(tablo?.id ?? "");
const [showFiles, setShowFiles] = useState(false);
// File upload state
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [deletingFile, setDeletingFile] = useState<string | null>(null);
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
const createFile = useCreateTabloFile();
const deleteFile = useDeleteTabloFile();
const downloadFile = useDownloadTabloFile();
const handleSaveEdit = () => {
if (editData && onEdit) {
// Clear the unused field based on selection
const updatedTablo: TabloUpdate & { id: string } = {
id: editData.id,
name: editData.name,
//TODO: image: creationMode === "image" ? editData.image : null,
color: creationMode === "color" ? selectedColor : null,
status: editData.status,
};
onEdit(updatedTablo);
}
};
const handleSendInvite = () => {
if (inviteEmail.trim()) {
inviteUser({ email: inviteEmail, tablo_id: tablo?.id ?? "" });
}
};
const isEmailValid = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const handleFileSelect = (files: FileList | null) => {
const file = files?.[0];
if (!file) return;
// Validate file size (20MB limit)
const maxSize = 20 * 1024 * 1024; // 20MB in bytes
if (file.size > maxSize) {
setError("Le fichier ne peut pas dépasser 20MB");
return;
}
setError("");
setSelectedFile(file);
};
const handleFileUpload = async () => {
if (!selectedFile || !tablo?.id) return;
setIsUploading(true);
try {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
await createFile.mutateAsync({
tabloId: tablo.id,
fileName: selectedFile.name,
data: {
content,
contentType: selectedFile.type || "application/octet-stream",
},
});
// Reset upload state
setSelectedFile(null);
setIsUploading(false);
} catch (uploadError) {
setIsUploading(false);
console.error("Upload error:", uploadError);
}
};
reader.onerror = () => {
setIsUploading(false);
toast.add(
{
title: "Erreur de lecture",
description: "Impossible de lire le fichier sélectionné",
type: "error",
},
{
timeout: 5000,
}
);
};
// Read file as base64 data URL for binary files, or as text for text files
if (selectedFile.type.startsWith("text/") || selectedFile.type === "application/json") {
reader.readAsText(selectedFile);
} else {
reader.readAsDataURL(selectedFile);
}
} catch (error) {
setIsUploading(false);
console.error("Upload error:", error);
}
};
const cancelFileUpload = () => {
setSelectedFile(null);
};
const handleDeleteFile = async (fileName: string) => {
if (!tablo?.id) return;
// Simple confirmation
if (!window.confirm(`Êtes-vous sûr de vouloir supprimer le fichier "${fileName}" ?`)) {
return;
}
setDeletingFile(fileName);
try {
await deleteFile.mutateAsync({
tabloId: tablo.id,
fileName,
});
} catch (error) {
console.error("Delete error:", error);
} finally {
setDeletingFile(null);
}
};
const handleDownloadFile = async (fileName: string) => {
if (!tablo?.id) return;
setDownloadingFile(fileName);
try {
await downloadFile.mutateAsync({
tabloId: tablo.id,
fileName,
});
} catch (error) {
console.error("Download error:", error);
} finally {
setDownloadingFile(null);
}
};
if (!tablo) return null;
const currentData = editData || tablo;
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && isAdmin) {
handleSaveEdit();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose, handleSaveEdit, isAdmin]);
// Auto-focus name input when editing
const nameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditingName && nameInputRef.current) {
nameInputRef.current.focus();
nameInputRef.current.select();
}
}, [isEditingName]);
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<ClickOutside onClickOutside={onClose}>
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full min-w-[32rem] max-w-2xl max-h-[95vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
<div className="flex items-center space-x-3 flex-1">
{/* Tablo Color/Image Preview */}
<div className="shrink-0">
{tablo.image ? (
<img
src={tablo.image}
alt={tablo.name}
className="w-10 h-10 rounded-lg object-cover"
/>
) : (
<div
className={`w-10 h-10 rounded-lg ${
tablo.color || "bg-blue-500"
} flex items-center justify-center`}
>
<span className="text-white font-bold text-sm">
{tablo.name.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Title */}
<div className="flex-1 min-w-0">
{isAdmin && isEditingName ? (
<ClickOutside onClickOutside={() => setIsEditingName(false)}>
<input
ref={nameInputRef}
type="text"
value={editData?.name}
onChange={(e) =>
setEditData((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
setIsEditingName(false);
}
}}
className="text-xl font-bold text-gray-900 dark:text-white bg-transparent border-b-2 border-blue-500 focus:outline-none focus:border-blue-600 w-full"
placeholder="Nom du tablo"
/>
</ClickOutside>
) : (
<div>
<h2
className={`text-xl font-bold text-gray-900 dark:text-white truncate ${
isAdmin
? "cursor-text hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
: ""
}`}
onClick={isAdmin ? () => setIsEditingName(true) : undefined}
title={tablo.name}
>
{tablo.name}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{isAdmin ? "Administrateur" : "Invité"} {" "}
{currentData.status === "todo" && "À faire"}
{currentData.status === "in_progress" && "En cours"}
{currentData.status === "done" && "Terminé"}
</p>
</div>
)}
</div>
</div>
{/* Close Button */}
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title="Fermer (Échap)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Error Banner */}
{error && (
<div className="mx-6 mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-center space-x-2">
<svg
className="w-5 h-5 text-red-500 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-red-700 dark:text-red-300 text-sm">{error}</span>
<button
onClick={() => setError("")}
className="ml-auto text-red-500 hover:text-red-700 dark:hover:text-red-300"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
)}
{/* Content - Expandable */}
<div className="flex-grow px-6 py-4 overflow-y-auto space-y-6">
{!isAdmin ? (
/* Read-only content */
<div className="space-y-4 mb-4">
{/* Tablo Preview */}
<div className="relative h-48 rounded-lg overflow-hidden">
{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`}
>
<h3 className="text-white font-bold text-2xl text-center px-4">
{tablo.name}
</h3>
</div>
)}
</div>
{/* Status Display */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Statut
</label>
<div className="text-gray-900 dark:text-white">
{currentData.status === "todo" && "À faire"}
{currentData.status === "in_progress" && "En cours"}
{currentData.status === "done" && "Terminé"}
</div>
</div>
{/* Access Level */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Votre rôle
</label>
<div className="text-gray-900 dark:text-white">
{tablo.is_admin ? "Administrateur" : "Invité"}
</div>
</div>
</div>
) : (
/* Editable content */
<>
<ImageColorPicker
creationMode={creationMode}
setCreationMode={setCreationMode}
selectedColor={selectedColor}
setSelectedColor={setSelectedColor}
/>
{/* Details */}
<div className="space-y-4 mb-4">
<div>
<StatusPicker
selectedStatus={currentData.status as StatusType}
setSelectedStatus={(status) =>
setEditData((prev) => (prev ? { ...prev, status } : null))
}
/>
</div>
{/* Invite User Section */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-3">
Inviter un utilisateur
</h3>
<div className="flex space-x-2">
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Email de l'utilisateur à inviter"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
/>
{isInvitingUser ? (
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
</div>
) : (
<button
type="button"
onClick={handleSendInvite}
disabled={!isEmailValid(inviteEmail)}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-md transition-colors"
>
Inviter
</button>
)}
</div>
</div>
</div>
</>
)}
</div>
{/* Files Section */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Fichiers</h3>
{fileData?.fileNames && (
<span className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium px-2 py-1 rounded-full">
{fileData.fileNames.length}
</span>
)}
</div>
<button
type="button"
onClick={() => setShowFiles(!showFiles)}
className="flex items-center space-x-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-white dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<span>{showFiles ? "Masquer" : "Afficher"}</span>
<svg
className={`w-4 h-4 transition-transform ${showFiles ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div>
{showFiles && (
<div className="space-y-4">
{/* File Upload Section - Only for Admins */}
{isAdmin && (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-4">
<div className="flex items-center space-x-2 mb-3">
<svg
className="w-4 h-4 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white">
Ajouter un fichier
</h4>
</div>
{!selectedFile ? (
<div className="space-y-3">
<FileTrigger allowsMultiple={false} onSelect={handleFileSelect}>
<Button
variant="outline"
className="w-full justify-center py-8 border-2 border-dashed border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 bg-gray-50 dark:bg-gray-800/50 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
>
<div className="flex flex-col items-center space-y-2">
<svg
className="w-8 h-8 text-gray-400 dark:text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<div className="text-center">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Cliquez pour sélectionner un fichier
</span>
</div>
</div>
</Button>
</FileTrigger>
</div>
) : (
<div className="space-y-3">
<div className="flex items-center space-x-3 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md">
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<div className="flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-white">
{selectedFile.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{(selectedFile.size / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={handleFileUpload}
disabled={isUploading}
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-green-600 hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center justify-center space-x-2 shadow-sm"
>
{isUploading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
<span>Ajout en cours...</span>
</>
) : (
<>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<span>Ajouter le fichier</span>
</>
)}
</button>
<button
type="button"
onClick={cancelFileUpload}
disabled={isUploading}
className="px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
>
Annuler
</button>
</div>
</div>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
Taille maximale: 2MB
</p>
</div>
)}
{/* File List */}
<div>
{filesLoading ? (
<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>
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
Chargement des fichiers...
</span>
</div>
) : filesError ? (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex items-center space-x-2">
<svg
className="w-5 h-5 text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span className="text-sm text-red-700 dark:text-red-300">
Erreur lors du chargement des fichiers
</span>
</div>
</div>
) : fileData && fileData.fileNames && fileData.fileNames.length > 0 ? (
<div className="space-y-2">
{fileData.fileNames.map((fileName, index) => {
const fileExtension = fileName.split(".").pop()?.toLowerCase() || "";
const isImage = ["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(
fileExtension
);
const isPdf = fileExtension === "pdf";
const isText = ["txt", "md", "json", "csv"].includes(fileExtension);
return (
<div
key={index}
className="flex items-center space-x-3 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-sm transition-shadow group"
>
<button
onClick={() => handleDownloadFile(fileName)}
disabled={downloadingFile === fileName}
className={`w-10 h-10 rounded-lg flex items-center justify-center text-white text-sm font-medium transition-all hover:scale-105 ${
isImage
? "bg-purple-500 hover:bg-purple-600"
: isPdf
? "bg-red-500 hover:bg-red-600"
: isText
? "bg-blue-500 hover:bg-blue-600"
: "bg-gray-500 hover:bg-gray-600"
} ${
downloadingFile === fileName
? "opacity-50 cursor-not-allowed"
: "cursor-pointer"
}`}
title={`Télécharger ${fileName}`}
>
{downloadingFile === fileName ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
) : isImage ? (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
) : isPdf ? (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
/>
</svg>
) : (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
)}
</button>
<div className="flex-1 min-w-0">
<p
className="text-sm font-medium text-gray-900 dark:text-white truncate"
title={fileName}
>
{fileName}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 uppercase">
{fileExtension || "Fichier"}
</p>
</div>
<div className="flex items-center space-x-1">
<Button
size="sm"
variant="ghost"
onClick={() => handleDownloadFile(fileName)}
disabled={downloadingFile === fileName}
className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20 transition-colors"
aria-label={`Télécharger ${fileName}`}
>
{downloadingFile === fileName ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
) : (
<DownloadIcon className="w-4 h-4" />
)}
</Button>
{isAdmin && (
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteFile(fileName)}
disabled={deletingFile === fileName}
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20 transition-colors"
aria-label={`Supprimer ${fileName}`}
>
{deletingFile === fileName ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-red-500"></div>
) : (
<Trash2Icon className="w-4 h-4" />
)}
</Button>
)}
</div>
</div>
);
})}
</div>
) : (
<div className="text-center py-8">
<svg
className="w-12 h-12 text-gray-300 dark:text-gray-600 mx-auto mb-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 5a2 2 0 012-2h2a2 2 0 012 2v0H8v0z"
/>
</svg>
<p className="text-sm text-gray-500 dark:text-gray-400">
Aucun fichier dans ce tablo
</p>
{isAdmin && (
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
Ajoutez votre premier fichier ci-dessus
</p>
)}
</div>
)}
</div>
</div>
)}
</div>
{/* Members Section */}
<div className="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Membres</h3>
{members && (
<span className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium px-2 py-1 rounded-full">
{members.length}
</span>
)}
</div>
<button
type="button"
onClick={() => setShowMembers(!showMembers)}
className="flex items-center space-x-2 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-white dark:hover:bg-gray-800 rounded-lg transition-colors"
>
<span>{showMembers ? "Masquer" : "Afficher"}</span>
<svg
className={`w-4 h-4 transition-transform ${showMembers ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</div>
{showMembers && (
<div className="space-y-2">
{members && members.length > 0 ? (
members.map((member, index) => (
<div
key={index}
className="flex items-center space-x-3 p-2 bg-gray-50 dark:bg-gray-800 rounded-md"
>
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-sm font-medium">
{member.name.charAt(0).toUpperCase()}
</div>
<span className="text-sm text-gray-900 dark:text-white">{member.name}</span>
{member.is_admin ? (
<span className="text-xs text-gray-500 dark:text-gray-400">
{member.id === currentUser?.id ? "(Vous, Admin)" : "(Admin)"}
</span>
) : (
<span className="text-xs text-gray-500 dark:text-gray-400">
{member.id === currentUser?.id ? "(Vous, Invité)" : "(Invité)"}
</span>
)}
</div>
))
) : (
<p className="text-sm text-gray-500 dark:text-gray-400">Aucun membre trouvé</p>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0 bg-gray-50 dark:bg-gray-900/50">
<div className="flex space-x-3 ml-auto">
{!isAdmin ? (
<button
type="button"
className="px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 rounded-lg transition-colors"
onClick={onClose}
>
Fermer
</button>
) : (
<>
<button
type="button"
className="px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600 rounded-lg transition-colors"
onClick={onClose}
>
Annuler
</button>
<button
type="button"
className="px-6 py-2.5 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg shadow-sm transition-colors flex items-center space-x-2"
onClick={handleSaveEdit}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span>Sauvegarder</span>
</button>
</>
)}
</div>
</div>
</div>
</ClickOutside>
</div>
);
};

View file

@ -0,0 +1,257 @@
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { useEffect, useRef, useState } from "react";
import { useInviteUser } from "../hooks/invite";
import { useTabloMembers } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
import { ClickOutside } from "./ClickOutside";
import { ImageColorPicker } from "./ImageColorPicker";
import { StatusPicker } from "./StatusPicker";
type StatusType = "todo" | "in_progress" | "done";
interface TabloSettingsSectionProps {
tablo: UserTablo;
isAdmin: boolean;
onEdit: (updatedTablo: TabloUpdate & { id: string }) => void;
}
export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSectionProps) => {
const currentUser = useUser();
const [editData, setEditData] = useState<UserTablo | null>(tablo);
const [isEditingName, setIsEditingName] = useState(false);
const [creationMode, setCreationMode] = useState<"image" | "color">("color");
const [selectedColor, setSelectedColor] = useState(tablo.color || "bg-blue-500");
const { data: members } = useTabloMembers(tablo.id);
const [inviteEmail, setInviteEmail] = useState("");
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
const nameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setEditData(tablo);
setSelectedColor(tablo.color || "bg-blue-500");
}, [tablo]);
// Auto-focus name input when editing
useEffect(() => {
if (isEditingName && nameInputRef.current) {
nameInputRef.current.focus();
nameInputRef.current.select();
}
}, [isEditingName]);
const handleSaveEdit = () => {
if (editData && onEdit) {
const updatedTablo: TabloUpdate & { id: string } = {
id: editData.id,
name: editData.name,
color: creationMode === "color" ? selectedColor : null,
status: editData.status,
};
onEdit(updatedTablo);
}
};
const handleSendInvite = () => {
if (inviteEmail.trim()) {
inviteUser({ email: inviteEmail, tablo_id: tablo.id });
setInviteEmail("");
}
};
const isEmailValid = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const currentData = editData || tablo;
return (
<div className="space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Paramètres</h1>
<p className="text-muted-foreground mt-1">Configurez votre tablo et gérez les accès</p>
</div>
{isAdmin && (
<Button onClick={handleSaveEdit} className="px-6">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
Sauvegarder les modifications
</Button>
)}
</div>
{!isAdmin ? (
/* Read-only view for non-admins */
<div className="space-y-6">
{/* Tablo Preview */}
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">Aperçu</h3>
<div className="relative h-32 rounded-lg overflow-hidden">
{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`}
>
<h3 className="text-white font-bold text-2xl text-center px-4">{tablo.name}</h3>
</div>
)}
</div>
</div>
{/* Status and Role */}
<div className="bg-card rounded-lg border border-border p-6">
<div className="flex gap-8">
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground mb-4">Statut</h3>
<div className="text-foreground">
{currentData.status === "todo" && "À faire"}
{currentData.status === "in_progress" && "En cours"}
{currentData.status === "done" && "Terminé"}
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-foreground mb-4">Votre rôle</h3>
<div className="text-foreground">
{tablo.is_admin ? "Administrateur" : "Invité"}
</div>
</div>
</div>
</div>
</div>
) : (
/* Editable view for admins */
<div className="space-y-6">
{/* Name Edit */}
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">Nom du tablo</h3>
{isEditingName ? (
<ClickOutside onClickOutside={() => setIsEditingName(false)}>
<input
ref={nameInputRef}
type="text"
value={editData?.name}
onChange={(e) =>
setEditData((prev) => (prev ? { ...prev, name: e.target.value } : null))
}
onKeyDown={(e) => {
if (e.key === "Enter") {
setIsEditingName(false);
}
}}
className="w-full px-3 py-2 text-lg font-medium text-foreground bg-transparent border-b-2 border-primary focus:outline-none focus:border-primary"
placeholder="Nom du tablo"
/>
</ClickOutside>
) : (
<div
className="text-lg font-medium text-foreground cursor-text hover:text-primary transition-colors"
onClick={() => setIsEditingName(true)}
>
{editData?.name}
</div>
)}
</div>
{/* Color/Image Picker */}
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">Apparence</h3>
<ImageColorPicker
creationMode={creationMode}
setCreationMode={setCreationMode}
selectedColor={selectedColor}
setSelectedColor={setSelectedColor}
/>
</div>
{/* Status Picker */}
<div className="bg-card rounded-lg border border-border p-6">
<StatusPicker
selectedStatus={currentData.status as StatusType}
setSelectedStatus={(status) =>
setEditData((prev) => (prev ? { ...prev, status } : null))
}
/>
</div>
{/* Invite User Section */}
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">Inviter un utilisateur</h3>
<div className="flex space-x-2">
<input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Email de l'utilisateur à inviter"
className="flex-1 px-3 py-2 border border-input rounded-md shadow-sm placeholder-muted-foreground focus:outline-none focus:ring-primary focus:border-primary bg-background text-foreground"
/>
{isInvitingUser ? (
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
</div>
) : (
<Button
type="button"
onClick={handleSendInvite}
disabled={!isEmailValid(inviteEmail)}
>
Inviter
</Button>
)}
</div>
</div>
</div>
)}
{/* Members List */}
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
Membres
{members && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
({members.length})
</span>
)}
</h3>
<div className="space-y-2">
{members && members.length > 0 ? (
members.map((member, index) => (
<div key={index} className="flex items-center space-x-3 p-3 bg-muted rounded-lg">
<div className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-primary-foreground text-sm font-medium">
{member.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<span className="text-sm font-medium text-foreground">{member.name}</span>
{member.is_admin ? (
<span className="text-xs text-muted-foreground ml-2">
{member.id === currentUser?.id ? "(Vous, Admin)" : "(Admin)"}
</span>
) : (
<span className="text-xs text-muted-foreground ml-2">
{member.id === currentUser?.id ? "(Vous, Invité)" : "(Invité)"}
</span>
)}
</div>
</div>
))
) : (
<p className="text-sm text-muted-foreground">Aucun membre trouvé</p>
)}
</div>
</div>
</div>
);
};

View file

@ -31,7 +31,7 @@ export const useInviteUser = () => {
export const useJoinTablo = () => {
const api = useAuthedApi();
const { mutate } = useMutation({
const { mutate } = useMutation<{ tablo_id: string }, unknown, { token: string }>({
mutationFn: async ({ token }: { token: string }) => {
const { data } = await api.post("/api/v1/tablos/join", { token });
return data;

View file

@ -4,10 +4,9 @@ import { EventModal } from "../components/EventModal";
import { Layout } from "../components/Layout";
import { ProtectedRoute } from "../components/ProtectedRoute";
import { AvailabilitiesPage } from "../pages/availabilities";
import { BookingsPage } from "../pages/bookings";
import { ChantiersPage } from "../pages/chantiers";
import { ChatPage } from "../pages/chat";
import { EventTypesPage } from "../pages/event-types-page";
import { EventsPage } from "../pages/events";
import { FeedbackPage } from "../pages/feedback";
import { JoinPage } from "../pages/join";
import { LandingPage } from "../pages/landing";
@ -20,6 +19,7 @@ import { ResetPasswordPage } from "../pages/reset-password";
import SettingsPage from "../pages/settings";
import { SignUpPage } from "../pages/signup";
import { TabloPage } from "../pages/tablo";
import { TabloDetailsPage } from "../pages/tablo-details";
import ChatProvider from "../providers/ChatProvider";
export const routes: RouteObject[] = [
@ -28,6 +28,10 @@ export const routes: RouteObject[] = [
path: "/",
element: <ProtectedRoute fallback="/login" />,
children: [
{
path: "tablos/:tabloId",
element: <TabloDetailsPage />,
},
{
path: "",
element: <Layout />,
@ -75,13 +79,8 @@ export const routes: RouteObject[] = [
element: <AvailabilitiesPage />,
},
{
path: "bookings",
element: <BookingsPage />,
children: [{ index: true }, { path: ":tablo_id" }],
},
{
path: "event-types",
element: <EventTypesPage />,
path: "events",
element: <EventsPage />,
},
{
path: "feedback",

View file

@ -380,7 +380,7 @@ export function PublicBookingPage() {
<div className="lg:col-span-1">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 sticky top-8">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-3">
{eventType?.name || "Type d'événement"}
{eventType?.name || "Type d'appel"}
</h2>
{eventType?.description && (

View file

@ -96,6 +96,17 @@ export function AvailabilitiesPage() {
});
};
// Filter exceptions to only show upcoming dates (today and future)
const today = new Date();
today.setHours(0, 0, 0, 0);
const upcomingExceptions = exceptions
.map((exception, originalIndex) => ({ exception, originalIndex }))
.filter(({ exception }) => {
const exceptionDate = new Date(exception.date);
exceptionDate.setHours(0, 0, 0, 0);
return exceptionDate >= today;
});
return (
<div className="min-h-screen">
<header className="bg-card shadow-sm border-b border-border">
@ -253,14 +264,14 @@ export function AvailabilitiesPage() {
Gérez vos exceptions de disponibilité pour des dates spécifiques
</Text>
</div>
{exceptions.length > 0 && (
{upcomingExceptions.length > 0 && (
<Button variant="default" size="lg" onClick={() => setExceptionModalOpen(true)}>
<PlusIcon /> Ajouter une exception
</Button>
)}
</div>
{exceptions.length === 0 ? (
{upcomingExceptions.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>Aucune exception définie</EmptyTitle>
@ -277,9 +288,9 @@ export function AvailabilitiesPage() {
</Empty>
) : (
<div className="grid gap-4">
{exceptions.map((exception, index) => (
{upcomingExceptions.map(({ exception, originalIndex }) => (
<div
key={`${exception.date}-${index}`}
key={`${exception.date}-${originalIndex}`}
className="bg-white dark:bg-gray-700/40 rounded-lg shadow-sm dark:shadow-gray-900/20 p-4 border border-gray-200 dark:border-gray-600/50"
>
<div className="flex justify-between items-start">
@ -328,7 +339,7 @@ export function AvailabilitiesPage() {
size="sm"
onClick={() => {
deleteException(
{ exceptionIndex: index },
{ exceptionIndex: originalIndex },
{
onSuccess: () => {
toast.add({

View file

@ -1,488 +0,0 @@
import { EventDetailsModal } from "@ui/components/EventDetailsModal";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { getTextColorFromTabloColor } from "@xtablo/shared";
import { EventAndTablo } from "@xtablo/shared/types/events.types";
import { Button } from "@xtablo/ui/components/button";
import { ButtonGroup } from "@xtablo/ui/components/button-group";
import { Input } from "@xtablo/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@xtablo/ui/components/select";
import { Strong, Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, SearchIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { useEventsByTablo } from "../hooks/events";
import { useGetAllTabloAccess, useTablosList } from "../hooks/tablos";
type BookingStatus = "all" | "upcoming" | "past";
interface BookingStatusOption {
id: BookingStatus;
name: string;
}
const statusOptions: BookingStatusOption[] = [
{ id: "upcoming", name: "À venir" },
{ id: "past", name: "Passés" },
];
export const BookingsPage = () => {
const { tablo_id } = useParams();
const navigate = useNavigate();
const [selectedTabloId, setSelectedTabloId] = useState<string>(tablo_id || "all");
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<BookingStatus>("upcoming");
const [selectedEvent, setSelectedEvent] = useState<EventAndTablo | null>(null);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Fetch tablos and events
const { data: tablos, isLoading: tablosLoading } = useTablosList();
const { data: events = [], isLoading: eventsLoading } = useEventsByTablo(
selectedTabloId !== "all" ? selectedTabloId : null
);
// Fetch all tablo accesses for permissions
const { data: tabloAccess } = useGetAllTabloAccess();
// Filter and search events
const filteredEvents = useMemo(() => {
if (!events) return [];
let filtered = events;
// Search filter
if (searchTerm) {
filtered = filtered.filter(
(event) =>
event.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
event.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
event.tablo_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Status filter
if (statusFilter !== "all") {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
filtered = filtered.filter((event) => {
if (!event.start_date) return false;
const eventDate = new Date(event.start_date);
eventDate.setHours(0, 0, 0, 0);
switch (statusFilter) {
case "upcoming":
return eventDate >= today;
case "past":
return eventDate < today;
default:
return true;
}
});
}
return filtered.sort((a, b) => {
if (!a.start_date || !b.start_date) return 0;
return new Date(a.start_date).getTime() - new Date(b.start_date).getTime();
});
}, [events, searchTerm, statusFilter]);
// Pagination logic
const totalItems = filteredEvents.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedEvents = filteredEvents.slice(startIndex, endIndex);
// Reset to first page when filters or page size change
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, statusFilter, selectedTabloId, itemsPerPage]);
const formatEventDateTime = (event: EventAndTablo) => {
if (!event.start_date) return "Date non définie";
try {
const date = new Date(event.start_date);
const options: Intl.DateTimeFormatOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
let formatted = date.toLocaleDateString("fr-FR", options);
if (event.start_time) {
// Remove seconds from time (HH:MM:SS -> HH:MM)
const startTime = event.start_time.substring(0, 5);
formatted += ` à ${startTime}`;
if (event.end_time) {
const endTime = event.end_time.substring(0, 5);
formatted += ` - ${endTime}`;
}
}
return formatted;
} catch {
return "Date invalide";
}
};
const getEventStatusBadge = (event: EventAndTablo) => {
if (!event.start_date) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const eventDate = new Date(event.start_date);
eventDate.setHours(0, 0, 0, 0);
if (eventDate.getTime() === today.getTime()) {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary">
Aujourd&apos;hui
</span>
);
} else if (eventDate > today) {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary text-secondary-foreground">
À venir
</span>
);
} else {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground">
Passé
</span>
);
}
};
const handleCreateEvent = () => {
const today = new Date();
const dateParam = today.toISOString();
const tabloParam = selectedTabloId !== "all" ? `&tablo_id=${selectedTabloId}` : "";
navigate(`/planning/create?date=${dateParam}${tabloParam}`);
};
// Check if an event can be edited (admin access required)
const canEditEvent = (event: EventAndTablo) => {
return tabloAccess?.find((access) => access.tablo_id === event.tablo_id && access.is_admin)
? true
: false;
};
const handleEditEvent = (event: EventAndTablo) => {
if (event.event_id && event.tablo_id && canEditEvent(event)) {
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
}
};
const handleViewEvent = (event: EventAndTablo) => {
setSelectedEvent(event);
setIsDetailsModalOpen(true);
};
return (
<div className="min-h-screen">
{/* Header */}
<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 items-center justify-between">
<div>
<TypographyH3>Réservations</TypographyH3>
<TypographyMuted>Gérez vos événements et réservations</TypographyMuted>
</div>
<div className="flex items-center space-x-3">
<Button onClick={handleCreateEvent}>
<CalendarIcon className="w-4 h-4 mr-2" />
Nouvel événement
</Button>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Filters */}
<div className="bg-card rounded-lg shadow-sm border border-border p-6 mb-6">
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center">
{/* Search */}
<div className="flex-1 w-full">
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
type="text"
placeholder="Rechercher un événement..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-10"
/>
</div>
</div>
{/* Tablo Filter */}
<div className="w-full lg:w-64">
<Select value={selectedTabloId} onValueChange={(value) => setSelectedTabloId(value)}>
<SelectTrigger className="w-full h-10" aria-label="Filtrer par tableau">
<SelectValue placeholder="Tous les tableaux" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les tableaux</SelectItem>
{tablos?.map((tablo) => (
<SelectItem key={tablo.id} value={tablo.id}>
<div className="flex items-center gap-2">
<div
className={twMerge(
"w-2 h-2 rounded-full",
tablo.color || "bg-muted-foreground"
)}
/>
{tablo.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Status Filter */}
<ButtonGroup orientation="horizontal">
{statusOptions.map((option) => (
<Button
key={option.id}
variant={statusFilter === option.id ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter(option.id as BookingStatus)}
className="rounded-full"
>
{option.name}
</Button>
))}
</ButtonGroup>
</div>
</div>
{/* Events List */}
<div className="bg-card rounded-lg shadow-sm border border-border">
{tablosLoading || eventsLoading ? (
<div className="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
) : paginatedEvents.length === 0 ? (
<div className="p-12 text-center">
<CalendarIcon className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-2 text-sm font-medium text-foreground">Aucun événement trouvé</h3>
<p className="mt-1 text-sm text-muted-foreground">
{searchTerm || statusFilter !== "all"
? "Essayez de modifier vos filtres de recherche."
: "Commencez par créer votre premier événement."}
</p>
</div>
) : (
<div className="divide-y divide-border">
{paginatedEvents.map((event) => (
<div
key={event.event_id}
className="p-6 hover:bg-muted transition-colors cursor-pointer"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-3 mb-2">
<Strong className="text-lg text-foreground truncate">
{event.title || "Événement sans titre"}
</Strong>
{getEventStatusBadge(event)}
</div>
<div className="flex items-center space-x-4 text-sm text-muted-foreground mb-2">
<span className="flex items-center">
<CalendarIcon className="w-4 h-4 mr-1" />
{formatEventDateTime(event)}
</span>
{event.tablo_name && (
<span
className={twMerge(
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium",
event.tablo_color,
getTextColorFromTabloColor(event.tablo_color)
)}
>
{event.tablo_name}
</span>
)}
</div>
{event.description && (
<Text className="text-muted-foreground line-clamp-2">
{event.description}
</Text>
)}
</div>
<div className="flex items-center space-x-2 ml-4">
<Button variant="outline" size="sm" onClick={() => handleViewEvent(event)}>
Détails
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Pagination Controls */}
{totalItems > 0 && (
<div className="bg-card rounded-lg shadow-sm border border-border mt-4 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
<span>
Affichage de {startIndex + 1} à {Math.min(endIndex, totalItems)} sur {totalItems}{" "}
événements
</span>
<div className="flex items-center space-x-2">
<span className="whitespace-nowrap">Éléments par page:</span>
<Select
value={itemsPerPage.toString()}
onValueChange={(value) => setItemsPerPage(Number(value))}
>
<SelectTrigger className="min-w-16 h-8" aria-label="Nombre d'éléments par page">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{totalPages > 1 && (
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="w-4 h-4" />
Précédent
</Button>
<div className="flex items-center space-x-1">
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((page) => {
// Show first page, last page, current page, and pages around current
return (
page === 1 || page === totalPages || Math.abs(page - currentPage) <= 1
);
})
.map((page, index, array) => {
// Add ellipsis if there's a gap
const prevPage = array[index - 1];
const showEllipsis = prevPage && page - prevPage > 1;
return (
<div key={page} className="flex items-center">
{showEllipsis && (
<span className="px-2 text-muted-foreground">...</span>
)}
<Button
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(page)}
className={
currentPage === page
? "bg-emerald-700 text-white hover:bg-emerald-600"
: ""
}
>
{page}
</Button>
</div>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
>
Suivant
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
</div>
)}
{/* Stats Summary */}
{filteredEvents.length > 0 && (
<div className="mt-6 bg-card rounded-lg shadow-sm border border-border p-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-foreground">{filteredEvents.length}</div>
<div className="text-sm text-muted-foreground">Événements trouvés</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-foreground">
{
filteredEvents.filter((e) => {
if (!e.start_date) return false;
const eventDate = new Date(e.start_date);
return eventDate >= new Date();
}).length
}
</div>
<div className="text-sm text-muted-foreground">À venir</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-primary">
{
filteredEvents.filter((e) => {
if (!e.start_date) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
const eventDate = new Date(e.start_date);
eventDate.setHours(0, 0, 0, 0);
return eventDate.getTime() === today.getTime();
}).length
}
</div>
<div className="text-sm text-muted-foreground">Aujourd&apos;hui</div>
</div>
</div>
</div>
)}
{/* Event Details Modal */}
<EventDetailsModal
event={selectedEvent}
isOpen={isDetailsModalOpen}
onClose={() => {
setIsDetailsModalOpen(false);
setSelectedEvent(null);
}}
onEdit={() => selectedEvent && handleEditEvent(selectedEvent)}
canEdit={selectedEvent ? canEditEvent(selectedEvent) : false}
/>
</main>
</div>
);
};

View file

@ -1,119 +0,0 @@
import { EventTypeModal } from "@ui/components/EventTypeModal";
import { toast } from "@xtablo/shared";
import { Button } from "@xtablo/ui/components/button";
import { Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import { PlusIcon } from "lucide-react";
import { useState } from "react";
import { EventTypeCard } from "../components/EventTypeCard";
import { EventTypeConfig, useEventTypes } from "../hooks/event-types";
export function EventTypesPage() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEventType, setEditingEventType] = useState<
(EventTypeConfig & { id: string }) | null
>(null);
const [formData, setFormData] = useState<Partial<EventTypeConfig> & { isActive?: boolean }>({
name: "",
description: "",
duration: 60,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
const { eventTypes: eventTypesData, addEventType, updateEventType } = useEventTypes();
const handleCreateEventType = () => {
setEditingEventType(null);
setFormData({
name: "",
description: "",
duration: 60,
isActive: true,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
setIsModalOpen(true);
};
const handleEditEventType = (id: string, eventType: EventTypeConfig) => {
setEditingEventType({ id, ...eventType });
setFormData(eventType as EventTypeConfig);
setIsModalOpen(true);
};
const handleSaveEventType = () => {
if (!formData.name) {
toast.add({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
type: "error",
});
return;
}
if (editingEventType) {
updateEventType({
id: editingEventType.id,
eventType: formData as EventTypeConfig,
});
} else {
addEventType({ eventType: formData as EventTypeConfig });
}
setIsModalOpen(false);
setEditingEventType(null);
};
return (
<div className="min-h-screen">
<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 items-center justify-between">
<div>
<TypographyH3>Types d&apos;événements</TypographyH3>
<TypographyMuted>
Configurez les différents types d&apos;événements que vous proposez
</TypographyMuted>
</div>
<Button size="lg" variant="default" onClick={handleCreateEventType}>
<PlusIcon className="w-4 h-4 mr-2" /> Nouveau type
</Button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{eventTypesData?.map((eventType) => (
<EventTypeCard
key={eventType.id}
eventType={eventType}
handleEditEventType={handleEditEventType}
/>
))}
</div>
{eventTypesData?.length === 0 && (
<div className="text-center py-12">
<Text className="text-muted-foreground mb-4">
Aucun type d&apos;événement configuré
</Text>
<Button variant="default" size="lg" onClick={handleCreateEventType}>
<PlusIcon className="w-4 h-4 mr-2" /> Créer votre premier type
</Button>
</div>
)}
</main>
<EventTypeModal
isModalOpen={isModalOpen}
setIsModalOpen={setIsModalOpen}
editingEventType={editingEventType}
formData={formData as EventTypeConfig}
setFormData={setFormData}
handleSaveEventType={handleSaveEventType}
/>
</div>
);
}

View file

@ -0,0 +1,619 @@
import { EventDetailsModal } from "@ui/components/EventDetailsModal";
import { EventTypeModal } from "@ui/components/EventTypeModal";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { getTextColorFromTabloColor, toast } from "@xtablo/shared";
import { EventAndTablo } from "@xtablo/shared/types/events.types";
import { Button } from "@xtablo/ui/components/button";
import { ButtonGroup } from "@xtablo/ui/components/button-group";
import { Input } from "@xtablo/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@xtablo/ui/components/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@xtablo/ui/components/tabs";
import { Strong, Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography";
import {
Calendar as CalendarIcon,
ChevronLeft,
ChevronRight,
PlusIcon,
SearchIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { EventTypeCard } from "../components/EventTypeCard";
import { EventTypeConfig, useEventTypes } from "../hooks/event-types";
import { useEventsByTablo } from "../hooks/events";
import { useGetAllTabloAccess, useTablosList } from "../hooks/tablos";
type BookingStatus = "all" | "upcoming" | "past";
interface BookingStatusOption {
id: BookingStatus;
name: string;
}
const statusOptions: BookingStatusOption[] = [
{ id: "upcoming", name: "À venir" },
{ id: "past", name: "Passés" },
];
export function EventsPage() {
const navigate = useNavigate();
// Events/Bookings state
const [selectedTabloId, setSelectedTabloId] = useState<string>("all");
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<BookingStatus>("upcoming");
const [selectedEvent, setSelectedEvent] = useState<EventAndTablo | null>(null);
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Event Types state
const [isEventTypeModalOpen, setIsEventTypeModalOpen] = useState(false);
const [editingEventType, setEditingEventType] = useState<
(EventTypeConfig & { id: string }) | null
>(null);
const [eventTypeFormData, setEventTypeFormData] = useState<
Partial<EventTypeConfig> & { isActive?: boolean }
>({
name: "",
description: "",
duration: 60,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
// Fetch data
const { data: tablos, isLoading: tablosLoading } = useTablosList();
const { data: events = [], isLoading: eventsLoading } = useEventsByTablo(
selectedTabloId !== "all" ? selectedTabloId : null
);
const { data: tabloAccess } = useGetAllTabloAccess();
const { eventTypes: eventTypesData, addEventType, updateEventType } = useEventTypes();
// Filter and search events
const filteredEvents = useMemo(() => {
if (!events) return [];
let filtered = events;
// Search filter
if (searchTerm) {
filtered = filtered.filter(
(event) =>
event.title?.toLowerCase().includes(searchTerm.toLowerCase()) ||
event.description?.toLowerCase().includes(searchTerm.toLowerCase()) ||
event.tablo_name?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Status filter
if (statusFilter !== "all") {
const today = new Date();
today.setHours(0, 0, 0, 0);
filtered = filtered.filter((event) => {
if (!event.start_date) return false;
const eventDate = new Date(event.start_date);
eventDate.setHours(0, 0, 0, 0);
switch (statusFilter) {
case "upcoming":
return eventDate >= today;
case "past":
return eventDate < today;
default:
return true;
}
});
}
return filtered.sort((a, b) => {
if (!a.start_date || !b.start_date) return 0;
return new Date(a.start_date).getTime() - new Date(b.start_date).getTime();
});
}, [events, searchTerm, statusFilter]);
// Pagination logic
const totalItems = filteredEvents.length;
const totalPages = Math.ceil(totalItems / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedEvents = filteredEvents.slice(startIndex, endIndex);
// Reset to first page when filters or page size change
useEffect(() => {
setCurrentPage(1);
}, [searchTerm, statusFilter, selectedTabloId, itemsPerPage]);
// Event Types handlers
const handleCreateEventType = () => {
setEditingEventType(null);
setEventTypeFormData({
name: "",
description: "",
duration: 60,
isActive: true,
bufferTime: 15,
maxBookingsPerDay: 8,
requiresApproval: false,
});
setIsEventTypeModalOpen(true);
};
const handleEditEventType = (id: string, eventType: EventTypeConfig) => {
setEditingEventType({ id, ...eventType });
setEventTypeFormData(eventType as EventTypeConfig);
setIsEventTypeModalOpen(true);
};
const handleSaveEventType = () => {
if (!eventTypeFormData.name) {
toast.add({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
type: "error",
});
return;
}
if (editingEventType) {
updateEventType({
id: editingEventType.id,
eventType: eventTypeFormData as EventTypeConfig,
});
} else {
addEventType({ eventType: eventTypeFormData as EventTypeConfig });
}
setIsEventTypeModalOpen(false);
setEditingEventType(null);
};
// Events handlers
const formatEventDateTime = (event: EventAndTablo) => {
if (!event.start_date) return "Date non définie";
try {
const date = new Date(event.start_date);
const options: Intl.DateTimeFormatOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
let formatted = date.toLocaleDateString("fr-FR", options);
if (event.start_time) {
const startTime = event.start_time.substring(0, 5);
formatted += ` à ${startTime}`;
if (event.end_time) {
const endTime = event.end_time.substring(0, 5);
formatted += ` - ${endTime}`;
}
}
return formatted;
} catch {
return "Date invalide";
}
};
const getEventStatusBadge = (event: EventAndTablo) => {
if (!event.start_date) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const eventDate = new Date(event.start_date);
eventDate.setHours(0, 0, 0, 0);
if (eventDate.getTime() === today.getTime()) {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary/10 text-primary">
Aujourd&apos;hui
</span>
);
} else if (eventDate > today) {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary text-secondary-foreground">
À venir
</span>
);
} else {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-muted-foreground">
Passé
</span>
);
}
};
const handleCreateEvent = () => {
const today = new Date();
const dateParam = today.toISOString();
const tabloParam = selectedTabloId !== "all" ? `&tablo_id=${selectedTabloId}` : "";
navigate(`/planning/create?date=${dateParam}${tabloParam}`);
};
const canEditEvent = (event: EventAndTablo) => {
return tabloAccess?.find((access) => access.tablo_id === event.tablo_id && access.is_admin)
? true
: false;
};
const handleEditEvent = (event: EventAndTablo) => {
if (event.event_id && event.tablo_id && canEditEvent(event)) {
navigate(`/planning/${event.tablo_id}/events/${event.event_id}/edit`);
}
};
const handleViewEvent = (event: EventAndTablo) => {
setSelectedEvent(event);
setIsDetailsModalOpen(true);
};
return (
<div className="min-h-screen">
{/* Header */}
<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>
<TypographyH3>Mes Événements</TypographyH3>
<TypographyMuted>
Gérez vos événements, réservations et types d&apos;appels
</TypographyMuted>
</div>
</div>
</header>
{/* Main Content with Tabs */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Tabs defaultValue="events" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="events">Événements</TabsTrigger>
<TabsTrigger value="event-types">Types d&apos;appels</TabsTrigger>
</TabsList>
{/* Events Tab */}
<TabsContent value="events" className="space-y-6">
<div className="flex items-center justify-end mb-4">
<Button onClick={handleCreateEvent}>
<CalendarIcon className="w-4 h-4 mr-2" />
Nouvel événement
</Button>
</div>
{/* Filters */}
<div className="bg-card rounded-lg shadow-sm border border-border p-6">
<div className="flex flex-col lg:flex-row gap-4 items-start lg:items-center">
{/* Search */}
<div className="flex-1 w-full">
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground w-4 h-4" />
<Input
type="text"
placeholder="Rechercher un événement..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 h-10"
/>
</div>
</div>
{/* Tablo Filter */}
<div className="w-full lg:w-64">
<Select
value={selectedTabloId}
onValueChange={(value) => setSelectedTabloId(value)}
>
<SelectTrigger className="w-full h-10" aria-label="Filtrer par tableau">
<SelectValue placeholder="Tous les tableaux" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Tous les tableaux</SelectItem>
{tablos?.map((tablo) => (
<SelectItem key={tablo.id} value={tablo.id}>
<div className="flex items-center gap-2">
<div
className={twMerge(
"w-2 h-2 rounded-full",
tablo.color || "bg-muted-foreground"
)}
/>
{tablo.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Status Filter */}
<ButtonGroup orientation="horizontal">
{statusOptions.map((option) => (
<Button
key={option.id}
variant={statusFilter === option.id ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter(option.id as BookingStatus)}
className="rounded-full"
>
{option.name}
</Button>
))}
</ButtonGroup>
</div>
</div>
{/* Events List */}
<div className="bg-card rounded-lg shadow-sm border border-border">
{tablosLoading || eventsLoading ? (
<div className="flex items-center justify-center h-64">
<LoadingSpinner />
</div>
) : paginatedEvents.length === 0 ? (
<div className="p-12 text-center">
<CalendarIcon className="mx-auto h-12 w-12 text-muted-foreground" />
<h3 className="mt-2 text-sm font-medium text-foreground">
Aucun événement trouvé
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{searchTerm || statusFilter !== "all"
? "Essayez de modifier vos filtres de recherche."
: "Commencez par créer votre premier événement."}
</p>
</div>
) : (
<div className="divide-y divide-border">
{paginatedEvents.map((event) => (
<div
key={event.event_id}
className="p-6 hover:bg-muted transition-colors cursor-pointer"
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-3 mb-2">
<Strong className="text-lg text-foreground truncate">
{event.title || "Événement sans titre"}
</Strong>
{getEventStatusBadge(event)}
</div>
<div className="flex items-center space-x-4 text-sm text-muted-foreground mb-2">
<span className="flex items-center">
<CalendarIcon className="w-4 h-4 mr-1" />
{formatEventDateTime(event)}
</span>
{event.tablo_name && (
<span
className={twMerge(
"inline-flex items-center px-2 py-1 rounded-md text-xs font-medium",
event.tablo_color,
getTextColorFromTabloColor(event.tablo_color)
)}
>
{event.tablo_name}
</span>
)}
</div>
{event.description && (
<Text className="text-muted-foreground line-clamp-2">
{event.description}
</Text>
)}
</div>
<div className="flex items-center space-x-2 ml-4">
<Button
variant="outline"
size="sm"
onClick={() => handleViewEvent(event)}
>
Détails
</Button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Pagination Controls */}
{totalItems > 0 && (
<div className="bg-card rounded-lg shadow-sm border border-border px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
<span>
Affichage de {startIndex + 1} à {Math.min(endIndex, totalItems)} sur{" "}
{totalItems} événements
</span>
<div className="flex items-center space-x-2">
<span className="whitespace-nowrap">Éléments par page:</span>
<Select
value={itemsPerPage.toString()}
onValueChange={(value) => setItemsPerPage(Number(value))}
>
<SelectTrigger
className="min-w-16 h-8"
aria-label="Nombre d'éléments par page"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{totalPages > 1 && (
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
>
<ChevronLeft className="w-4 h-4" />
Précédent
</Button>
<div className="flex items-center space-x-1">
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((page) => {
return (
page === 1 || page === totalPages || Math.abs(page - currentPage) <= 1
);
})
.map((page, index, array) => {
const prevPage = array[index - 1];
const showEllipsis = prevPage && page - prevPage > 1;
return (
<div key={page} className="flex items-center">
{showEllipsis && (
<span className="px-2 text-muted-foreground">...</span>
)}
<Button
variant={currentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(page)}
className={
currentPage === page
? "bg-emerald-700 text-white hover:bg-emerald-600"
: ""
}
>
{page}
</Button>
</div>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
>
Suivant
<ChevronRight className="w-4 h-4" />
</Button>
</div>
)}
</div>
</div>
)}
{/* Stats Summary */}
{filteredEvents.length > 0 && (
<div className="bg-card rounded-lg shadow-sm border border-border p-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-foreground">
{filteredEvents.length}
</div>
<div className="text-sm text-muted-foreground">Événements trouvés</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-foreground">
{
filteredEvents.filter((e) => {
if (!e.start_date) return false;
const eventDate = new Date(e.start_date);
return eventDate >= new Date();
}).length
}
</div>
<div className="text-sm text-muted-foreground">À venir</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-primary">
{
filteredEvents.filter((e) => {
if (!e.start_date) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
const eventDate = new Date(e.start_date);
eventDate.setHours(0, 0, 0, 0);
return eventDate.getTime() === today.getTime();
}).length
}
</div>
<div className="text-sm text-muted-foreground">Aujourd&apos;hui</div>
</div>
</div>
</div>
)}
</TabsContent>
{/* Event Types Tab */}
<TabsContent value="event-types" className="space-y-6">
<div className="flex items-center justify-end mb-4">
<Button size="lg" variant="default" onClick={handleCreateEventType}>
<PlusIcon className="w-4 h-4 mr-2" /> Nouveau type
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{eventTypesData?.map((eventType) => (
<EventTypeCard
key={eventType.id}
eventType={eventType}
handleEditEventType={handleEditEventType}
/>
))}
</div>
{eventTypesData?.length === 0 && (
<div className="text-center py-12 bg-card rounded-lg shadow-sm border border-border">
<Text className="text-muted-foreground mb-4">
Aucun type d&apos;appel configuré
</Text>
<Button variant="default" size="lg" onClick={handleCreateEventType}>
<PlusIcon className="w-4 h-4 mr-2" /> Créer votre premier type
</Button>
</div>
)}
</TabsContent>
</Tabs>
{/* Event Details Modal */}
<EventDetailsModal
event={selectedEvent}
isOpen={isDetailsModalOpen}
onClose={() => {
setIsDetailsModalOpen(false);
setSelectedEvent(null);
}}
onEdit={() => selectedEvent && handleEditEvent(selectedEvent)}
canEdit={selectedEvent ? canEditEvent(selectedEvent) : false}
/>
{/* Event Type Modal */}
<EventTypeModal
isModalOpen={isEventTypeModalOpen}
setIsModalOpen={setIsEventTypeModalOpen}
editingEventType={editingEventType}
formData={eventTypeFormData as EventTypeConfig}
setFormData={setEventTypeFormData}
handleSaveEventType={handleSaveEventType}
/>
</main>
</div>
);
}

View file

@ -53,14 +53,15 @@ export const JoinPage = () => {
joinTablo(
{ token },
{
onSuccess: () => {
navigate("/");
onSuccess: ({ tablo_id }) => {
navigate(`/tablos/${tablo_id}`);
},
onError: (error) => {
console.error(error);
toast.add(
{
title: "Erreur",
description: error.message,
description: "Une erreur est survenue.",
type: "error",
},
{ timeout: 2000 }

View file

@ -0,0 +1,180 @@
import { toast } from "@xtablo/shared";
import { TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { ArrowLeft, FileText, MessageSquare, Settings } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { LoadingSpinner } from "../components/LoadingSpinner";
import { TabloDiscussionSection } from "../components/TabloDiscussionSection";
import { TabloFilesSection } from "../components/TabloFilesSection";
import { TabloSettingsSection } from "../components/TabloSettingsSection";
import { useTablosList, useUpdateTablo } from "../hooks/tablos";
type TabSection = "files" | "discussion" | "settings";
export const TabloDetailsPage = () => {
const { tabloId } = useParams<{ tabloId: string }>();
const navigate = useNavigate();
const { data: tablos, isLoading } = useTablosList();
const { mutateAsync: updateTablo } = useUpdateTablo();
const [activeSection, setActiveSection] = useState<TabSection>("files");
const [tablo, setTablo] = useState<UserTablo | null>(null);
useEffect(() => {
if (tablos && tabloId) {
const foundTablo = tablos.find((t) => t.id === tabloId);
if (foundTablo) {
setTablo(foundTablo);
} else {
// Tablo not found, redirect back
toast.add(
{
title: "Tablo introuvable",
description: "Le tablo demandé n'existe pas ou vous n'y avez pas accès",
type: "error",
},
{ timeout: 5000 }
);
navigate("/tablo");
}
}
}, [tablos, tabloId, navigate]);
const handleEdit = async (updatedTablo: TabloUpdate & { id: string }) => {
try {
await updateTablo(updatedTablo);
toast.add(
{
title: "Tablo mis à jour",
description: "Les modifications ont été enregistrées",
type: "success",
},
{ timeout: 3000 }
);
} catch (_error) {
toast.add(
{
title: "Erreur",
description: "Impossible de mettre à jour le tablo",
type: "error",
},
{ timeout: 5000 }
);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<LoadingSpinner />
</div>
);
}
if (!tablo) {
return null;
}
const isAdmin = tablo.is_admin;
const navigationItems: Array<{
id: TabSection;
label: string;
icon: React.ReactNode;
}> = [
{
id: "files",
label: "Fichiers",
icon: <FileText className="w-5 h-5" />,
},
{
id: "discussion",
label: "Discussion",
icon: <MessageSquare className="w-5 h-5" />,
},
{
id: "settings",
label: "Paramètres",
icon: <Settings className="w-5 h-5" />,
},
];
return (
<div className="flex h-screen bg-background">
{/* Left Sidebar Navigation */}
<aside className="w-64 border-r border-border bg-card flex flex-col">
{/* Header with back button */}
<div className="p-4 border-b border-border">
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/tablo")}
className="mb-4 w-full justify-start"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Retour aux tablos
</Button>
{/* Tablo preview */}
<div className="flex items-center space-x-3">
{tablo.image ? (
<img
src={tablo.image}
alt={tablo.name}
className="w-12 h-12 rounded-lg object-cover"
/>
) : (
<div
className={`w-12 h-12 rounded-lg ${
tablo.color || "bg-blue-500"
} flex items-center justify-center`}
>
<span className="text-white font-bold text-sm">
{tablo.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div className="flex-1 min-w-0">
<h2 className="text-lg font-bold text-foreground truncate">{tablo.name}</h2>
<p className="text-xs text-muted-foreground">
{isAdmin ? "Administrateur" : "Invité"}
</p>
</div>
</div>
</div>
{/* Navigation items */}
<nav className="flex-1 p-4 space-y-1">
{navigationItems.map((item) => (
<button
key={item.id}
onClick={() => setActiveSection(item.id)}
className={`w-full flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
activeSection === item.id
? "bg-primary text-primary-foreground"
: "text-foreground hover:bg-muted"
}`}
>
{item.icon}
<span className="font-medium">{item.label}</span>
</button>
))}
</nav>
</aside>
{/* Main Content Area */}
<main className="flex-1 overflow-auto">
<div className="max-w-5xl mx-auto p-6 h-full">
{activeSection === "files" && <TabloFilesSection tablo={tablo} isAdmin={isAdmin} />}
{activeSection === "discussion" && (
<TabloDiscussionSection tablo={tablo} isAdmin={isAdmin} />
)}
{activeSection === "settings" && (
<TabloSettingsSection tablo={tablo} isAdmin={isAdmin} onEdit={handleEdit} />
)}
</div>
</main>
</div>
);
};

View file

@ -1,9 +1,7 @@
import { CreateTabloModal } from "@ui/components/CreateTabloModal";
import { DeleteTabloModal } from "@ui/components/DeleteTabloModal";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { TabloModal } from "@ui/components/TabloModal";
import { TabloTutorial } from "@ui/components/TabloTutorial";
import { TabloInsert, TabloUpdate, UserTablo } from "@xtablo/shared/types/tablos.types";
import { TabloInsert, UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import {
Empty,
@ -25,7 +23,6 @@ import {
CheckCircle2,
Clock,
Eye,
HelpCircle,
LayoutGrid,
List,
ListTodo,
@ -34,7 +31,7 @@ import {
Trash2,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useCreateTablo, useDeleteTablo, useTablosList, useUpdateTablo } from "../hooks/tablos";
@ -57,12 +54,9 @@ export const TabloPage = () => {
y: number;
} | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [viewingTablo, setViewingTablo] = useState<UserTablo | null>(null);
const [deletingTablo, setDeletingTablo] = useState<UserTablo | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [filterType, setFilterType] = useState<"all" | "todo" | "in_progress" | "done">("all");
const [isTutorialOpen, setIsTutorialOpen] = useState(false);
const [hasInteractedWithTutorial, setHasInteractedWithTutorial] = useState(false);
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@ -81,20 +75,6 @@ export const TabloPage = () => {
const { mutateAsync: updateTablo } = useUpdateTablo();
const { mutateAsync: deleteTablo } = useDeleteTablo();
// Check if tutorial should be shown
useEffect(() => {
const tutorialCompleted = localStorage.getItem("xtablo-tutorial-completed");
const tutorialInteracted = localStorage.getItem("xtablo-tutorial-interacted");
// Show tutorial if user hasn't completed it and has no tablos
if (!tutorialCompleted && !isLoading && tablos && tablos.length === 0) {
setIsTutorialOpen(true);
}
// Check if user has interacted with tutorial before
setHasInteractedWithTutorial(!!tutorialInteracted);
}, [tablos, isLoading]);
// Filter tablos based on status
const filteredTablos = tablos?.filter((tablo) => {
if (filterType === "todo") {
@ -143,15 +123,7 @@ export const TabloPage = () => {
};
const openTablo = (tabloId: string) => {
if (!tablos) return;
const tablo = tablos.find((t) => t.id === tabloId);
if (tablo) {
setViewingTablo(tablo);
}
};
const closeTabloModal = () => {
setViewingTablo(null);
navigate(`/tablos/${tabloId}`);
};
const getStatusLabel = (status: string) => {
@ -193,14 +165,6 @@ export const TabloPage = () => {
}
};
const onEditTablo = (tablo: TabloUpdate & { id: string }) => {
updateTablo(tablo, {
onSuccess: () => {
closeTabloModal();
},
});
};
const handleDeleteTablo = (tabloId: string) => {
if (!tablos) return;
const tablo = tablos.find((t) => t.id === tabloId);
@ -226,20 +190,6 @@ export const TabloPage = () => {
setIsDeleting(false);
};
const handleCloseTutorial = () => {
setIsTutorialOpen(false);
};
const handleOpenTutorial = () => {
setIsTutorialOpen(true);
setHasInteractedWithTutorial(true);
localStorage.setItem("xtablo-tutorial-interacted", "true");
};
const handleTutorialCreateTablo = () => {
setIsCreateModalOpen(true);
};
const getUserRole = (tablo: UserTablo) => {
return tablo.is_admin ? "Admin" : "Invité";
};
@ -689,29 +639,6 @@ export const TabloPage = () => {
<TypographyMuted>Gérez vos projets et collaborations</TypographyMuted>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{!hasInteractedWithTutorial && (
<div className="flex items-center gap-1 px-3 py-1 bg-primary/10 rounded-full border border-primary/20 animate-pulse shadow-lg">
<span className="text-sm font-medium text-primary">Avant de commencer</span>
</div>
)}
<Button
variant="ghost"
size="sm"
className={`p-2 ${
!hasInteractedWithTutorial
? "animate-pulse bg-primary/10 rounded-lg shadow-lg ring-2 ring-primary/20"
: ""
}`}
onClick={handleOpenTutorial}
title="Aide - Revoir le guide"
>
<HelpCircle
className={`w-5 h-5 ${!hasInteractedWithTutorial ? "text-primary" : ""}`}
/>
</Button>
</div>
{/* Filter Controls */}
<div className="flex items-center gap-2">
<Select
@ -921,11 +848,6 @@ export const TabloPage = () => {
<CreateTabloModal onClose={closeCreateModal} onCreate={createNewTablo} />
)}
{/* Tablo Details Modal */}
{!!viewingTablo && (
<TabloModal tablo={viewingTablo} onEdit={onEditTablo} onClose={closeTabloModal} />
)}
{/* Delete Tablo Modal */}
{!!deletingTablo && (
<DeleteTabloModal
@ -936,12 +858,12 @@ export const TabloPage = () => {
/>
)}
{/* Tutorial */}
<TabloTutorial
{/* Tutorial - Hidden */}
{/* <TabloTutorial
isOpen={isTutorialOpen}
onClose={handleCloseTutorial}
onCreateTablo={handleTutorialCreateTablo}
/>
/> */}
</div>
);
};

File diff suppressed because one or more lines are too long

View file

@ -18,7 +18,7 @@ export default defineConfig(({ mode }) => {
// Only include cloudflare plugin when not in test mode
if (mode !== "test" && process.env.VITEST !== "true") {
plugins.push(cloudflare());
plugins.push(cloudflare({ inspectorPort: 9230 }));
}
return {

View file

@ -61,6 +61,9 @@ importers:
'@biomejs/biome':
specifier: 2.2.5
version: 2.2.5
'@cloudflare/vite-plugin':
specifier: ^1.9.4
version: 1.13.14(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2))(workerd@1.20251011.0)(wrangler@4.44.0)
'@tailwindcss/vite':
specifier: ^4.0.14
version: 4.1.15(vite@6.4.1(@types/node@22.18.12)(jiti@2.6.1)(lightningcss@1.30.2))