Add drive

This commit is contained in:
Arthur Belleville 2025-10-09 22:33:16 +02:00
parent 5d9eae3ef7
commit c19522058a
No known key found for this signature in database
15 changed files with 1411 additions and 146 deletions

View file

@ -1 +0,0 @@
*/5 * * * * syncCalendars

View file

@ -1,5 +1,11 @@
import type { EventAndTablo } from "./types.ts";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import {
GetObjectCommand,
ListObjectsCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import type { SupabaseClient } from "@supabase/supabase-js";
export const generateICSFromEvents = (
@ -106,3 +112,60 @@ export const writeCalendarFileToR2 = async (
})
);
};
export const getTabloFileNames = async (
s3_client: S3Client,
tabloId: string
) => {
const bucketName = "tablo-data";
const { Contents } = await s3_client.send(
new ListObjectsV2Command({
Bucket: bucketName,
Prefix: tabloId,
})
);
return Contents?.map((content) => content.Key?.split("/")[1]).filter(
(content) => content?.length && content.length > 0
);
};
export const isTabloMember = async (
supabase: SupabaseClient,
tabloId: string,
userId: string
) => {
const { data: tabloAccess, error: isMemberError } = await supabase
.from("tablo_access")
.select("*")
.eq("tablo_id", tabloId)
.eq("user_id", userId)
.eq("is_active", true);
if (isMemberError) {
return false;
}
return tabloAccess?.length > 0;
};
export const isTabloAdmin = async (
supabase: SupabaseClient,
tabloId: string,
userId: string
) => {
const { data: tabloAccess, error: isAdminError } = await supabase
.from("tablo_access")
.select("*")
.eq("tablo_id", tabloId)
.eq("user_id", userId)
.eq("is_active", true)
.eq("is_admin", true);
if (isAdminError) {
return false;
}
return tabloAccess?.length > 0;
};

View file

@ -3,6 +3,7 @@ import { userRouter } from "./user.js";
import { supabaseMiddleware } from "./middleware.js";
import { tabloRouter } from "./tablo.js";
import { taskRouter } from "./tasks.js";
import { tabloDataRouter } from "./tablo_data.js";
export const mainRouter = new Hono<{
Bindings: {
@ -32,3 +33,4 @@ mainRouter.use(supabaseMiddleware);
mainRouter.route("/users", userRouter);
mainRouter.route("/tablos", tabloRouter);
mainRouter.route("/tasks", taskRouter);
mainRouter.route("/tablo-data", tabloDataRouter);

192
api/src/tablo_data.ts Normal file
View file

@ -0,0 +1,192 @@
import { Hono, type Context, type Next } from "hono";
import {
authMiddleware,
r2Middleware,
streamChatMiddleware,
} from "./middleware.js";
import type { SupabaseClient, User } from "@supabase/supabase-js";
import type { S3Client } from "@aws-sdk/client-s3";
import { getTabloFileNames, isTabloAdmin, isTabloMember } from "./helpers.js";
export const tabloDataRouter = new Hono<{
Variables: {
user: User;
supabase: SupabaseClient;
s3_client: S3Client;
};
}>();
tabloDataRouter.use(authMiddleware);
tabloDataRouter.use(streamChatMiddleware);
tabloDataRouter.use(r2Middleware);
const checkTabloMember = async (c: Context, next: Next) => {
const supabase = c.get("supabase");
const user = c.get("user");
const tabloId = c.req.param("tabloId");
const isMember = await isTabloMember(supabase, tabloId, user.id);
if (!isMember) {
return c.json({ error: "You are not a member of this tablo" }, 403);
}
await next();
};
const checkTabloAdmin = async (c: Context, next: Next) => {
const supabase = c.get("supabase");
const user = c.get("user");
const tabloId = c.req.param("tabloId");
const isAdmin = await isTabloAdmin(supabase, tabloId, user.id);
if (!isAdmin) {
return c.json({ error: "You are not an admin of this tablo" }, 403);
}
await next();
};
// GET /tablo-data/:tabloId/filenames - Get all files for a tablo
tabloDataRouter.get("/:tabloId/filenames", checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const s3_client = c.get("s3_client");
try {
const fileNames = await getTabloFileNames(s3_client, tabloId);
return c.json({ fileNames: fileNames || [] });
} catch (error) {
console.error("Error fetching tablo files:", error);
return c.json({ error: "Failed to fetch tablo files" }, 500);
}
});
// GET /tablo-data/:tabloId/:fileName - Get a specific file
tabloDataRouter.get("/:tabloId/:fileName", checkTabloMember, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const { GetObjectCommand } = await import("@aws-sdk/client-s3");
const response = await s3_client.send(
new GetObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
})
);
if (!response.Body) {
return c.json({ error: "File not found" }, 404);
}
const content = await response.Body.transformToString();
return c.json({
fileName,
content,
contentType: response.ContentType,
lastModified: response.LastModified,
});
} catch (error) {
console.error("Error fetching file:", error);
return c.json({ error: "Failed to fetch file" }, 500);
}
});
// POST /tablo-data/:tabloId/:fileName - Create or update a file
tabloDataRouter.post("/:tabloId/:fileName", checkTabloAdmin, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const body = await c.req.json();
const { content, contentType = "text/plain" } = body;
if (!content) {
return c.json({ error: "Content is required" }, 400);
}
const { PutObjectCommand } = await import("@aws-sdk/client-s3");
await s3_client.send(
new PutObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
Body: content,
ContentType: contentType,
})
);
return c.json({
message: "File uploaded successfully",
fileName,
tabloId,
});
} catch (error) {
console.error("Error uploading file:", error);
return c.json({ error: "Failed to upload file" }, 500);
}
});
// // PUT /tablo-data/:tabloId/:fileName - Update a file
// tabloDataRouter.put("/:tabloId/:fileName", async (c) => {
// const tabloId = c.req.param("tabloId");
// const fileName = c.req.param("fileName");
// const s3_client = c.get("s3_client");
// try {
// const body = await c.req.json();
// const { content, contentType = "text/plain" } = body;
// if (!content) {
// return c.json({ error: "Content is required" }, 400);
// }
// const { PutObjectCommand } = await import("@aws-sdk/client-s3");
// await s3_client.send(
// new PutObjectCommand({
// Bucket: "tablo-data",
// Key: `${tabloId}/${fileName}`,
// Body: content,
// ContentType: contentType,
// })
// );
// return c.json({
// message: "File updated successfully",
// fileName,
// tabloId,
// });
// } catch (error) {
// console.error("Error updating file:", error);
// return c.json({ error: "Failed to update file" }, 500);
// }
// });
// DELETE /tablo-data/:tabloId/:fileName - Delete a file
tabloDataRouter.delete("/:tabloId/:fileName", checkTabloAdmin, async (c) => {
const tabloId = c.req.param("tabloId");
const fileName = c.req.param("fileName");
const s3_client = c.get("s3_client");
try {
const { DeleteObjectCommand } = await import("@aws-sdk/client-s3");
await s3_client.send(
new DeleteObjectCommand({
Bucket: "tablo-data",
Key: `${tabloId}/${fileName}`,
})
);
return c.json({
message: "File deleted successfully",
fileName,
tabloId,
});
} catch (error) {
console.error("Error deleting file:", error);
return c.json({ error: "Failed to delete file" }, 500);
}
});

View file

@ -1,3 +0,0 @@
module.exports = async (payload, helpers) => {
helpers.logger.info("Hello World");
};

View file

@ -1,34 +0,0 @@
const { createClient } = require("@supabase/supabase-js");
const { S3Client } = require("@aws-sdk/client-s3");
const { config } = require("../config");
const { writeCalendarFileToR2 } = require("../helpers");
module.exports = async (payload, helpers) => {
const supabase = createClient(
config.SUPABASE_URL,
config.SUPABASE_SERVICE_ROLE_KEY
);
const s3 = new S3Client({
region: "auto",
endpoint: `https://${config.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: config.R2_ACCESS_KEY_ID,
secretAccessKey: config.R2_SECRET_ACCESS_KEY,
},
});
const { data, error } = await supabase.from("calendar_subscriptions").select("token, tablo_id, tablos(name)");
if (error) {
helpers.logger.error(error);
}
data.forEach(async (subscription) => {
const tabloName = subscription.tablos.name.replace(/ /g, "_");
await writeCalendarFileToR2(s3, supabase, {
tabloName,
token: subscription.token,
tablo_id: subscription.tablo_id,
});
});
helpers.logger.info("Synced calendars");
};

View file

@ -1 +0,0 @@

View file

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

View file

@ -4,7 +4,7 @@ export const AnimatedBackground = () => {
{/* Horizontal moving logos */}
<div className="absolute top-1/4 left-0 animate-move-right-slow opacity-4 dark:opacity-8">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-16 h-16 object-contain animate-spin-slow block dark:hidden"
/>
@ -16,7 +16,7 @@ export const AnimatedBackground = () => {
</div>
<div className="absolute top-1/3 left-0 animate-move-right-medium opacity-3 dark:opacity-6">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-12 h-12 object-contain animate-bounce-gentle block dark:hidden"
/>
@ -28,7 +28,7 @@ export const AnimatedBackground = () => {
</div>
<div className="absolute top-1/2 left-0 animate-move-right-fast opacity-5 dark:opacity-10">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-20 h-20 object-contain animate-pulse-gentle block dark:hidden"
/>
@ -40,7 +40,7 @@ export const AnimatedBackground = () => {
</div>
<div className="absolute top-2/3 left-0 animate-move-right-slow opacity-2 dark:opacity-4">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-14 h-14 object-contain animate-wiggle block dark:hidden"
/>
@ -52,7 +52,7 @@ export const AnimatedBackground = () => {
</div>
<div className="absolute top-3/4 left-0 animate-move-right-medium opacity-3 dark:opacity-7">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-18 h-18 object-contain animate-float-gentle"
/>
@ -61,21 +61,21 @@ export const AnimatedBackground = () => {
{/* Diagonal moving logos */}
<div className="absolute top-0 left-1/4 animate-move-diagonal-1 opacity-3 dark:opacity-6">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-10 h-10 object-contain animate-spin-reverse"
/>
</div>
<div className="absolute top-0 left-1/2 animate-move-diagonal-2 opacity-4 dark:opacity-8">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-16 h-16 object-contain animate-scale-gentle"
/>
</div>
<div className="absolute top-0 left-3/4 animate-move-diagonal-3 opacity-2 dark:opacity-5">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-12 h-12 object-contain animate-rotate-gentle"
/>
@ -84,14 +84,14 @@ export const AnimatedBackground = () => {
{/* Vertical moving logos */}
<div className="absolute left-1/6 top-0 animate-move-down-slow opacity-3 dark:opacity-6">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-14 h-14 object-contain animate-bounce-soft"
/>
</div>
<div className="absolute left-5/6 top-0 animate-move-down-medium opacity-4 dark:opacity-7">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-16 h-16 object-contain animate-sway"
/>
@ -99,28 +99,36 @@ export const AnimatedBackground = () => {
{/* Circular moving logos */}
<div className="absolute top-1/2 left-1/2 animate-orbit-1 opacity-2 dark:opacity-4">
<img src="/icon.png" alt="Xtablo" className="w-8 h-8 object-contain" />
<img
src="/logo_dark.png"
alt="Xtablo"
className="w-8 h-8 object-contain"
/>
</div>
<div className="absolute top-1/2 left-1/2 animate-orbit-2 opacity-3 dark:opacity-5">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-10 h-10 object-contain"
/>
</div>
<div className="absolute top-1/2 left-1/2 animate-orbit-3 opacity-2 dark:opacity-4">
<img src="/icon.png" alt="Xtablo" className="w-6 h-6 object-contain" />
<img
src="/logo_dark.png"
alt="Xtablo"
className="w-6 h-6 object-contain"
/>
</div>
<div className="absolute top-1/2 left-1/2 animate-orbit-4 opacity-3 dark:opacity-6">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-12 h-12 object-contain animate-spin-fast"
/>
</div>
<div className="absolute top-1/2 left-1/2 animate-orbit-5 opacity-2 dark:opacity-5">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-7 h-7 object-contain animate-pulse-fast"
/>
@ -129,21 +137,21 @@ export const AnimatedBackground = () => {
{/* Zigzag moving logos */}
<div className="absolute top-1/4 left-0 animate-zigzag-1 opacity-4 dark:opacity-8">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-14 h-14 object-contain animate-wobble"
/>
</div>
<div className="absolute top-1/2 left-0 animate-zigzag-2 opacity-3 dark:opacity-6">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-11 h-11 object-contain animate-shake"
/>
</div>
<div className="absolute top-3/4 left-0 animate-zigzag-3 opacity-5 dark:opacity-9">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-16 h-16 object-contain animate-bounce-crazy"
/>
@ -152,14 +160,14 @@ export const AnimatedBackground = () => {
{/* Spiral moving logos */}
<div className="absolute top-0 left-1/4 animate-spiral-1 opacity-3 dark:opacity-7">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-9 h-9 object-contain animate-spin-wobble"
/>
</div>
<div className="absolute top-0 left-3/4 animate-spiral-2 opacity-4 dark:opacity-8">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-13 h-13 object-contain animate-flip"
/>
@ -168,28 +176,28 @@ export const AnimatedBackground = () => {
{/* Random floating logos */}
<div className="absolute top-1/6 left-1/3 animate-float-random-1 opacity-2 dark:opacity-5">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-8 h-8 object-contain animate-twirl"
/>
</div>
<div className="absolute top-1/3 left-2/3 animate-float-random-2 opacity-3 dark:opacity-6">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-10 h-10 object-contain animate-dance"
/>
</div>
<div className="absolute top-2/3 left-1/4 animate-float-random-3 opacity-4 dark:opacity-7">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-12 h-12 object-contain animate-jiggle"
/>
</div>
<div className="absolute top-5/6 left-3/4 animate-float-random-4 opacity-2 dark:opacity-4">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-9 h-9 object-contain animate-vibrate"
/>
@ -198,28 +206,28 @@ export const AnimatedBackground = () => {
{/* Wave pattern logos */}
<div className="absolute top-1/8 left-0 animate-wave-1 opacity-3 dark:opacity-6">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-11 h-11 object-contain animate-swing"
/>
</div>
<div className="absolute top-3/8 left-0 animate-wave-2 opacity-4 dark:opacity-8">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-13 h-13 object-contain animate-pendulum"
/>
</div>
<div className="absolute top-5/8 left-0 animate-wave-3 opacity-2 dark:opacity-5">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-10 h-10 object-contain animate-elastic"
/>
</div>
<div className="absolute top-7/8 left-0 animate-wave-4 opacity-5 dark:opacity-9">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-15 h-15 object-contain animate-rubber"
/>
@ -228,28 +236,28 @@ export const AnimatedBackground = () => {
{/* Corner shooters */}
<div className="absolute top-0 left-0 animate-corner-shoot-1 opacity-3 dark:opacity-7">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-12 h-12 object-contain animate-rocket"
/>
</div>
<div className="absolute top-0 right-0 animate-corner-shoot-2 opacity-4 dark:opacity-8">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-14 h-14 object-contain animate-comet"
/>
</div>
<div className="absolute bottom-0 left-0 animate-corner-shoot-3 opacity-2 dark:opacity-5">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-10 h-10 object-contain animate-meteor"
/>
</div>
<div className="absolute bottom-0 right-0 animate-corner-shoot-4 opacity-5 dark:opacity-10">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-16 h-16 object-contain animate-blast"
/>
@ -258,21 +266,21 @@ export const AnimatedBackground = () => {
{/* Bouncing balls */}
<div className="absolute top-1/5 left-1/5 animate-bounce-ball-1 opacity-4 dark:opacity-8">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-8 h-8 object-contain animate-spin-bounce"
/>
</div>
<div className="absolute top-2/5 left-4/5 animate-bounce-ball-2 opacity-3 dark:opacity-6">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-11 h-11 object-contain animate-flip-bounce"
/>
</div>
<div className="absolute top-4/5 left-2/5 animate-bounce-ball-3 opacity-5 dark:opacity-9">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-13 h-13 object-contain animate-scale-bounce"
/>

View file

@ -1,11 +1,21 @@
import { ClickOutside } from "./ClickOutside";
import { useState } from "react";
import { useState, useEffect, useRef } from "react";
import { ImageColorPicker } from "./ImageColorPicker";
import { StatusPicker } from "./StatusPicker";
import { useInviteUser } from "@ui/hooks/invite";
import { TabloUpdate, UserTablo } from "@ui/types/tablos.types";
import { useTabloMembers } from "@ui/hooks/tablos";
import { useUser } from "@ui/providers/UserStoreProvider";
import {
useTabloFileNames,
useCreateTabloFile,
useDeleteTabloFile,
useDownloadTabloFile,
} from "@ui/hooks/tablo_data";
import { toast } from "@ui/ui-library/toast/toast-queue";
import { FileTrigger } from "@ui/ui-library/file-trigger";
import { Button } from "@ui/ui-library/button";
import { Trash2Icon, DownloadIcon } from "lucide-react";
type StatusType = "todo" | "in_progress" | "done";
@ -16,13 +26,9 @@ interface TabloModalProps {
readOnly?: boolean;
}
export const TabloModal = ({
tablo,
onClose,
onEdit,
readOnly = false,
}: TabloModalProps) => {
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);
@ -31,13 +37,29 @@ export const TabloModal = ({
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 inviteUser = 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
@ -63,52 +85,292 @@ export const TabloModal = ({
return emailRegex.test(email);
};
const handleFileSelect = (files: FileList | null) => {
const file = files?.[0];
if (!file) return;
// Validate file size (2MB limit)
const maxSize = 2 * 1024 * 1024; // 2MB in bytes
if (file.size > maxSize) {
setError("Le fichier ne peut pas dépasser 2MB");
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">
<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-lg shadow-xl w-full max-w-xl min-w-[28rem] max-h-[90vh] flex flex-col p-4">
<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-2 py-2 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
{!readOnly && isEditingName ? (
<ClickOutside onClickOutside={() => setIsEditingName(false)}>
<input
type="text"
value={editData?.name}
onChange={(e) =>
setEditData((prev) =>
prev ? { ...prev, name: e.target.value } : null
)
}
className="text-2xl font-bold text-gray-900 dark:text-white bg-transparent border-b-2 border-blue-500 focus:outline-none focus:border-blue-600"
/>
</ClickOutside>
) : (
<h2
className={`text-2xl font-bold text-gray-900 dark:text-white ${
!readOnly
? "cursor-text hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
: ""
}`}
onClick={!readOnly ? () => setIsEditingName(true) : undefined}
>
{tablo.name}
</h2>
)}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="flex items-center space-x-3 flex-1">
{/* Tablo Color/Image Preview */}
<div className="flex-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-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 p-2"
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 flex-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 p-2 overflow-y-auto">
{readOnly ? (
<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 */}
@ -205,16 +467,396 @@ export const TabloModal = ({
)}
</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="plain"
onPress={() => handleDownloadFile(fileName)}
isDisabled={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="plain"
onPress={() => handleDeleteFile(fileName)}
isDisabled={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="border-t border-gray-200 dark:border-gray-700 pt-4 px-2">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
Membres
</h3>
<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-1 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
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
@ -274,33 +916,48 @@ export const TabloModal = ({
</div>
{/* Footer */}
<div className="flex justify-end space-x-4 py-2 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
{readOnly ? (
<button
type="button"
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md"
onClick={onClose}
>
Fermer
</button>
) : (
<>
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex-shrink-0 bg-gray-50 dark:bg-gray-900/50">
<div className="flex space-x-3 ml-auto">
{!isAdmin ? (
<button
type="button"
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md"
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
Fermer
</button>
<button
type="button"
className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md"
onClick={handleSaveEdit}
>
Sauvegarder
</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>

374
ui/src/hooks/tablo_data.ts Normal file
View file

@ -0,0 +1,374 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSession } from "@ui/contexts/SessionContext";
import { api } from "@ui/lib/api";
import { toast } from "@ui/ui-library/toast/toast-queue";
// Types for tablo data API responses
export interface TabloFile {
fileName: string;
content: string;
contentType?: string;
lastModified?: Date;
}
export interface TabloFileList {
fileNames: string[];
}
export interface FileUploadRequest {
content: string;
contentType?: string;
}
export interface FileOperationResponse {
message: string;
fileName: string;
tabloId: string;
}
const toastTimeout = 5000;
export const toastOptions = {
timeout: toastTimeout,
};
// Hook to get all file names for a tablo
export function useTabloFileNames(tabloId: string) {
const { session } = useSession();
const { data, isLoading, error } = useQuery<TabloFileList>({
queryKey: ["tablo-files", tabloId],
queryFn: async () => {
const response = await api.get(
`/api/v1/tablo-data/${tabloId}/filenames`,
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
}
);
if (response.status !== 200) {
throw new Error("Failed to fetch tablo files");
}
return response.data;
},
enabled: !!tabloId,
});
return { data, isLoading, error };
}
// Hook to get a specific file from a tablo
export function useTabloFile(tabloId: string, fileName: string) {
const { session } = useSession();
return useQuery<TabloFile>({
queryKey: ["tablo-file", tabloId, fileName],
queryFn: async () => {
const response = await api.get(
`/api/v1/tablo-data/${tabloId}/${fileName}`,
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
}
);
if (response.status !== 200) {
throw new Error("Failed to fetch file");
}
return response.data;
},
enabled: !!tabloId && !!fileName,
});
}
// Hook to download a file from a tablo
export function useDownloadTabloFile() {
const { session } = useSession();
return useMutation<void, Error, { tabloId: string; fileName: string }>({
mutationFn: async ({ tabloId, fileName }) => {
try {
const response = await api.get(
`/api/v1/tablo-data/${tabloId}/${fileName}`,
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
}
);
if (response.status !== 200) {
throw new Error("Failed to download file");
}
const fileData = response.data;
let blob: Blob;
// Handle different content types
if (fileData.content.startsWith("data:")) {
// Handle data URLs (base64 encoded files)
const response = await fetch(fileData.content);
blob = await response.blob();
} else {
// Handle text content
blob = new Blob([fileData.content], {
type: fileData.contentType || "application/octet-stream",
});
}
// Create download link
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error("Download error:", error);
throw error;
}
},
onSuccess: (_, variables) => {
toast.add(
{
title: "Téléchargement réussi",
description: `Le fichier ${variables.fileName} a été téléchargé`,
type: "success",
},
toastOptions
);
},
onError: (error, variables) => {
toast.add(
{
title: "Erreur de téléchargement",
description: `Impossible de télécharger ${variables.fileName}: ${error.message}`,
type: "error",
},
toastOptions
);
},
});
}
// Hook to create a new file in a tablo
export function useCreateTabloFile() {
const { session } = useSession();
return useMutation<
FileOperationResponse,
Error,
{ tabloId: string; fileName: string; data: FileUploadRequest }
>({
mutationFn: async ({ tabloId, fileName, data }) => {
const response = await api.post(
`/api/v1/tablo-data/${tabloId}/${fileName}`,
data,
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
}
);
if (response.status !== 200) {
throw new Error("Failed to create file");
}
return response.data;
},
onSuccess: (_, variables) => {
toast.add(
{
title: "Fichier créé",
description: `Le fichier ${variables.fileName} a été créé avec succès`,
type: "success",
},
toastOptions
);
invalidateTabloData(variables.tabloId);
},
onError: (error, variables) => {
toast.add(
{
title: "Erreur",
description: `Échec de la création du fichier ${variables.fileName}: ${error.message}`,
type: "error",
},
toastOptions
);
},
});
}
// Hook to update an existing file in a tablo
export function useUpdateTabloFile() {
const { session } = useSession();
return useMutation<
FileOperationResponse,
Error,
{ tabloId: string; fileName: string; data: FileUploadRequest }
>({
mutationFn: async ({ tabloId, fileName, data }) => {
const response = await api.put(
`/api/v1/tablo-data/${tabloId}/${fileName}`,
data,
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
}
);
if (response.status !== 200) {
throw new Error("Failed to update file");
}
return response.data;
},
onSuccess: (_, variables) => {
toast.add(
{
title: "Fichier mis à jour",
description: `Le fichier ${variables.fileName} a été mis à jour avec succès`,
type: "success",
},
toastOptions
);
invalidateTabloData(variables.tabloId);
},
onError: (error, variables) => {
toast.add(
{
title: "Erreur",
description: `Échec de la mise à jour du fichier ${variables.fileName}: ${error.message}`,
type: "error",
},
toastOptions
);
},
});
}
// Hook to delete a file from a tablo
export function useDeleteTabloFile() {
const { session } = useSession();
return useMutation<
FileOperationResponse,
Error,
{ tabloId: string; fileName: string }
>({
mutationFn: async ({ tabloId, fileName }) => {
const response = await api.delete(
`/api/v1/tablo-data/${tabloId}/${fileName}`,
{
headers: {
Authorization: `Bearer ${session?.access_token}`,
},
}
);
if (response.status !== 200) {
throw new Error("Failed to delete file");
}
return response.data;
},
onSuccess: (_, variables) => {
toast.add(
{
title: "Fichier supprimé",
description: `Le fichier ${variables.fileName} a été supprimé avec succès`,
type: "success",
},
toastOptions
);
invalidateTabloData(variables.tabloId);
},
onError: (error, variables) => {
toast.add(
{
title: "Erreur",
description: `Échec de la suppression du fichier ${variables.fileName}: ${error.message}`,
type: "error",
},
toastOptions
);
},
});
}
// Utility function to invalidate all tablo data queries for a specific tablo
export const invalidateTabloData = (tabloId: string) => {
const queryClient = useQueryClient();
queryClient.invalidateQueries({
queryKey: ["tablo-files", tabloId],
});
queryClient.invalidateQueries({
queryKey: ["tablo-file", tabloId],
});
};
// Hook to upload or update a file (combines create/update logic)
export function useUploadTabloFile() {
const createFile = useCreateTabloFile();
const updateFile = useUpdateTabloFile();
const queryClient = useQueryClient();
return useMutation<
FileOperationResponse,
Error,
{
tabloId: string;
fileName: string;
data: FileUploadRequest;
overwrite?: boolean;
}
>({
mutationFn: async ({ tabloId, fileName, data, overwrite = false }) => {
// Check if file exists first (unless overwrite is explicitly true)
if (!overwrite) {
try {
const existingFile = queryClient.getQueryData([
"tablo-file",
tabloId,
fileName,
]);
if (existingFile) {
// File exists, use update
return await updateFile.mutateAsync({ tabloId, fileName, data });
}
} catch {
// File doesn't exist, continue with create
}
}
// Try create first, fall back to update if file exists
try {
return await createFile.mutateAsync({ tabloId, fileName, data });
} catch (error) {
// If create fails because file exists, try update
if (error instanceof Error && error.message.includes("exists")) {
return await updateFile.mutateAsync({ tabloId, fileName, data });
}
throw error;
}
},
onSuccess: (_, variables) => {
toast.add(
{
title: "Fichier téléchargé",
description: `Le fichier ${variables.fileName} a été téléchargé avec succès`,
type: "success",
},
toastOptions
);
},
onError: (error, variables) => {
toast.add(
{
title: "Erreur",
description: `Échec du téléchargement du fichier ${variables.fileName}: ${error.message}`,
type: "error",
},
toastOptions
);
},
});
}

View file

@ -324,6 +324,15 @@ export function PublicBookingPage() {
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto py-6 px-4">
<div className="flex items-center gap-4">
{/* Xtablo Logo */}
<div className="flex-shrink-0">
<img
src={theme === "dark" ? "/logo_white.png" : "/logo_dark.png"}
alt="Xtablo"
className="h-8 w-auto"
/>
</div>
{/* Avatar */}
{/* <div className="flex-shrink-0">
{userProfile.avatar_url ? (

View file

@ -105,7 +105,7 @@ export function LoginPage() {
{/* Xtablo Icon */}
<div className="flex justify-center mb-6">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-16 h-16 object-contain block dark:hidden"
/>

View file

@ -152,7 +152,7 @@ export function SignUpPage() {
{/* Xtablo Icon */}
<div className="flex justify-center mb-4">
<img
src="/icon.png"
src="/logo_dark.png"
alt="Xtablo"
className="w-12 h-12 object-contain block dark:hidden"
/>

View file

@ -730,7 +730,6 @@ export const TabloPage = () => {
tablo={viewingTablo}
onEdit={onEditTablo}
onClose={closeTabloModal}
readOnly={!viewingTablo.is_admin}
/>
)}