Add drive
This commit is contained in:
parent
5d9eae3ef7
commit
c19522058a
15 changed files with 1411 additions and 146 deletions
|
|
@ -1 +0,0 @@
|
|||
*/5 * * * * syncCalendars
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
192
api/src/tablo_data.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
module.exports = async (payload, helpers) => {
|
||||
helpers.logger.info("Hello World");
|
||||
};
|
||||
|
|
@ -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");
|
||||
};
|
||||
|
|
@ -1 +0,0 @@
|
|||
|
||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
374
ui/src/hooks/tablo_data.ts
Normal 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
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -730,7 +730,6 @@ export const TabloPage = () => {
|
|||
tablo={viewingTablo}
|
||||
onEdit={onEditTablo}
|
||||
onClose={closeTabloModal}
|
||||
readOnly={!viewingTablo.is_admin}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue