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 */}

@@ -16,7 +16,7 @@ export const AnimatedBackground = () => {

@@ -28,7 +28,7 @@ export const AnimatedBackground = () => {

@@ -40,7 +40,7 @@ export const AnimatedBackground = () => {

@@ -52,7 +52,7 @@ export const AnimatedBackground = () => {

@@ -61,21 +61,21 @@ export const AnimatedBackground = () => {
{/* Diagonal moving logos */}

@@ -84,14 +84,14 @@ export const AnimatedBackground = () => {
{/* Vertical moving logos */}

@@ -99,28 +99,36 @@ export const AnimatedBackground = () => {
{/* Circular moving logos */}
-

+
-

+

@@ -129,21 +137,21 @@ export const AnimatedBackground = () => {
{/* Zigzag moving logos */}

@@ -152,14 +160,14 @@ export const AnimatedBackground = () => {
{/* Spiral moving logos */}

@@ -168,28 +176,28 @@ export const AnimatedBackground = () => {
{/* Random floating logos */}

@@ -198,28 +206,28 @@ export const AnimatedBackground = () => {
{/* Wave pattern logos */}

@@ -228,28 +236,28 @@ export const AnimatedBackground = () => {
{/* Corner shooters */}

@@ -258,21 +266,21 @@ export const AnimatedBackground = () => {
{/* Bouncing balls */}

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.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}
+
+ )}
+