From 766f3d5a59bc5a4650e789336e383d3fbdf6235b Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Tue, 3 Mar 2026 10:04:56 +0100 Subject: [PATCH] feat: add GIF Mood Board workshop - New workshop where each team member shares up to 5 GIFs with notes to express their weekly mood - Per-user week rating (1-5 stars) visible next to each member's section - Masonry-style grid with adjustable column count (3/4/5) toggle - Handwriting font (Caveat) for GIF notes - Full real-time collaboration via SSE - Clean migration (add_gif_mood_workshop) safe for production deploy - DB backup via cp before each migration in docker-entrypoint Co-Authored-By: Claude Sonnet 4.6 --- docker-entrypoint.sh | 13 + .../migration.sql | 137 +++++++ prisma/schema.prisma | 84 +++++ src/actions/gif-mood.ts | 340 ++++++++++++++++++ src/app/api/gif-mood/[id]/subscribe/route.ts | 112 ++++++ src/app/gif-mood/[id]/page.tsx | 95 +++++ src/app/gif-mood/new/page.tsx | 123 +++++++ src/app/layout.tsx | 9 +- src/app/page.tsx | 111 ++++++ src/app/sessions/WorkshopTabs.tsx | 74 +++- src/app/sessions/page.tsx | 13 + .../collaboration/BaseSessionLiveWrapper.tsx | 2 +- src/components/gif-mood/GifMoodAddForm.tsx | 126 +++++++ src/components/gif-mood/GifMoodBoard.tsx | 273 ++++++++++++++ src/components/gif-mood/GifMoodCard.tsx | 112 ++++++ .../gif-mood/GifMoodLiveWrapper.tsx | 62 ++++ src/components/gif-mood/index.ts | 4 + src/components/ui/EditableGifMoodTitle.tsx | 28 ++ src/lib/types.ts | 47 +++ src/lib/workshops.ts | 24 ++ src/services/gif-mood.ts | 258 +++++++++++++ 21 files changed, 2032 insertions(+), 15 deletions(-) create mode 100644 prisma/migrations/20260303000000_add_gif_mood_workshop/migration.sql create mode 100644 src/actions/gif-mood.ts create mode 100644 src/app/api/gif-mood/[id]/subscribe/route.ts create mode 100644 src/app/gif-mood/[id]/page.tsx create mode 100644 src/app/gif-mood/new/page.tsx create mode 100644 src/components/gif-mood/GifMoodAddForm.tsx create mode 100644 src/components/gif-mood/GifMoodBoard.tsx create mode 100644 src/components/gif-mood/GifMoodCard.tsx create mode 100644 src/components/gif-mood/GifMoodLiveWrapper.tsx create mode 100644 src/components/gif-mood/index.ts create mode 100644 src/components/ui/EditableGifMoodTitle.tsx create mode 100644 src/services/gif-mood.ts diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 781a1fd..f120f2a 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,6 +1,19 @@ #!/bin/sh set -e +DB_PATH="/app/data/dev.db" +BACKUP_DIR="/app/data/backups" + +if [ -f "$DB_PATH" ]; then + mkdir -p "$BACKUP_DIR" + BACKUP_FILE="$BACKUP_DIR/dev-$(date +%Y%m%d-%H%M%S).db" + cp "$DB_PATH" "$BACKUP_FILE" + echo "💾 Database backed up to $BACKUP_FILE" + + # Keep only the 10 most recent backups + ls -t "$BACKUP_DIR"/*.db 2>/dev/null | tail -n +11 | xargs rm -f +fi + echo "🔄 Running database migrations..." pnpm prisma migrate deploy diff --git a/prisma/migrations/20260303000000_add_gif_mood_workshop/migration.sql b/prisma/migrations/20260303000000_add_gif_mood_workshop/migration.sql new file mode 100644 index 0000000..2331ba1 --- /dev/null +++ b/prisma/migrations/20260303000000_add_gif_mood_workshop/migration.sql @@ -0,0 +1,137 @@ +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "Emotion"; +PRAGMA foreign_keys=on; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "KeyResultStatus"; +PRAGMA foreign_keys=on; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "OKRStatus"; +PRAGMA foreign_keys=on; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "TeamRole"; +PRAGMA foreign_keys=on; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "WeeklyCheckInCategory"; +PRAGMA foreign_keys=on; + +-- CreateTable +CREATE TABLE "GifMoodSession" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "GifMoodSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "GifMoodUserRating" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "rating" INTEGER NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "GifMoodUserRating_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "GifMoodUserRating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "GifMoodItem" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "gifUrl" TEXT NOT NULL, + "note" TEXT, + "order" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "GifMoodItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "GifMoodItem_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "GMSessionShare" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'EDITOR', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "GMSessionShare_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "GMSessionShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "GMSessionEvent" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "payload" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "GMSessionEvent_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "GifMoodSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "GMSessionEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_WeeklyCheckInItem" ( + "id" TEXT NOT NULL PRIMARY KEY, + "content" TEXT NOT NULL, + "category" TEXT NOT NULL, + "emotion" TEXT NOT NULL DEFAULT 'NONE', + "order" INTEGER NOT NULL DEFAULT 0, + "sessionId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "WeeklyCheckInItem_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "WeeklyCheckInSession" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_WeeklyCheckInItem" ("category", "content", "createdAt", "emotion", "id", "order", "sessionId", "updatedAt") SELECT "category", "content", "createdAt", "emotion", "id", "order", "sessionId", "updatedAt" FROM "WeeklyCheckInItem"; +DROP TABLE "WeeklyCheckInItem"; +ALTER TABLE "new_WeeklyCheckInItem" RENAME TO "WeeklyCheckInItem"; +CREATE INDEX "WeeklyCheckInItem_sessionId_idx" ON "WeeklyCheckInItem"("sessionId"); +CREATE INDEX "WeeklyCheckInItem_sessionId_category_idx" ON "WeeklyCheckInItem"("sessionId", "category"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE INDEX "GifMoodSession_userId_idx" ON "GifMoodSession"("userId"); + +-- CreateIndex +CREATE INDEX "GifMoodSession_date_idx" ON "GifMoodSession"("date"); + +-- CreateIndex +CREATE INDEX "GifMoodUserRating_sessionId_idx" ON "GifMoodUserRating"("sessionId"); + +-- CreateIndex +CREATE UNIQUE INDEX "GifMoodUserRating_sessionId_userId_key" ON "GifMoodUserRating"("sessionId", "userId"); + +-- CreateIndex +CREATE INDEX "GifMoodItem_sessionId_userId_idx" ON "GifMoodItem"("sessionId", "userId"); + +-- CreateIndex +CREATE INDEX "GifMoodItem_sessionId_idx" ON "GifMoodItem"("sessionId"); + +-- CreateIndex +CREATE INDEX "GMSessionShare_sessionId_idx" ON "GMSessionShare"("sessionId"); + +-- CreateIndex +CREATE INDEX "GMSessionShare_userId_idx" ON "GMSessionShare"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "GMSessionShare_sessionId_userId_key" ON "GMSessionShare"("sessionId", "userId"); + +-- CreateIndex +CREATE INDEX "GMSessionEvent_sessionId_createdAt_idx" ON "GMSessionEvent"("sessionId", "createdAt"); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 783d6b5..69ebb9c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -34,6 +34,12 @@ model User { sharedWeatherSessions WeatherSessionShare[] weatherSessionEvents WeatherSessionEvent[] weatherEntries WeatherEntry[] + // GIF Mood Board relations + gifMoodSessions GifMoodSession[] + gifMoodItems GifMoodItem[] + sharedGifMoodSessions GMSessionShare[] + gifMoodSessionEvents GMSessionEvent[] + gifMoodRatings GifMoodUserRating[] // Teams & OKRs relations createdTeams Team[] teamMembers TeamMember[] @@ -525,3 +531,81 @@ model WeatherSessionEvent { @@index([sessionId, createdAt]) } + +// ============================================ +// GIF Mood Board Workshop +// ============================================ + +model GifMoodSession { + id String @id @default(cuid()) + title String + date DateTime @default(now()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + items GifMoodItem[] + shares GMSessionShare[] + events GMSessionEvent[] + ratings GifMoodUserRating[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([date]) +} + +model GifMoodUserRating { + id String @id @default(cuid()) + sessionId String + session GifMoodSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + rating Int // 1-5 + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([sessionId, userId]) + @@index([sessionId]) +} + +model GifMoodItem { + id String @id @default(cuid()) + sessionId String + session GifMoodSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + gifUrl String + note String? + order Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([sessionId, userId]) + @@index([sessionId]) +} + +model GMSessionShare { + id String @id @default(cuid()) + sessionId String + session GifMoodSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role ShareRole @default(EDITOR) + createdAt DateTime @default(now()) + + @@unique([sessionId, userId]) + @@index([sessionId]) + @@index([userId]) +} + +model GMSessionEvent { + id String @id @default(cuid()) + sessionId String + session GifMoodSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type String // GIF_ADDED, GIF_UPDATED, GIF_DELETED, SESSION_UPDATED + payload String // JSON payload + createdAt DateTime @default(now()) + + @@index([sessionId, createdAt]) +} diff --git a/src/actions/gif-mood.ts b/src/actions/gif-mood.ts new file mode 100644 index 0000000..8eb9d36 --- /dev/null +++ b/src/actions/gif-mood.ts @@ -0,0 +1,340 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { auth } from '@/lib/auth'; +import * as gifMoodService from '@/services/gif-mood'; +import { getUserById } from '@/services/auth'; +import { broadcastToGifMoodSession } from '@/app/api/gif-mood/[id]/subscribe/route'; + +// ============================================ +// Session Actions +// ============================================ + +export async function createGifMoodSession(data: { title: string; date?: Date }) { + const session = await auth(); + if (!session?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + const gifMoodSession = await gifMoodService.createGifMoodSession(session.user.id, data); + revalidatePath('/gif-mood'); + revalidatePath('/sessions'); + return { success: true, data: gifMoodSession }; + } catch (error) { + console.error('Error creating gif mood session:', error); + return { success: false, error: 'Erreur lors de la création' }; + } +} + +export async function updateGifMoodSession( + sessionId: string, + data: { title?: string; date?: Date } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await gifMoodService.updateGifMoodSession(sessionId, authSession.user.id, data); + + const user = await getUserById(authSession.user.id); + if (!user) { + return { success: false, error: 'Utilisateur non trouvé' }; + } + + const event = await gifMoodService.createGifMoodSessionEvent( + sessionId, + authSession.user.id, + 'SESSION_UPDATED', + data + ); + + broadcastToGifMoodSession(sessionId, { + type: 'SESSION_UPDATED', + payload: data, + userId: authSession.user.id, + user: { id: user.id, name: user.name, email: user.email }, + timestamp: event.createdAt, + }); + + revalidatePath(`/gif-mood/${sessionId}`); + revalidatePath('/gif-mood'); + revalidatePath('/sessions'); + return { success: true }; + } catch (error) { + console.error('Error updating gif mood session:', error); + return { success: false, error: 'Erreur lors de la mise à jour' }; + } +} + +export async function deleteGifMoodSession(sessionId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await gifMoodService.deleteGifMoodSession(sessionId, authSession.user.id); + revalidatePath('/gif-mood'); + revalidatePath('/sessions'); + return { success: true }; + } catch (error) { + console.error('Error deleting gif mood session:', error); + return { success: false, error: 'Erreur lors de la suppression' }; + } +} + +// ============================================ +// Item Actions +// ============================================ + +export async function addGifMoodItem( + sessionId: string, + data: { gifUrl: string; note?: string } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + const item = await gifMoodService.addGifMoodItem(sessionId, authSession.user.id, data); + + const user = await getUserById(authSession.user.id); + if (!user) { + return { success: false, error: 'Utilisateur non trouvé' }; + } + + const event = await gifMoodService.createGifMoodSessionEvent( + sessionId, + authSession.user.id, + 'GIF_ADDED', + { itemId: item.id, userId: item.userId, gifUrl: item.gifUrl, note: item.note } + ); + + broadcastToGifMoodSession(sessionId, { + type: 'GIF_ADDED', + payload: { itemId: item.id, userId: item.userId, gifUrl: item.gifUrl, note: item.note }, + userId: authSession.user.id, + user: { id: user.id, name: user.name, email: user.email }, + timestamp: event.createdAt, + }); + + revalidatePath(`/gif-mood/${sessionId}`); + return { success: true, data: item }; + } catch (error) { + console.error('Error adding gif mood item:', error); + const message = error instanceof Error ? error.message : "Erreur lors de l'ajout"; + return { success: false, error: message }; + } +} + +export async function updateGifMoodItem( + sessionId: string, + itemId: string, + data: { note?: string; order?: number } +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + await gifMoodService.updateGifMoodItem(itemId, authSession.user.id, data); + + const user = await getUserById(authSession.user.id); + if (!user) { + return { success: false, error: 'Utilisateur non trouvé' }; + } + + const event = await gifMoodService.createGifMoodSessionEvent( + sessionId, + authSession.user.id, + 'GIF_UPDATED', + { itemId, ...data } + ); + + broadcastToGifMoodSession(sessionId, { + type: 'GIF_UPDATED', + payload: { itemId, ...data }, + userId: authSession.user.id, + user: { id: user.id, name: user.name, email: user.email }, + timestamp: event.createdAt, + }); + + revalidatePath(`/gif-mood/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error updating gif mood item:', error); + return { success: false, error: 'Erreur lors de la mise à jour' }; + } +} + +export async function deleteGifMoodItem(sessionId: string, itemId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + await gifMoodService.deleteGifMoodItem(itemId, authSession.user.id); + + const user = await getUserById(authSession.user.id); + if (!user) { + return { success: false, error: 'Utilisateur non trouvé' }; + } + + const event = await gifMoodService.createGifMoodSessionEvent( + sessionId, + authSession.user.id, + 'GIF_DELETED', + { itemId, userId: authSession.user.id } + ); + + broadcastToGifMoodSession(sessionId, { + type: 'GIF_DELETED', + payload: { itemId, userId: authSession.user.id }, + userId: authSession.user.id, + user: { id: user.id, name: user.name, email: user.email }, + timestamp: event.createdAt, + }); + + revalidatePath(`/gif-mood/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error deleting gif mood item:', error); + return { success: false, error: 'Erreur lors de la suppression' }; + } +} + +// ============================================ +// Week Rating Actions +// ============================================ + +export async function setGifMoodUserRating(sessionId: string, rating: number) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + const canEdit = await gifMoodService.canEditGifMoodSession(sessionId, authSession.user.id); + if (!canEdit) { + return { success: false, error: 'Permission refusée' }; + } + + try { + await gifMoodService.upsertGifMoodUserRating(sessionId, authSession.user.id, rating); + + const user = await getUserById(authSession.user.id); + if (user) { + const event = await gifMoodService.createGifMoodSessionEvent( + sessionId, + authSession.user.id, + 'SESSION_UPDATED', + { rating, userId: authSession.user.id } + ); + broadcastToGifMoodSession(sessionId, { + type: 'SESSION_UPDATED', + payload: { rating, userId: authSession.user.id }, + userId: authSession.user.id, + user: { id: user.id, name: user.name, email: user.email }, + timestamp: event.createdAt, + }); + } + + revalidatePath(`/gif-mood/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error setting gif mood user rating:', error); + return { success: false, error: 'Erreur lors de la mise à jour' }; + } +} + +// ============================================ +// Sharing Actions +// ============================================ + +export async function shareGifMoodSession( + sessionId: string, + targetEmail: string, + role: 'VIEWER' | 'EDITOR' = 'EDITOR' +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + const share = await gifMoodService.shareGifMoodSession( + sessionId, + authSession.user.id, + targetEmail, + role + ); + revalidatePath(`/gif-mood/${sessionId}`); + return { success: true, data: share }; + } catch (error) { + console.error('Error sharing gif mood session:', error); + const message = error instanceof Error ? error.message : 'Erreur lors du partage'; + return { success: false, error: message }; + } +} + +export async function shareGifMoodSessionToTeam( + sessionId: string, + teamId: string, + role: 'VIEWER' | 'EDITOR' = 'EDITOR' +) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + const shares = await gifMoodService.shareGifMoodSessionToTeam( + sessionId, + authSession.user.id, + teamId, + role + ); + revalidatePath(`/gif-mood/${sessionId}`); + return { success: true, data: shares }; + } catch (error) { + console.error('Error sharing gif mood session to team:', error); + const message = error instanceof Error ? error.message : "Erreur lors du partage à l'équipe"; + return { success: false, error: message }; + } +} + +export async function removeGifMoodShare(sessionId: string, shareUserId: string) { + const authSession = await auth(); + if (!authSession?.user?.id) { + return { success: false, error: 'Non autorisé' }; + } + + try { + await gifMoodService.removeGifMoodShare(sessionId, authSession.user.id, shareUserId); + revalidatePath(`/gif-mood/${sessionId}`); + return { success: true }; + } catch (error) { + console.error('Error removing gif mood share:', error); + return { success: false, error: 'Erreur lors de la suppression du partage' }; + } +} diff --git a/src/app/api/gif-mood/[id]/subscribe/route.ts b/src/app/api/gif-mood/[id]/subscribe/route.ts new file mode 100644 index 0000000..630c31c --- /dev/null +++ b/src/app/api/gif-mood/[id]/subscribe/route.ts @@ -0,0 +1,112 @@ +import { auth } from '@/lib/auth'; +import { canAccessGifMoodSession, getGifMoodSessionEvents } from '@/services/gif-mood'; + +export const dynamic = 'force-dynamic'; + +// Store active connections per session +const connections = new Map>(); + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id: sessionId } = await params; + const session = await auth(); + + if (!session?.user?.id) { + return new Response('Unauthorized', { status: 401 }); + } + + const hasAccess = await canAccessGifMoodSession(sessionId, session.user.id); + if (!hasAccess) { + return new Response('Forbidden', { status: 403 }); + } + + const userId = session.user.id; + let lastEventTime = new Date(); + let controller: ReadableStreamDefaultController; + + const stream = new ReadableStream({ + start(ctrl) { + controller = ctrl; + + if (!connections.has(sessionId)) { + connections.set(sessionId, new Set()); + } + connections.get(sessionId)!.add(controller); + + const encoder = new TextEncoder(); + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'connected', userId })}\n\n`) + ); + }, + cancel() { + connections.get(sessionId)?.delete(controller); + if (connections.get(sessionId)?.size === 0) { + connections.delete(sessionId); + } + }, + }); + + const pollInterval = setInterval(async () => { + try { + const events = await getGifMoodSessionEvents(sessionId, lastEventTime); + if (events.length > 0) { + const encoder = new TextEncoder(); + for (const event of events) { + if (event.userId !== userId) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ + type: event.type, + payload: JSON.parse(event.payload), + userId: event.userId, + user: event.user, + timestamp: event.createdAt, + })}\n\n` + ) + ); + } + lastEventTime = event.createdAt; + } + } + } catch { + clearInterval(pollInterval); + } + }, 2000); + + request.signal.addEventListener('abort', () => { + clearInterval(pollInterval); + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} + +export function broadcastToGifMoodSession(sessionId: string, event: object) { + try { + const sessionConnections = connections.get(sessionId); + if (!sessionConnections || sessionConnections.size === 0) { + return; + } + + const encoder = new TextEncoder(); + const message = encoder.encode(`data: ${JSON.stringify(event)}\n\n`); + + for (const controller of sessionConnections) { + try { + controller.enqueue(message); + } catch { + sessionConnections.delete(controller); + } + } + + if (sessionConnections.size === 0) { + connections.delete(sessionId); + } + } catch (error) { + console.error('[SSE Broadcast] Error broadcasting:', error); + } +} diff --git a/src/app/gif-mood/[id]/page.tsx b/src/app/gif-mood/[id]/page.tsx new file mode 100644 index 0000000..1bff752 --- /dev/null +++ b/src/app/gif-mood/[id]/page.tsx @@ -0,0 +1,95 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { auth } from '@/lib/auth'; +import { getWorkshop, getSessionsTabUrl } from '@/lib/workshops'; +import { getGifMoodSessionById } from '@/services/gif-mood'; +import { getUserTeams } from '@/services/teams'; +import { GifMoodBoard, GifMoodLiveWrapper } from '@/components/gif-mood'; +import { Badge } from '@/components/ui'; +import { EditableGifMoodTitle } from '@/components/ui/EditableGifMoodTitle'; + +interface GifMoodSessionPageProps { + params: Promise<{ id: string }>; +} + +export default async function GifMoodSessionPage({ params }: GifMoodSessionPageProps) { + const { id } = await params; + const authSession = await auth(); + + if (!authSession?.user?.id) { + return null; + } + + const session = await getGifMoodSessionById(id, authSession.user.id); + + if (!session) { + notFound(); + } + + const userTeams = await getUserTeams(authSession.user.id); + + return ( +
+ {/* Header */} +
+
+ + {getWorkshop('gif-mood').labelShort} + + / + {session.title} + {!session.isOwner && ( + + Partagé par {session.user.name || session.user.email} + + )} +
+ +
+
+ +
+
+ {session.items.length} GIFs + + {new Date(session.date).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} + +
+
+
+ + {/* Live Wrapper + Board */} + + + +
+ ); +} diff --git a/src/app/gif-mood/new/page.tsx b/src/app/gif-mood/new/page.tsx new file mode 100644 index 0000000..e86ea00 --- /dev/null +++ b/src/app/gif-mood/new/page.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Button, + Input, +} from '@/components/ui'; +import { createGifMoodSession } from '@/actions/gif-mood'; +import { GIF_MOOD_MAX_ITEMS } from '@/lib/types'; + +export default function NewGifMoodPage() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]); + const [title, setTitle] = useState( + () => `GIF Mood - ${new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}` + ); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + + const date = selectedDate ? new Date(selectedDate) : undefined; + + if (!title) { + setError('Veuillez remplir le titre'); + setLoading(false); + return; + } + + const result = await createGifMoodSession({ title, date }); + + if (!result.success) { + setError(result.error || 'Une erreur est survenue'); + setLoading(false); + return; + } + + router.push(`/gif-mood/${result.data?.id}`); + } + + return ( +
+ + + + 🎭 + Nouveau GIF Mood Board + + + Créez un tableau de bord GIF pour exprimer et partager votre humeur avec votre équipe + + + + +
+ {error && ( +
+ {error} +
+ )} + + setTitle(e.target.value)} + required + /> + +
+ + setSelectedDate(e.target.value)} + required + className="w-full rounded-lg border border-border bg-input px-3 py-2 text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20" + /> +
+ +
+

Comment ça marche ?

+
    +
  1. Partagez la session avec votre équipe
  2. +
  3. Chaque membre peut ajouter jusqu'à {GIF_MOOD_MAX_ITEMS} GIFs
  4. +
  5. Ajoutez une note à chaque GIF pour expliquer votre humeur
  6. +
  7. Les GIFs apparaissent en temps réel pour tous les membres
  8. +
+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0cabc48..ff4e3eb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next'; -import { Geist, Geist_Mono } from 'next/font/google'; +import { Geist, Geist_Mono, Caveat } from 'next/font/google'; import './globals.css'; import { Providers } from '@/components/Providers'; import { Header } from '@/components/layout/Header'; @@ -14,6 +14,11 @@ const geistMono = Geist_Mono({ subsets: ['latin'], }); +const caveat = Caveat({ + variable: '--font-caveat', + subsets: ['latin'], +}); + export const metadata: Metadata = { title: 'Workshop Manager', description: "Application de gestion d'ateliers pour entretiens managériaux", @@ -37,7 +42,7 @@ export default function RootLayout({ }} /> - +
diff --git a/src/app/page.tsx b/src/app/page.tsx index f1fba59..500910b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -565,6 +565,117 @@ export default function Home() {
+ {/* GIF Mood Board Deep Dive Section */} +
+
+ 🎞️ +
+

GIF Mood Board

+

+ Exprimez l'humeur de l'équipe en images +

+
+
+ +
+ {/* Why */} +
+

+ 💡 + Pourquoi un GIF Mood Board ? +

+

+ Les GIFs sont un langage universel pour exprimer ce que les mots peinent parfois à + traduire. Le GIF Mood Board transforme un rituel d'équipe en moment visuel et + ludique, idéal pour les rétrospectives, les stand-ups ou tout point d'équipe + récurrent. +

+
    +
  • + + Rendre les rétrospectives plus vivantes et engageantes +
  • +
  • + + Libérer l'expression émotionnelle avec humour et créativité +
  • +
  • + + Voir en un coup d'œil l'humeur collective de l'équipe +
  • +
  • + + Briser la glace et créer de la cohésion d'équipe +
  • +
+
+ + {/* What's in it */} +
+

+ + Ce que chaque membre peut faire +

+
+ + + + +
+
+ + {/* How it works */} +
+

+ ⚙️ + Comment ça marche ? +

+
+ + + + +
+
+
+
+ {/* OKRs Deep Dive Section */}
diff --git a/src/app/sessions/WorkshopTabs.tsx b/src/app/sessions/WorkshopTabs.tsx index 6128b12..a9d574c 100644 --- a/src/app/sessions/WorkshopTabs.tsx +++ b/src/app/sessions/WorkshopTabs.tsx @@ -17,6 +17,7 @@ import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving import { deleteYearReviewSession, updateYearReviewSession } from '@/actions/year-review'; import { deleteWeeklyCheckInSession, updateWeeklyCheckInSession } from '@/actions/weekly-checkin'; import { deleteWeatherSession, updateWeatherSession } from '@/actions/weather'; +import { deleteGifMoodSession, updateGifMoodSession } from '@/actions/gif-mood'; import { type WorkshopTabType, type WorkshopTypeId, @@ -125,12 +126,28 @@ interface WeatherSession { canEdit?: boolean; } +interface GifMoodSession { + id: string; + title: string; + date: Date; + updatedAt: Date; + isOwner: boolean; + role: 'OWNER' | 'VIEWER' | 'EDITOR'; + user: { id: string; name: string | null; email: string }; + shares: Share[]; + _count: { items: number }; + workshopType: 'gif-mood'; + isTeamCollab?: true; + canEdit?: boolean; +} + type AnySession = | SwotSession | MotivatorSession | YearReviewSession | WeeklyCheckInSession - | WeatherSession; + | WeatherSession + | GifMoodSession; interface WorkshopTabsProps { swotSessions: SwotSession[]; @@ -138,6 +155,7 @@ interface WorkshopTabsProps { yearReviewSessions: YearReviewSession[]; weeklyCheckInSessions: WeeklyCheckInSession[]; weatherSessions: WeatherSession[]; + gifMoodSessions: GifMoodSession[]; teamCollabSessions?: (AnySession & { isTeamCollab?: true })[]; } @@ -150,7 +168,6 @@ function getResolvedCollaborator(session: AnySession): ResolvedCollaborator { } else if (session.workshopType === 'weekly-checkin') { return (session as WeeklyCheckInSession).resolvedParticipant; } else if (session.workshopType === 'weather') { - // For weather sessions, use the owner as the "participant" since it's a personal weather const weatherSession = session as WeatherSession; return { raw: weatherSession.user.name || weatherSession.user.email, @@ -160,6 +177,16 @@ function getResolvedCollaborator(session: AnySession): ResolvedCollaborator { name: weatherSession.user.name, }, }; + } else if (session.workshopType === 'gif-mood') { + const gifMoodSession = session as GifMoodSession; + return { + raw: gifMoodSession.user.name || gifMoodSession.user.email, + matchedUser: { + id: gifMoodSession.user.id, + email: gifMoodSession.user.email, + name: gifMoodSession.user.name, + }, + }; } else { return (session as MotivatorSession).resolvedParticipant; } @@ -205,6 +232,7 @@ export function WorkshopTabs({ yearReviewSessions, weeklyCheckInSessions, weatherSessions, + gifMoodSessions, teamCollabSessions = [], }: WorkshopTabsProps) { const searchParams = useSearchParams(); @@ -235,6 +263,7 @@ export function WorkshopTabs({ ...yearReviewSessions, ...weeklyCheckInSessions, ...weatherSessions, + ...gifMoodSessions, ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); // Filter based on active tab (for non-byPerson tabs) @@ -251,7 +280,9 @@ export function WorkshopTabs({ ? yearReviewSessions : activeTab === 'weekly-checkin' ? weeklyCheckInSessions - : weatherSessions; + : activeTab === 'gif-mood' + ? gifMoodSessions + : weatherSessions; // Separate by ownership (for non-team tab: owned, shared, teamCollab) const ownedSessions = filteredSessions.filter((s) => s.isOwner); @@ -305,6 +336,7 @@ export function WorkshopTabs({ 'year-review': yearReviewSessions.length, 'weekly-checkin': weeklyCheckInSessions.length, weather: weatherSessions.length, + 'gif-mood': gifMoodSessions.length, team: teamCollabSessions.length, }} /> @@ -551,7 +583,7 @@ function SessionCard({ ? (session as SwotSession).collaborator : session.workshopType === 'year-review' ? (session as YearReviewSession).participant - : session.workshopType === 'weather' + : session.workshopType === 'weather' || session.workshopType === 'gif-mood' ? '' : (session as MotivatorSession).participant ); @@ -561,6 +593,7 @@ function SessionCard({ const isYearReview = session.workshopType === 'year-review'; const isWeeklyCheckIn = session.workshopType === 'weekly-checkin'; const isWeather = session.workshopType === 'weather'; + const isGifMood = session.workshopType === 'gif-mood'; const href = getSessionPath(session.workshopType as WorkshopTypeId, session.id); const participant = isSwot ? (session as SwotSession).collaborator @@ -570,7 +603,9 @@ function SessionCard({ ? (session as WeeklyCheckInSession).participant : isWeather ? (session as WeatherSession).user.name || (session as WeatherSession).user.email - : (session as MotivatorSession).participant; + : isGifMood + ? (session as GifMoodSession).user.name || (session as GifMoodSession).user.email + : (session as MotivatorSession).participant; const accentColor = workshop.accentColor; const handleDelete = () => { @@ -583,7 +618,9 @@ function SessionCard({ ? await deleteWeeklyCheckInSession(session.id) : isWeather ? await deleteWeatherSession(session.id) - : await deleteMotivatorSession(session.id); + : isGifMood + ? await deleteGifMoodSession(session.id) + : await deleteMotivatorSession(session.id); if (result.success) { setShowDeleteModal(false); @@ -609,10 +646,12 @@ function SessionCard({ }) : isWeather ? await updateWeatherSession(session.id, { title: editTitle }) - : await updateMotivatorSession(session.id, { - title: editTitle, - participant: editParticipant, - }); + : isGifMood + ? await updateGifMoodSession(session.id, { title: editTitle }) + : await updateMotivatorSession(session.id, { + title: editTitle, + participant: editParticipant, + }); if (result.success) { setShowEditModal(false); @@ -705,6 +744,17 @@ function SessionCard({ })} + ) : isGifMood ? ( + <> + {(session as GifMoodSession)._count.items} GIFs + · + + {new Date((session as GifMoodSession).date).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + })} + + ) : ( {(session as MotivatorSession)._count.cards}/10 )} @@ -834,7 +884,7 @@ function SessionCard({ > {editParticipantLabel} - {!isWeather && ( + {!isWeather && !isGifMood && ( diff --git a/src/app/sessions/page.tsx b/src/app/sessions/page.tsx index 3e8e778..b721d75 100644 --- a/src/app/sessions/page.tsx +++ b/src/app/sessions/page.tsx @@ -20,6 +20,10 @@ import { getWeatherSessionsByUserId, getTeamCollaboratorSessionsForAdmin as getTeamWeatherSessions, } from '@/services/weather'; +import { + getGifMoodSessionsByUserId, + getTeamCollaboratorSessionsForAdmin as getTeamGifMoodSessions, +} from '@/services/gif-mood'; import { Card } from '@/components/ui'; import { withWorkshopType } from '@/lib/workshops'; import { WorkshopTabs } from './WorkshopTabs'; @@ -58,22 +62,26 @@ export default async function SessionsPage() { yearReviewSessions, weeklyCheckInSessions, weatherSessions, + gifMoodSessions, teamSwotSessions, teamMotivatorSessions, teamYearReviewSessions, teamWeeklyCheckInSessions, teamWeatherSessions, + teamGifMoodSessions, ] = await Promise.all([ getSessionsByUserId(session.user.id), getMotivatorSessionsByUserId(session.user.id), getYearReviewSessionsByUserId(session.user.id), getWeeklyCheckInSessionsByUserId(session.user.id), getWeatherSessionsByUserId(session.user.id), + getGifMoodSessionsByUserId(session.user.id), getTeamSwotSessions(session.user.id), getTeamMotivatorSessions(session.user.id), getTeamYearReviewSessions(session.user.id), getTeamWeeklyCheckInSessions(session.user.id), getTeamWeatherSessions(session.user.id), + getTeamGifMoodSessions(session.user.id), ]); // Add workshopType to each session for unified display @@ -82,12 +90,14 @@ export default async function SessionsPage() { const allYearReviewSessions = withWorkshopType(yearReviewSessions, 'year-review'); const allWeeklyCheckInSessions = withWorkshopType(weeklyCheckInSessions, 'weekly-checkin'); const allWeatherSessions = withWorkshopType(weatherSessions, 'weather'); + const allGifMoodSessions = withWorkshopType(gifMoodSessions, 'gif-mood'); const teamSwotWithType = withWorkshopType(teamSwotSessions, 'swot'); const teamMotivatorWithType = withWorkshopType(teamMotivatorSessions, 'motivators'); const teamYearReviewWithType = withWorkshopType(teamYearReviewSessions, 'year-review'); const teamWeeklyCheckInWithType = withWorkshopType(teamWeeklyCheckInSessions, 'weekly-checkin'); const teamWeatherWithType = withWorkshopType(teamWeatherSessions, 'weather'); + const teamGifMoodWithType = withWorkshopType(teamGifMoodSessions, 'gif-mood'); // Combine and sort by updatedAt const allSessions = [ @@ -96,6 +106,7 @@ export default async function SessionsPage() { ...allYearReviewSessions, ...allWeeklyCheckInSessions, ...allWeatherSessions, + ...allGifMoodSessions, ].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); const hasNoSessions = allSessions.length === 0; @@ -135,12 +146,14 @@ export default async function SessionsPage() { yearReviewSessions={allYearReviewSessions} weeklyCheckInSessions={allWeeklyCheckInSessions} weatherSessions={allWeatherSessions} + gifMoodSessions={allGifMoodSessions} teamCollabSessions={[ ...teamSwotWithType, ...teamMotivatorWithType, ...teamYearReviewWithType, ...teamWeeklyCheckInWithType, ...teamWeatherWithType, + ...teamGifMoodWithType, ]} /> diff --git a/src/components/collaboration/BaseSessionLiveWrapper.tsx b/src/components/collaboration/BaseSessionLiveWrapper.tsx index d6ae3c5..8b5a52e 100644 --- a/src/components/collaboration/BaseSessionLiveWrapper.tsx +++ b/src/components/collaboration/BaseSessionLiveWrapper.tsx @@ -7,7 +7,7 @@ import { ShareModal } from './ShareModal'; import type { ShareRole } from '@prisma/client'; import type { TeamWithMembers, Share } from '@/lib/share-utils'; -export type LiveApiPath = 'sessions' | 'motivators' | 'weather' | 'year-review' | 'weekly-checkin'; +export type LiveApiPath = 'sessions' | 'motivators' | 'weather' | 'year-review' | 'weekly-checkin' | 'gif-mood'; interface ShareModalConfig { title: string; diff --git a/src/components/gif-mood/GifMoodAddForm.tsx b/src/components/gif-mood/GifMoodAddForm.tsx new file mode 100644 index 0000000..fa57d9d --- /dev/null +++ b/src/components/gif-mood/GifMoodAddForm.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { Button, Input } from '@/components/ui'; +import { addGifMoodItem } from '@/actions/gif-mood'; +import { GIF_MOOD_MAX_ITEMS } from '@/lib/types'; + +interface GifMoodAddFormProps { + sessionId: string; + currentCount: number; +} + +export function GifMoodAddForm({ sessionId, currentCount }: GifMoodAddFormProps) { + const [open, setOpen] = useState(false); + const [gifUrl, setGifUrl] = useState(''); + const [note, setNote] = useState(''); + const [previewUrl, setPreviewUrl] = useState(''); + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const remaining = GIF_MOOD_MAX_ITEMS - currentCount; + + function handleUrlBlur() { + const trimmed = gifUrl.trim(); + if (!trimmed) { setPreviewUrl(''); return; } + try { new URL(trimmed); setPreviewUrl(trimmed); setError(null); } + catch { setPreviewUrl(''); } + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + const trimmed = gifUrl.trim(); + if (!trimmed) { setError("L'URL est requise"); return; } + try { new URL(trimmed); } catch { setError('URL invalide'); return; } + + startTransition(async () => { + const result = await addGifMoodItem(sessionId, { + gifUrl: trimmed, + note: note.trim() || undefined, + }); + if (result.success) { + setGifUrl(''); setNote(''); setPreviewUrl(''); setOpen(false); + } else { + setError(result.error || "Erreur lors de l'ajout"); + } + }); + } + + // Collapsed state — placeholder card + if (!open) { + return ( + + ); + } + + // Expanded form + return ( +
+
+ Ajouter un GIF + +
+ + {error && ( +

{error}

+ )} + + setGifUrl(e.target.value)} + onBlur={handleUrlBlur} + placeholder="https://media.giphy.com/…" + disabled={isPending} + /> + + {previewUrl && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Aperçu setPreviewUrl('')} + /> +
+ )} + +
+ +