From c19522058a913cdbd8dda34743836fb85516a993 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 9 Oct 2025 22:33:16 +0200 Subject: [PATCH] Add drive --- api/src/crontab | 1 - api/src/helpers.ts | 65 +- api/src/routers.ts | 2 + api/src/tablo_data.ts | 192 ++++++ api/src/tasks/hello.cjs | 3 - api/src/tasks/syncCalendars.cjs | 34 - api/test_buffer_time.js | 1 - ui/public/{icon.png => logo_dark.png} | Bin ui/src/components/AnimatedBackground.tsx | 78 ++- ui/src/components/TabloModal.tsx | 793 +++++++++++++++++++++-- ui/src/hooks/tablo_data.ts | 374 +++++++++++ ui/src/pages/PublicBookingPage.tsx | 9 + ui/src/pages/login.tsx | 2 +- ui/src/pages/signup.tsx | 2 +- ui/src/pages/tablo.tsx | 1 - 15 files changed, 1411 insertions(+), 146 deletions(-) delete mode 100644 api/src/crontab create mode 100644 api/src/tablo_data.ts delete mode 100644 api/src/tasks/hello.cjs delete mode 100644 api/src/tasks/syncCalendars.cjs delete mode 100644 api/test_buffer_time.js rename ui/public/{icon.png => logo_dark.png} (100%) create mode 100644 ui/src/hooks/tablo_data.ts diff --git a/api/src/crontab b/api/src/crontab deleted file mode 100644 index df8f6ba..0000000 --- a/api/src/crontab +++ /dev/null @@ -1 +0,0 @@ -*/5 * * * * syncCalendars \ No newline at end of file diff --git a/api/src/helpers.ts b/api/src/helpers.ts index 0466c6c..3ca7dc8 100644 --- a/api/src/helpers.ts +++ b/api/src/helpers.ts @@ -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; +}; diff --git a/api/src/routers.ts b/api/src/routers.ts index 3b06b1e..8bb525a 100644 --- a/api/src/routers.ts +++ b/api/src/routers.ts @@ -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); diff --git a/api/src/tablo_data.ts b/api/src/tablo_data.ts new file mode 100644 index 0000000..c5375ae --- /dev/null +++ b/api/src/tablo_data.ts @@ -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); + } +}); diff --git a/api/src/tasks/hello.cjs b/api/src/tasks/hello.cjs deleted file mode 100644 index 1885f15..0000000 --- a/api/src/tasks/hello.cjs +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = async (payload, helpers) => { - helpers.logger.info("Hello World"); -}; \ No newline at end of file diff --git a/api/src/tasks/syncCalendars.cjs b/api/src/tasks/syncCalendars.cjs deleted file mode 100644 index 428d70e..0000000 --- a/api/src/tasks/syncCalendars.cjs +++ /dev/null @@ -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"); -}; \ No newline at end of file diff --git a/api/test_buffer_time.js b/api/test_buffer_time.js deleted file mode 100644 index 0519ecb..0000000 --- a/api/test_buffer_time.js +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/ui/public/icon.png b/ui/public/logo_dark.png similarity index 100% rename from ui/public/icon.png rename to ui/public/logo_dark.png diff --git a/ui/src/components/AnimatedBackground.tsx b/ui/src/components/AnimatedBackground.tsx index 7ec99ee..ee5dd3e 100644 --- a/ui/src/components/AnimatedBackground.tsx +++ b/ui/src/components/AnimatedBackground.tsx @@ -4,7 +4,7 @@ export const AnimatedBackground = () => { {/* Horizontal moving logos */}
Xtablo @@ -16,7 +16,7 @@ export const AnimatedBackground = () => {
Xtablo @@ -28,7 +28,7 @@ export const AnimatedBackground = () => {
Xtablo @@ -40,7 +40,7 @@ export const AnimatedBackground = () => {
Xtablo @@ -52,7 +52,7 @@ export const AnimatedBackground = () => {
Xtablo @@ -61,21 +61,21 @@ export const AnimatedBackground = () => { {/* Diagonal moving logos */}
Xtablo
Xtablo
Xtablo @@ -84,14 +84,14 @@ export const AnimatedBackground = () => { {/* Vertical moving logos */}
Xtablo
Xtablo @@ -99,28 +99,36 @@ export const AnimatedBackground = () => { {/* Circular moving logos */}
- Xtablo + Xtablo
Xtablo
- Xtablo + Xtablo
Xtablo
Xtablo @@ -129,21 +137,21 @@ export const AnimatedBackground = () => { {/* Zigzag moving logos */}
Xtablo
Xtablo
Xtablo @@ -152,14 +160,14 @@ export const AnimatedBackground = () => { {/* Spiral moving logos */}
Xtablo
Xtablo @@ -168,28 +176,28 @@ export const AnimatedBackground = () => { {/* Random floating logos */}
Xtablo
Xtablo
Xtablo
Xtablo @@ -198,28 +206,28 @@ export const AnimatedBackground = () => { {/* Wave pattern logos */}
Xtablo
Xtablo
Xtablo
Xtablo @@ -228,28 +236,28 @@ export const AnimatedBackground = () => { {/* Corner shooters */}
Xtablo
Xtablo
Xtablo
Xtablo @@ -258,21 +266,21 @@ export const AnimatedBackground = () => { {/* Bouncing balls */}
Xtablo
Xtablo
Xtablo diff --git a/ui/src/components/TabloModal.tsx b/ui/src/components/TabloModal.tsx index e64e70d..669446a 100644 --- a/ui/src/components/TabloModal.tsx +++ b/ui/src/components/TabloModal.tsx @@ -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(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(null); + const [isUploading, setIsUploading] = useState(false); + const [deletingFile, setDeletingFile] = useState(null); + const [downloadingFile, setDownloadingFile] = useState(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(null); + useEffect(() => { + if (isEditingName && nameInputRef.current) { + nameInputRef.current.focus(); + nameInputRef.current.select(); + } + }, [isEditingName]); + return ( -
+
-
+
{/* Header */} -
- {!readOnly && isEditingName ? ( - setIsEditingName(false)}> - - 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" - /> - - ) : ( -

setIsEditingName(true) : undefined} - > - {tablo.name} -

- )} +
+
+ {/* Tablo Color/Image Preview */} +
+ {tablo.image ? ( + {tablo.name} + ) : ( +
+ + {tablo.name.charAt(0).toUpperCase()} + +
+ )} +
+ + {/* Title */} +
+ {isAdmin && isEditingName ? ( + setIsEditingName(false)}> + + 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" + /> + + ) : ( +
+

setIsEditingName(true) : undefined + } + title={tablo.name} + > + {tablo.name} +

+

+ {isAdmin ? "Administrateur" : "Invité"} •{" "} + {currentData.status === "todo" && "À faire"} + {currentData.status === "in_progress" && "En cours"} + {currentData.status === "done" && "Terminé"} +

+
+ )} +
+
+ + {/* Close Button */}
+ {/* Error Banner */} + {error && ( +
+ + + + + {error} + + +
+ )} + {/* Content - Expandable */} -
- {readOnly ? ( +
+ {!isAdmin ? ( /* Read-only content */
{/* Tablo Preview */} @@ -205,16 +467,396 @@ export const TabloModal = ({ )}
+ {/* Files Section */} +
+
+
+

+ Fichiers +

+ {fileData?.fileNames && ( + + {fileData.fileNames.length} + + )} +
+ +
+ + {showFiles && ( +
+ {/* File Upload Section - Only for Admins */} + {isAdmin && ( +
+
+ + + +

+ Ajouter un fichier +

+
+ + {!selectedFile ? ( +
+ + + +
+ ) : ( +
+
+
+ + + +
+
+

+ {selectedFile.name} +

+

+ {(selectedFile.size / 1024 / 1024).toFixed(2)} MB +

+
+
+ +
+ + +
+
+ )} + +

+ Taille maximale: 2MB +

+
+ )} + + {/* File List */} +
+ {filesLoading ? ( +
+
+ + Chargement des fichiers... + +
+ ) : filesError ? ( +
+
+ + + + + Erreur lors du chargement des fichiers + +
+
+ ) : fileData && + fileData.fileNames && + fileData.fileNames.length > 0 ? ( +
+ {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 ( +
+ +
+

+ {fileName} +

+

+ {fileExtension || "Fichier"} +

+
+
+ + {isAdmin && ( + + )} +
+
+ ); + })} +
+ ) : ( +
+ + + + +

+ Aucun fichier dans ce tablo +

+ {isAdmin && ( +

+ Ajoutez votre premier fichier ci-dessus +

+ )} +
+ )} +
+
+ )} +
+ {/* Members Section */} -
-
-

- Membres -

+
+
+
+

+ Membres +

+ {members && ( + + {members.length} + + )} +
- ) : ( - <> +
+
+ {!isAdmin ? ( - - - )} + ) : ( + <> + + + + )} +
diff --git a/ui/src/hooks/tablo_data.ts b/ui/src/hooks/tablo_data.ts new file mode 100644 index 0000000..90806ea --- /dev/null +++ b/ui/src/hooks/tablo_data.ts @@ -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({ + 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({ + 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({ + 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 + ); + }, + }); +} diff --git a/ui/src/pages/PublicBookingPage.tsx b/ui/src/pages/PublicBookingPage.tsx index 528df7c..eada51c 100644 --- a/ui/src/pages/PublicBookingPage.tsx +++ b/ui/src/pages/PublicBookingPage.tsx @@ -324,6 +324,15 @@ export function PublicBookingPage() {
+ {/* Xtablo Logo */} +
+ Xtablo +
+ {/* Avatar */} {/*
{userProfile.avatar_url ? ( diff --git a/ui/src/pages/login.tsx b/ui/src/pages/login.tsx index 8df9c72..40f3a1e 100644 --- a/ui/src/pages/login.tsx +++ b/ui/src/pages/login.tsx @@ -105,7 +105,7 @@ export function LoginPage() { {/* Xtablo Icon */}
Xtablo diff --git a/ui/src/pages/signup.tsx b/ui/src/pages/signup.tsx index 15a79e5..2ee2428 100644 --- a/ui/src/pages/signup.tsx +++ b/ui/src/pages/signup.tsx @@ -152,7 +152,7 @@ export function SignUpPage() { {/* Xtablo Icon */}
Xtablo diff --git a/ui/src/pages/tablo.tsx b/ui/src/pages/tablo.tsx index 92dfc35..9a44ae2 100644 --- a/ui/src/pages/tablo.tsx +++ b/ui/src/pages/tablo.tsx @@ -730,7 +730,6 @@ export const TabloPage = () => { tablo={viewingTablo} onEdit={onEditTablo} onClose={closeTabloModal} - readOnly={!viewingTablo.is_admin} /> )}