diff --git a/api/src/public.ts b/api/src/public.ts index 43604c0..5843249 100644 --- a/api/src/public.ts +++ b/api/src/public.ts @@ -30,7 +30,8 @@ function getCETTime(): Date { const parts = formatter.formatToParts(utcNow); const year = parseInt(parts.find((p) => p.type === "year")?.value || "0"); - const month = parseInt(parts.find((p) => p.type === "month")?.value || "0") - 1; // Month is 0-indexed + const month = + parseInt(parts.find((p) => p.type === "month")?.value || "0") - 1; // Month is 0-indexed const day = parseInt(parts.find((p) => p.type === "day")?.value || "0"); const hour = parseInt(parts.find((p) => p.type === "hour")?.value || "0"); const minute = parseInt(parts.find((p) => p.type === "minute")?.value || "0"); @@ -78,22 +79,25 @@ publicRouter.get("/slots/:shortUserId/:standardName", async (c) => { return c.json({ error: "Event type not found" }, 404); } - const eventType = eventTypeData as Database["public"]["Tables"]["event_types"]["Row"]; + const eventType = + eventTypeData as Database["public"]["Tables"]["event_types"]["Row"]; const eventTypeConfig = eventType.config as EventTypeConfig; // Get user's availabilities - const { data: availabilitiesData, error: availabilitiesError } = await supabase - .from("availabilities") - .select("*") - .eq("user_id", user.id) - .single(); + const { data: availabilitiesData, error: availabilitiesError } = + await supabase + .from("availabilities") + .select("*") + .eq("user_id", user.id) + .single(); if (availabilitiesError) { return c.json({ error: "Availabilities not found" }, 404); } const availabilities = availabilitiesData as Tables<"availabilities">; - const weeklyAvailability = availabilities.availability_data as WeeklyAvailability; + const weeklyAvailability = + availabilities.availability_data as WeeklyAvailability; const exceptions = (availabilities.exceptions as Exception[]) || []; // Get existing events for the next month @@ -149,7 +153,7 @@ publicRouter.get("/slots/:shortUserId/:standardName", async (c) => { }); return c.json({ - user: { name: user.name }, + user: { name: user.name, avatar_url: user.avatar_url }, eventType: eventTypeConfig, slots: slotsByDate, availableSlots: slots.filter((slot) => slot.available), diff --git a/api/src/tablo_data.ts b/api/src/tablo_data.ts index c7ec936..aee1ea0 100644 --- a/api/src/tablo_data.ts +++ b/api/src/tablo_data.ts @@ -1,4 +1,4 @@ -import type { S3Client } from "@aws-sdk/client-s3"; +import { PutObjectCommand, type S3Client } from "@aws-sdk/client-s3"; import type { SupabaseClient, User } from "@supabase/supabase-js"; import { type Context, Hono, type Next } from "hono"; import { getTabloFileNames, isTabloAdmin, isTabloMember } from "./helpers.js"; @@ -106,8 +106,6 @@ tabloDataRouter.post("/:tabloId/:fileName", checkTabloAdmin, async (c) => { 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", diff --git a/api/src/user.ts b/api/src/user.ts index 82cfd35..de84243 100644 --- a/api/src/user.ts +++ b/api/src/user.ts @@ -3,8 +3,18 @@ import { Hono } from "hono"; import type { Transporter } from "nodemailer"; import { StreamChat } from "stream-chat"; import type { Tables } from "./database.types.ts"; -import { authMiddleware, streamChatMiddleware } from "./middleware.js"; +import { + authMiddleware, + r2Middleware, + streamChatMiddleware, +} from "./middleware.js"; import { transporter } from "./transporter.js"; +import { + DeleteObjectsCommand, + ListObjectsV2Command, + PutObjectCommand, + type S3Client, +} from "@aws-sdk/client-s3"; export const userRouter = new Hono<{ Variables: { @@ -12,11 +22,13 @@ export const userRouter = new Hono<{ supabase: SupabaseClient; transporter: Transporter; streamServerClient: StreamChat; + s3_client: S3Client; }; }>(); userRouter.use(authMiddleware); userRouter.use(streamChatMiddleware); +userRouter.use(r2Middleware); userRouter.post("/sign-up-to-stream", async (c) => { const { id } = c.get("user"); @@ -149,23 +161,81 @@ L'équipe XTablo`, }); }); -userRouter.put("/profile", async (c) => { +// userRouter.put("/profile", async (c) => { +// const user = c.get("user"); +// const supabase = c.get("supabase"); + +// const body = await c.req.json(); +// const { first_name, last_name } = body; + +// // Deprecated: name field is deprecated, use first_name and last_name instead +// // Combine first_name and last_name into a single name field +// const name = [first_name, last_name].filter(Boolean).join(" "); + +// const updateData = +// first_name && last_name +// ? { +// name, +// first_name, +// last_name, +// } +// : {}; + +// const { data: profile, error } = await supabase +// .from("profiles") +// .update(updateData) +// .eq("id", user.id) +// .select() +// .single(); + +// if (error) { +// return c.json({ error: error.message }, 500); +// } + +// return c.json({ +// message: "Profile updated successfully", +// profile, +// }); +// }); + +userRouter.post("/profile/avatar", async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); + const s3Client = c.get("s3_client"); const body = await c.req.json(); - const { first_name, last_name, introduction_email } = body; + const { content, contentType = "image/jpeg" } = body; - // Combine first_name and last_name into a single name field - const name = [first_name, last_name].filter(Boolean).join(" "); + if (!content) { + return c.json({ error: "Content is required" }, 400); + } - const { data: profile, error } = await supabase + const randomString = Math.random().toString(36).substring(2, 15); + const base64Content = Buffer.from(content, "base64"); + const key = `${user.id}/public_avatar_${randomString}.${ + contentType.split("/")[1] + }`; + + try { + await s3Client.send( + new PutObjectCommand({ + Bucket: "web-assets", + Key: key, + Body: base64Content, + ContentType: contentType, + ContentEncoding: "base64", + }) + ); + } catch (error) { + console.error("Failed to upload avatar:", error); + return c.json({ error: "Failed to upload avatar" }, 500); + } + + const avatarUrl = `https://assets.xtablo.com/${key}`; + + const { data, error } = await supabase .from("profiles") - .update({ - name: name || null, - first_name: first_name || null, - last_name: last_name || null, - }) + .update({ avatar_url: avatarUrl }) .eq("id", user.id) .select() .single(); @@ -174,22 +244,51 @@ userRouter.put("/profile", async (c) => { return c.json({ error: error.message }, 500); } - // Update user metadata in Supabase Auth using updateUser - const { error: authError } = await supabase.auth.updateUser({ - data: { - first_name: first_name || "", - last_name: last_name || "", - introduction_email: introduction_email || "", - }, + return c.json({ + message: "Avatar updated successfully", + profile: data, }); +}); - if (authError) { - console.error("Failed to update user metadata:", authError); - // Don't fail the request if metadata update fails +userRouter.delete("/profile/avatar", async (c) => { + const user = c.get("user"); + const supabase = c.get("supabase"); + const s3Client = c.get("s3_client"); + + try { + const listedObjects = await s3Client.send( + new ListObjectsV2Command({ + Bucket: "web-assets", + Prefix: `${user.id}/`, + }) + ); + + if (listedObjects.Contents.length === 0) + return c.json({ error: "No objects found" }, 404); + + await s3Client.send( + new DeleteObjectsCommand({ + Bucket: "web-assets", + Delete: { Objects: listedObjects.Contents.map(({ Key }) => ({ Key })) }, + }) + ); + } catch (error) { + console.error("Failed to delete avatar:", error); + return c.json({ error: "Failed to delete avatar" }, 500); + } + + const { error } = await supabase + .from("profiles") + .update({ avatar_url: null }) + .eq("id", user.id) + .select() + .single(); + + if (error) { + return c.json({ error: error.message }, 500); } return c.json({ - message: "Profile updated successfully", - profile, + message: "Avatar deleted successfully", }); }); diff --git a/ui/package.json b/ui/package.json index ec31299..8dd85b5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -78,6 +78,7 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", @@ -97,6 +98,7 @@ "jspdf": "^3.0.3", "jwt-decode": "^4.0.0", "react-day-picker": "^9.11.1", + "react-easy-crop": "^5.5.3", "react-hook-form": "^7.65.0", "react-router-dom": "^7.9.4", "react-stately": "^3.36.1", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 7284010..e5d6c24 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@radix-ui/react-separator': specifier: ^1.1.7 version: 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slider': + specifier: ^1.3.6 + version: 1.3.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-slot': specifier: ^1.2.3 version: 1.2.3(@types/react@19.0.10)(react@19.0.0) @@ -105,6 +108,9 @@ importers: react-day-picker: specifier: ^9.11.1 version: 9.11.1(react@19.0.0) + react-easy-crop: + specifier: ^5.5.3 + version: 5.5.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-hook-form: specifier: ^7.65.0 version: 7.65.0(react@19.0.0) @@ -1546,6 +1552,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -4682,6 +4701,9 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + normalize-wheel@1.0.1: + resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -4974,6 +4996,12 @@ packages: peerDependencies: react: '>= 16.8 || 18.0.0' + react-easy-crop@5.5.3: + resolution: {integrity: sha512-iKwFTnAsq+IVuyF6N0Q3zjRx9DG1NMySkwWxVfM/xAOeHYH1vhvM+V2kFiq5HOIQGWouITjfltCx54mbDpMpmA==} + peerDependencies: + react: '>=16.4.0' + react-dom: '>=16.4.0' + react-fast-compare@3.2.2: resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} @@ -7175,6 +7203,25 @@ snapshots: '@types/react': 19.0.10 '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-slot@1.2.3(@types/react@19.0.10)(react@19.0.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) @@ -11365,6 +11412,8 @@ snapshots: normalize-path@3.0.0: {} + normalize-wheel@1.0.1: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -11674,6 +11723,13 @@ snapshots: prop-types: 15.8.1 react: 19.0.0 + react-easy-crop@5.5.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + dependencies: + normalize-wheel: 1.0.1 + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + tslib: 2.8.1 + react-fast-compare@3.2.2: {} react-hook-form@7.65.0(react@19.0.0): diff --git a/ui/src/components/ImageCropDialog.tsx b/ui/src/components/ImageCropDialog.tsx new file mode 100644 index 0000000..2782875 --- /dev/null +++ b/ui/src/components/ImageCropDialog.tsx @@ -0,0 +1,158 @@ +import { useState, useCallback } from "react"; +import Cropper from "react-easy-crop"; +import type { Area } from "react-easy-crop"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@ui/components/ui/dialog"; +import { Button } from "@ui/components/ui/button"; +import { Slider } from "@ui/components/ui/slider"; +import { Label } from "@ui/components/ui/label"; + +interface ImageCropDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + imageSrc: string; + onCropComplete: (croppedImage: Blob) => void; +} + +/** + * Creates a cropped image from the original image and crop area + */ +async function getCroppedImg(imageSrc: string, pixelCrop: Area): Promise { + const image = await createImage(imageSrc); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new Error("Failed to get canvas context"); + } + + // Set canvas size to the desired crop size + canvas.width = pixelCrop.width; + canvas.height = pixelCrop.height; + + // Draw the cropped portion of the image + ctx.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height + ); + + // Convert canvas to blob + return new Promise((resolve, reject) => { + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Canvas is empty")); + } + }, "image/jpeg"); + }); +} + +/** + * Loads an image from a URL + */ +function createImage(url: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener("load", () => resolve(image)); + image.addEventListener("error", (error) => reject(error)); + image.src = url; + }); +} + +export function ImageCropDialog({ + open, + onOpenChange, + imageSrc, + onCropComplete, +}: ImageCropDialogProps) { + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + const [isCropping, setIsCropping] = useState(false); + + const onCropAreaChange = useCallback((_croppedArea: Area, croppedAreaPixels: Area) => { + setCroppedAreaPixels(croppedAreaPixels); + }, []); + + const handleCropConfirm = useCallback(async () => { + if (!croppedAreaPixels) return; + + try { + setIsCropping(true); + const croppedImage = await getCroppedImg(imageSrc, croppedAreaPixels); + onCropComplete(croppedImage); + onOpenChange(false); + } catch (error) { + console.error("Error cropping image:", error); + } finally { + setIsCropping(false); + } + }, [croppedAreaPixels, imageSrc, onCropComplete, onOpenChange]); + + return ( + + + + Recadrer l'image + + Ajustez la position et le zoom pour recadrer votre photo de profil + + + +
+ +
+ +
+
+ + setZoom(value[0])} + className="w-full" + /> +
+
+ + + + + +
+
+ ); +} diff --git a/ui/src/components/NavigationBar.tsx b/ui/src/components/NavigationBar.tsx index bcbb7c5..8dc876a 100644 --- a/ui/src/components/NavigationBar.tsx +++ b/ui/src/components/NavigationBar.tsx @@ -215,8 +215,9 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) { } export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean }) => { - const [isCollapsed, setIsCollapsed] = useState(false); const isCollapsable = !isMobileMenuOpen; + const [isCollapsed, setIsCollapsed] = useState(!isCollapsable); + return (