feat: add GIF Mood Board workshop
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m5s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 10:04:56 +01:00
parent 7c68fb81e3
commit 766f3d5a59
21 changed files with 2032 additions and 15 deletions

View File

@@ -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

View File

@@ -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");

View File

@@ -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])
}

340
src/actions/gif-mood.ts Normal file
View File

@@ -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' };
}
}

View File

@@ -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<string, Set<ReadableStreamDefaultController>>();
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);
}
}

View File

@@ -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 (
<main className="mx-auto max-w-7xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-muted mb-2">
<Link href={getSessionsTabUrl('gif-mood')} className="hover:text-foreground">
{getWorkshop('gif-mood').labelShort}
</Link>
<span>/</span>
<span className="text-foreground">{session.title}</span>
{!session.isOwner && (
<Badge variant="accent" className="ml-2">
Partagé par {session.user.name || session.user.email}
</Badge>
)}
</div>
<div className="flex items-start justify-between">
<div>
<EditableGifMoodTitle
sessionId={session.id}
initialTitle={session.title}
canEdit={session.canEdit}
/>
</div>
<div className="flex items-center gap-3">
<Badge variant="primary">{session.items.length} GIFs</Badge>
<span className="text-sm text-muted">
{new Date(session.date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</span>
</div>
</div>
</div>
{/* Live Wrapper + Board */}
<GifMoodLiveWrapper
sessionId={session.id}
sessionTitle={session.title}
currentUserId={authSession.user.id}
shares={session.shares}
isOwner={session.isOwner}
canEdit={session.canEdit}
userTeams={userTeams}
>
<GifMoodBoard
sessionId={session.id}
currentUserId={authSession.user.id}
items={session.items}
shares={session.shares}
owner={{
id: session.user.id,
name: session.user.name ?? null,
email: session.user.email ?? '',
}}
ratings={session.ratings}
canEdit={session.canEdit}
/>
</GifMoodLiveWrapper>
</main>
);
}

View File

@@ -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<string | null>(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<HTMLFormElement>) {
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 (
<main className="mx-auto max-w-2xl px-4 py-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span>🎭</span>
Nouveau GIF Mood Board
</CardTitle>
<CardDescription>
Créez un tableau de bord GIF pour exprimer et partager votre humeur avec votre équipe
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
<Input
label="Titre de la session"
name="title"
placeholder="Ex: GIF Mood - Mars 2026"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<div>
<label htmlFor="date" className="block text-sm font-medium text-foreground mb-1">
Date
</label>
<input
id="date"
name="date"
type="date"
value={selectedDate}
onChange={(e) => 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"
/>
</div>
<div className="rounded-lg border border-border bg-card-hover p-4">
<h3 className="font-medium text-foreground mb-2">Comment ça marche ?</h3>
<ol className="text-sm text-muted space-y-1 list-decimal list-inside">
<li>Partagez la session avec votre équipe</li>
<li>Chaque membre peut ajouter jusqu&apos;à {GIF_MOOD_MAX_ITEMS} GIFs</li>
<li>Ajoutez une note à chaque GIF pour expliquer votre humeur</li>
<li>Les GIFs apparaissent en temps réel pour tous les membres</li>
</ol>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={loading}
>
Annuler
</Button>
<Button type="submit" loading={loading} className="flex-1">
Créer le GIF Mood Board
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}

View File

@@ -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({
}}
/>
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<body className={`${geistSans.variable} ${geistMono.variable} ${caveat.variable} antialiased`}>
<Providers>
<div className="min-h-screen bg-background">
<Header />

View File

@@ -565,6 +565,117 @@ export default function Home() {
</div>
</section>
{/* GIF Mood Board Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">
<span className="text-4xl">🎞</span>
<div>
<h2 className="text-3xl font-bold text-foreground">GIF Mood Board</h2>
<p className="font-medium" style={{ color: '#ec4899' }}>
Exprimez l&apos;humeur de l&apos;équipe en images
</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Why */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl">💡</span>
Pourquoi un GIF Mood Board ?
</h3>
<p className="text-muted mb-4">
Les GIFs sont un langage universel pour exprimer ce que les mots peinent parfois à
traduire. Le GIF Mood Board transforme un rituel d&apos;équipe en moment visuel et
ludique, idéal pour les rétrospectives, les stand-ups ou tout point d&apos;équipe
récurrent.
</p>
<ul className="space-y-2 text-sm text-muted">
<li className="flex items-start gap-2">
<span style={{ color: '#ec4899' }}></span>
Rendre les rétrospectives plus vivantes et engageantes
</li>
<li className="flex items-start gap-2">
<span style={{ color: '#ec4899' }}></span>
Libérer l&apos;expression émotionnelle avec humour et créativité
</li>
<li className="flex items-start gap-2">
<span style={{ color: '#ec4899' }}></span>
Voir en un coup d&apos;œil l&apos;humeur collective de l&apos;équipe
</li>
<li className="flex items-start gap-2">
<span style={{ color: '#ec4899' }}></span>
Briser la glace et créer de la cohésion d&apos;équipe
</li>
</ul>
</div>
{/* What's in it */}
<div className="rounded-xl border border-border bg-card p-6">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Ce que chaque membre peut faire
</h3>
<div className="space-y-3">
<FeaturePill
icon="🎞️"
name="Jusqu'à 5 GIFs par session"
color="#ec4899"
description="Choisissez les GIFs qui reflètent le mieux votre humeur du moment"
/>
<FeaturePill
icon="✍️"
name="Notes manuscrites"
color="#8b5cf6"
description="Ajoutez un contexte ou une explication à chaque GIF"
/>
<FeaturePill
icon="⭐"
name="Note de la semaine sur 5"
color="#f59e0b"
description="Résumez votre semaine en une note globale visible par toute l'équipe"
/>
<FeaturePill
icon="⚡"
name="Mise à jour en temps réel"
color="#10b981"
description="Voir les GIFs des collègues apparaître au fur et à mesure"
/>
</div>
</div>
{/* How it works */}
<div className="rounded-xl border border-border bg-card p-6 lg:col-span-2">
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
<span className="text-2xl"></span>
Comment ça marche ?
</h3>
<div className="grid md:grid-cols-4 gap-4">
<StepCard
number={1}
title="Créer la session"
description="Le manager crée une session GIF Mood Board et la partage avec son équipe"
/>
<StepCard
number={2}
title="Choisir ses GIFs"
description="Chaque membre ajoute jusqu'à 5 GIFs (Giphy, Tenor, ou toute URL d'image) pour exprimer son humeur"
/>
<StepCard
number={3}
title="Annoter et noter"
description="Ajoutez une note manuscrite à chaque GIF et une note de semaine sur 5 étoiles"
/>
<StepCard
number={4}
title="Partager et discuter"
description="Parcourez le board ensemble, repérez les GIFs qui reflètent le mieux l'humeur de l'équipe et lancez la discussion"
/>
</div>
</div>
</div>
</section>
{/* OKRs Deep Dive Section */}
<section className="mb-16">
<div className="flex items-center gap-3 mb-8">

View File

@@ -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,6 +280,8 @@ export function WorkshopTabs({
? yearReviewSessions
: activeTab === 'weekly-checkin'
? weeklyCheckInSessions
: activeTab === 'gif-mood'
? gifMoodSessions
: weatherSessions;
// Separate by ownership (for non-team tab: owned, shared, teamCollab)
@@ -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,6 +603,8 @@ function SessionCard({
? (session as WeeklyCheckInSession).participant
: isWeather
? (session as WeatherSession).user.name || (session as WeatherSession).user.email
: isGifMood
? (session as GifMoodSession).user.name || (session as GifMoodSession).user.email
: (session as MotivatorSession).participant;
const accentColor = workshop.accentColor;
@@ -583,6 +618,8 @@ function SessionCard({
? await deleteWeeklyCheckInSession(session.id)
: isWeather
? await deleteWeatherSession(session.id)
: isGifMood
? await deleteGifMoodSession(session.id)
: await deleteMotivatorSession(session.id);
if (result.success) {
@@ -609,6 +646,8 @@ function SessionCard({
})
: isWeather
? await updateWeatherSession(session.id, { title: editTitle })
: isGifMood
? await updateGifMoodSession(session.id, { title: editTitle })
: await updateMotivatorSession(session.id, {
title: editTitle,
participant: editParticipant,
@@ -705,6 +744,17 @@ function SessionCard({
})}
</span>
</>
) : isGifMood ? (
<>
<span>{(session as GifMoodSession)._count.items} GIFs</span>
<span>·</span>
<span>
{new Date((session as GifMoodSession).date).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
})}
</span>
</>
) : (
<span>{(session as MotivatorSession)._count.cards}/10</span>
)}
@@ -834,7 +884,7 @@ function SessionCard({
>
{editParticipantLabel}
</label>
{!isWeather && (
{!isWeather && !isGifMood && (
<Input
id="edit-participant"
value={editParticipant}
@@ -855,7 +905,7 @@ function SessionCard({
</Button>
<Button
type="submit"
disabled={isPending || !editTitle.trim() || (!isWeather && !editParticipant.trim())}
disabled={isPending || !editTitle.trim() || (!isWeather && !isGifMood && !editParticipant.trim())}
>
{isPending ? 'Enregistrement...' : 'Enregistrer'}
</Button>

View File

@@ -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,
]}
/>
</Suspense>

View File

@@ -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;

View File

@@ -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<string | null>(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<HTMLFormElement>) {
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 (
<button
onClick={() => setOpen(true)}
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/50 hover:border-primary/40 hover:bg-primary/5 min-h-[120px] transition-all duration-200 text-muted hover:text-primary w-full"
>
<span className="text-2xl opacity-40 group-hover:opacity-70 transition-opacity"></span>
<span className="text-xs font-medium">{remaining} slot{remaining !== 1 ? 's' : ''} restant{remaining !== 1 ? 's' : ''}</span>
</button>
);
}
// Expanded form
return (
<form
onSubmit={handleSubmit}
className="rounded-2xl border border-border bg-card shadow-sm p-4 space-y-3"
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">Ajouter un GIF</span>
<button
type="button"
onClick={() => { setOpen(false); setError(null); setPreviewUrl(''); }}
className="text-muted hover:text-foreground transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
<Input
label="URL du GIF"
value={gifUrl}
onChange={(e) => setGifUrl(e.target.value)}
onBlur={handleUrlBlur}
placeholder="https://media.giphy.com/…"
disabled={isPending}
/>
{previewUrl && (
<div className="rounded-xl overflow-hidden border border-border/50">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt="Aperçu"
className="w-full object-contain max-h-40"
onError={() => setPreviewUrl('')}
/>
</div>
)}
<div>
<label className="block text-xs font-medium text-muted mb-1">
Note <span className="font-normal opacity-60">(optionnelle)</span>
</label>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="Ce que ce GIF exprime…"
rows={2}
disabled={isPending}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 resize-none"
/>
</div>
<Button type="submit" loading={isPending} className="w-full" size="sm">
Ajouter
</Button>
</form>
);
}

View File

@@ -0,0 +1,273 @@
'use client';
import { useMemo, useState, useTransition } from 'react';
import { setGifMoodUserRating } from '@/actions/gif-mood';
import { Avatar } from '@/components/ui/Avatar';
import { GifMoodCard } from './GifMoodCard';
import { GifMoodAddForm } from './GifMoodAddForm';
import { GIF_MOOD_MAX_ITEMS } from '@/lib/types';
interface GifMoodItem {
id: string;
gifUrl: string;
note: string | null;
order: number;
userId: string;
user: {
id: string;
name: string | null;
email: string;
};
}
interface Share {
id: string;
userId: string;
user: {
id: string;
name: string | null;
email: string;
};
}
interface GifMoodBoardProps {
sessionId: string;
currentUserId: string;
items: GifMoodItem[];
shares: Share[];
owner: {
id: string;
name: string | null;
email: string;
};
ratings: { userId: string; rating: number }[];
canEdit: boolean;
}
function WeekRating({
sessionId,
isCurrentUser,
canEdit,
initialRating,
}: {
sessionId: string;
isCurrentUser: boolean;
canEdit: boolean;
initialRating: number | null;
}) {
const [prevInitialRating, setPrevInitialRating] = useState(initialRating);
const [rating, setRating] = useState<number | null>(initialRating);
const [hovered, setHovered] = useState<number | null>(null);
const [isPending, startTransition] = useTransition();
if (prevInitialRating !== initialRating) {
setPrevInitialRating(initialRating);
setRating(initialRating);
}
const interactive = isCurrentUser && canEdit;
const display = hovered ?? rating;
function handleClick(n: number) {
if (!interactive) return;
setRating(n);
startTransition(async () => {
await setGifMoodUserRating(sessionId, n);
});
}
return (
<div
className="flex items-center gap-0.5"
onMouseLeave={() => setHovered(null)}
>
{[1, 2, 3, 4, 5].map((n) => {
const filled = display !== null && n <= display;
return (
<button
key={n}
type="button"
onClick={() => handleClick(n)}
onMouseEnter={() => interactive && setHovered(n)}
disabled={!interactive || isPending}
className={`transition-all duration-100 ${interactive ? 'cursor-pointer hover:scale-125' : 'cursor-default'}`}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
className={`transition-colors duration-100 ${
filled ? 'text-amber-400' : 'text-border'
}`}
>
<path
d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"
fill="currentColor"
stroke="currentColor"
strokeWidth="1"
strokeLinejoin="round"
/>
</svg>
</button>
);
})}
</div>
);
}
// Subtle accent colors for each user section
const SECTION_COLORS = ['#ec4899', '#8b5cf6', '#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
const GRID_COLS: Record<number, string> = {
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4',
5: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5',
};
function GridIcon({ cols }: { cols: number }) {
return (
<svg width="20" height="14" viewBox="0 0 20 14" fill="none" aria-hidden>
{Array.from({ length: cols }).map((_, i) => {
const w = (20 - (cols - 1) * 2) / cols;
const x = i * (w + 2);
return (
<g key={i}>
<rect x={x} y={0} width={w} height={6} rx={1} fill="currentColor" opacity={0.7} />
<rect x={x} y={8} width={w} height={6} rx={1} fill="currentColor" opacity={0.4} />
</g>
);
})}
</svg>
);
}
export function GifMoodBoard({
sessionId,
currentUserId,
items,
shares,
owner,
ratings,
canEdit,
}: GifMoodBoardProps) {
const [cols, setCols] = useState(5);
const allUsers = useMemo(() => {
const map = new Map<string, { id: string; name: string | null; email: string }>();
map.set(owner.id, owner);
shares.forEach((s) => map.set(s.userId, s.user));
return Array.from(map.values());
}, [owner, shares]);
const itemsByUser = useMemo(() => {
const map = new Map<string, GifMoodItem[]>();
items.forEach((item) => {
const existing = map.get(item.userId) ?? [];
existing.push(item);
map.set(item.userId, existing);
});
return map;
}, [items]);
const sortedUsers = useMemo(() => {
return [...allUsers].sort((a, b) => {
if (a.id === currentUserId) return -1;
if (b.id === currentUserId) return 1;
if (a.id === owner.id) return -1;
if (b.id === owner.id) return 1;
return (a.name || a.email).localeCompare(b.name || b.email, 'fr');
});
}, [allUsers, currentUserId, owner.id]);
return (
<div className="space-y-10">
{/* Column size control */}
<div className="flex justify-end">
<div className="inline-flex items-center gap-1 rounded-xl border border-border bg-card p-1">
{[3, 4, 5].map((n) => (
<button
key={n}
onClick={() => setCols(n)}
className={`flex items-center justify-center rounded-lg px-2.5 py-1.5 transition-all ${
cols === n
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted hover:text-foreground hover:bg-card-hover'
}`}
title={`${n} colonnes`}
>
<GridIcon cols={n} />
</button>
))}
</div>
</div>
{sortedUsers.map((user, index) => {
const userItems = itemsByUser.get(user.id) ?? [];
const isCurrentUser = user.id === currentUserId;
const canAdd = canEdit && isCurrentUser && userItems.length < GIF_MOOD_MAX_ITEMS;
const accentColor = SECTION_COLORS[index % SECTION_COLORS.length];
const userRating = ratings.find((r) => r.userId === user.id)?.rating ?? null;
return (
<section key={user.id}>
{/* Section header */}
<div className="flex items-center gap-4 mb-5">
{/* Colored accent bar */}
<div className="w-1 h-8 rounded-full shrink-0" style={{ backgroundColor: accentColor }} />
<Avatar email={user.email} name={user.name} size={36} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-foreground truncate">
{user.name || user.email}
</span>
{isCurrentUser && (
<span className="text-xs text-muted bg-card-hover px-2 py-0.5 rounded-full border border-border">
vous
</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<p className="text-xs text-muted">
{userItems.length} / {GIF_MOOD_MAX_ITEMS} GIF{userItems.length !== 1 ? 's' : ''}
</p>
<WeekRating
sessionId={sessionId}
isCurrentUser={isCurrentUser}
canEdit={canEdit}
initialRating={userRating}
/>
</div>
</div>
</div>
{/* Grid */}
<div className={`grid ${GRID_COLS[cols]} gap-4 items-start`}>
{userItems.map((item) => (
<GifMoodCard
key={item.id}
sessionId={sessionId}
item={item}
currentUserId={currentUserId}
canEdit={canEdit}
/>
))}
{/* Add form slot */}
{canAdd && (
<GifMoodAddForm sessionId={sessionId} currentCount={userItems.length} />
)}
{/* Empty state */}
{!canAdd && userItems.length === 0 && (
<div className="col-span-full flex items-center justify-center rounded-2xl border border-dashed border-border/60 py-10">
<p className="text-sm text-muted/60">Aucun GIF pour le moment</p>
</div>
)}
</div>
</section>
);
})}
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { memo, useState, useTransition } from 'react';
import { updateGifMoodItem, deleteGifMoodItem } from '@/actions/gif-mood';
interface GifMoodCardProps {
sessionId: string;
item: {
id: string;
gifUrl: string;
note: string | null;
userId: string;
};
currentUserId: string;
canEdit: boolean;
}
export const GifMoodCard = memo(function GifMoodCard({
sessionId,
item,
currentUserId,
canEdit,
}: GifMoodCardProps) {
const [note, setNote] = useState(item.note || '');
const [itemVersion, setItemVersion] = useState(item);
const [isPending, startTransition] = useTransition();
const [imgError, setImgError] = useState(false);
if (itemVersion !== item) {
setItemVersion(item);
setNote(item.note || '');
}
const isOwner = item.userId === currentUserId;
const canEditThis = canEdit && isOwner;
function handleNoteBlur() {
if (!canEditThis) return;
startTransition(async () => {
await updateGifMoodItem(sessionId, item.id, { note: note.trim() || undefined });
});
}
function handleDelete() {
if (!canEditThis) return;
startTransition(async () => {
await deleteGifMoodItem(sessionId, item.id);
});
}
return (
<div
className={`group relative rounded-2xl overflow-hidden bg-card shadow-sm hover:shadow-md transition-all duration-200 ${
isPending ? 'opacity-50 scale-95' : ''
}`}
>
{/* GIF */}
{imgError ? (
<div className="flex flex-col items-center justify-center gap-2 min-h-[120px] bg-card-hover">
<span className="text-3xl opacity-40">🖼</span>
<p className="text-xs text-muted">Image non disponible</p>
</div>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.gifUrl}
alt="GIF"
className="w-full block"
onError={() => setImgError(true)}
/>
)}
{/* Gradient overlay on hover (for delete affordance) */}
{canEditThis && (
<div className="absolute inset-0 bg-gradient-to-b from-black/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
)}
{/* Delete button — visible on hover */}
{canEditThis && (
<button
onClick={handleDelete}
disabled={isPending}
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 hover:bg-black/70 transition-all backdrop-blur-sm"
title="Supprimer ce GIF"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
{/* Note */}
{canEditThis ? (
<div className="px-3 pt-2 pb-3 bg-card">
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
onBlur={handleNoteBlur}
placeholder="Ajouter une note…"
rows={1}
className="w-full text-foreground/70 bg-transparent resize-none outline-none placeholder:text-muted/40 leading-relaxed text-center"
style={{ fontFamily: 'var(--font-caveat)', fontSize: '1.2rem' }}
/>
</div>
) : note ? (
<div className="px-3 py-2.5 bg-card">
<p className="text-foreground/70 leading-relaxed text-center" style={{ fontFamily: 'var(--font-caveat)', fontSize: '1.2rem' }}>{note}</p>
</div>
) : null}
</div>
);
});

View File

@@ -0,0 +1,62 @@
'use client';
import { BaseSessionLiveWrapper } from '@/components/collaboration/BaseSessionLiveWrapper';
import {
shareGifMoodSession,
shareGifMoodSessionToTeam,
removeGifMoodShare,
} from '@/actions/gif-mood';
import type { TeamWithMembers, Share } from '@/lib/share-utils';
interface GifMoodLiveWrapperProps {
sessionId: string;
sessionTitle: string;
currentUserId: string;
shares: Share[];
isOwner: boolean;
canEdit: boolean;
userTeams?: TeamWithMembers[];
children: React.ReactNode;
}
export function GifMoodLiveWrapper({
sessionId,
sessionTitle,
currentUserId,
shares,
isOwner,
canEdit,
userTeams = [],
children,
}: GifMoodLiveWrapperProps) {
return (
<BaseSessionLiveWrapper
sessionId={sessionId}
sessionTitle={sessionTitle}
currentUserId={currentUserId}
shares={shares}
isOwner={isOwner}
canEdit={canEdit}
userTeams={userTeams}
config={{
apiPath: 'gif-mood',
shareModal: {
title: 'Partager le GIF Mood Board',
sessionSubtitle: 'GIF Mood Board',
helpText: (
<>
<strong>Éditeur</strong> : peut ajouter ses GIFs et voir ceux des autres
<br />
<strong>Lecteur</strong> : peut uniquement consulter
</>
),
},
onShareWithEmail: (email, role) => shareGifMoodSession(sessionId, email, role),
onShareWithTeam: (teamId, role) => shareGifMoodSessionToTeam(sessionId, teamId, role),
onRemoveShare: (userId) => removeGifMoodShare(sessionId, userId),
}}
>
{children}
</BaseSessionLiveWrapper>
);
}

View File

@@ -0,0 +1,4 @@
export { GifMoodBoard } from './GifMoodBoard';
export { GifMoodCard } from './GifMoodCard';
export { GifMoodAddForm } from './GifMoodAddForm';
export { GifMoodLiveWrapper } from './GifMoodLiveWrapper';

View File

@@ -0,0 +1,28 @@
'use client';
import { EditableTitle } from './EditableTitle';
import { updateGifMoodSession } from '@/actions/gif-mood';
interface EditableGifMoodTitleProps {
sessionId: string;
initialTitle: string;
canEdit: boolean;
}
export function EditableGifMoodTitle({
sessionId,
initialTitle,
canEdit,
}: EditableGifMoodTitleProps) {
return (
<EditableTitle
sessionId={sessionId}
initialTitle={initialTitle}
canEdit={canEdit}
onUpdate={async (id, title) => {
const result = await updateGifMoodSession(id, { title });
return result;
}}
/>
);
}

View File

@@ -785,3 +785,50 @@ export const EMOTION_BY_TYPE: Record<Emotion, EmotionConfig> = EMOTIONS_CONFIG.r
},
{} as Record<Emotion, EmotionConfig>
);
// ============================================
// GIF Mood Board Workshop
// ============================================
export const GIF_MOOD_MAX_ITEMS = 5;
export interface GifMoodItem {
id: string;
gifUrl: string;
note: string | null;
order: number;
sessionId: string;
userId: string;
user: {
id: string;
name: string | null;
email: string;
};
createdAt: Date;
updatedAt: Date;
}
export interface GifMoodSession {
id: string;
title: string;
date: Date;
userId: string;
items: GifMoodItem[];
createdAt: Date;
updatedAt: Date;
}
export interface CreateGifMoodSessionInput {
title: string;
date?: Date;
}
export interface AddGifMoodItemInput {
gifUrl: string;
note?: string;
}
export interface UpdateGifMoodItemInput {
note?: string;
order?: number;
}

View File

@@ -9,6 +9,7 @@ export const WORKSHOP_TYPE_IDS = [
'year-review',
'weekly-checkin',
'weather',
'gif-mood',
] as const;
export type WorkshopTypeId = (typeof WORKSHOP_TYPE_IDS)[number];
@@ -158,6 +159,29 @@ export const WORKSHOPS: WorkshopConfig[] = [
],
},
},
{
id: 'gif-mood',
icon: '🎞️',
label: 'GIF Mood Board',
labelShort: 'GIF Mood',
cardLabel: 'GIF Mood',
description: 'Exprimez votre humeur en GIF',
path: '/gif-mood',
newPath: '/gif-mood/new',
accentColor: '#ec4899',
hasParticipant: false,
participantLabel: '',
home: {
tagline: 'Exprimez-vous en GIF',
description:
"Partagez jusqu'à 5 GIFs pour exprimer votre humeur du moment. Un format visuel et ludique pour rendre les rétrospectives d'équipe plus vivantes.",
features: [
"Jusqu'à 5 GIFs par personne par session",
'Notes pour contextualiser chaque GIF',
"Mise à jour en temps réel pour toute l'équipe",
],
},
},
];
export const WORKSHOP_BY_ID = Object.fromEntries(WORKSHOPS.map((w) => [w.id, w])) as Record<

258
src/services/gif-mood.ts Normal file
View File

@@ -0,0 +1,258 @@
import { prisma } from '@/services/database';
import { getTeamMemberIdsForAdminTeams } from '@/services/teams';
import { createSessionPermissionChecks } from '@/services/session-permissions';
import { createShareAndEventHandlers } from '@/services/session-share-events';
import {
mergeSessionsByUserId,
fetchTeamCollaboratorSessions,
getSessionByIdGeneric,
} from '@/services/session-queries';
import { GIF_MOOD_MAX_ITEMS } from '@/lib/types';
import type { ShareRole } from '@prisma/client';
const gifMoodInclude = {
user: { select: { id: true, name: true, email: true } },
shares: { include: { user: { select: { id: true, name: true, email: true } } } },
_count: { select: { items: true } },
};
// ============================================
// GifMood Session CRUD
// ============================================
export async function getGifMoodSessionsByUserId(userId: string) {
return mergeSessionsByUserId(
(uid) =>
prisma.gifMoodSession.findMany({
where: { userId: uid },
include: gifMoodInclude,
orderBy: { updatedAt: 'desc' },
}),
(uid) =>
prisma.gMSessionShare.findMany({
where: { userId: uid },
include: { session: { include: gifMoodInclude } },
}),
userId
);
}
export async function getTeamCollaboratorSessionsForAdmin(userId: string) {
return fetchTeamCollaboratorSessions(
(teamMemberIds, uid) =>
prisma.gifMoodSession.findMany({
where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } },
include: gifMoodInclude,
orderBy: { updatedAt: 'desc' },
}),
getTeamMemberIdsForAdminTeams,
userId
);
}
const gifMoodByIdInclude = {
user: { select: { id: true, name: true, email: true } },
items: {
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: { createdAt: 'asc' as const },
},
shares: { include: { user: { select: { id: true, name: true, email: true } } } },
ratings: { select: { userId: true, rating: true } },
};
export async function getGifMoodSessionById(sessionId: string, userId: string) {
return getSessionByIdGeneric(
sessionId,
userId,
(sid, uid) =>
prisma.gifMoodSession.findFirst({
where: { id: sid, OR: [{ userId: uid }, { shares: { some: { userId: uid } } }] },
include: gifMoodByIdInclude,
}),
(sid) => prisma.gifMoodSession.findUnique({ where: { id: sid }, include: gifMoodByIdInclude })
);
}
const gifMoodPermissions = createSessionPermissionChecks(prisma.gifMoodSession);
const gifMoodShareEvents = createShareAndEventHandlers<
'GIF_ADDED' | 'GIF_UPDATED' | 'GIF_DELETED' | 'SESSION_UPDATED'
>(
prisma.gifMoodSession,
prisma.gMSessionShare,
prisma.gMSessionEvent,
gifMoodPermissions.canAccess
);
export const canAccessGifMoodSession = gifMoodPermissions.canAccess;
export const canEditGifMoodSession = gifMoodPermissions.canEdit;
export const canDeleteGifMoodSession = gifMoodPermissions.canDelete;
export async function createGifMoodSession(
userId: string,
data: { title: string; date?: Date }
) {
return prisma.gifMoodSession.create({
data: {
...data,
date: data.date || new Date(),
userId,
},
include: gifMoodByIdInclude,
});
}
export async function updateGifMoodSession(
sessionId: string,
userId: string,
data: { title?: string; date?: Date }
) {
if (!(await canEditGifMoodSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.gifMoodSession.updateMany({
where: { id: sessionId },
data,
});
}
export async function deleteGifMoodSession(sessionId: string, userId: string) {
if (!(await canDeleteGifMoodSession(sessionId, userId))) {
return { count: 0 };
}
return prisma.gifMoodSession.deleteMany({
where: { id: sessionId },
});
}
// ============================================
// GifMood Item CRUD
// ============================================
export async function addGifMoodItem(
sessionId: string,
userId: string,
data: { gifUrl: string; note?: string }
) {
// Enforce max items per user per session
const count = await prisma.gifMoodItem.count({
where: { sessionId, userId },
});
if (count >= GIF_MOOD_MAX_ITEMS) {
throw new Error(`Maximum ${GIF_MOOD_MAX_ITEMS} GIFs par personne par session`);
}
// Set order to count (append at end)
return prisma.gifMoodItem.create({
data: {
sessionId,
userId,
gifUrl: data.gifUrl,
note: data.note ?? null,
order: count,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
}
export async function updateGifMoodItem(
itemId: string,
userId: string,
data: { note?: string; order?: number }
) {
return prisma.gifMoodItem.updateMany({
where: { id: itemId, userId },
data,
});
}
export async function deleteGifMoodItem(itemId: string, userId: string) {
return prisma.gifMoodItem.deleteMany({
where: { id: itemId, userId },
});
}
export async function upsertGifMoodUserRating(
sessionId: string,
userId: string,
rating: number
) {
return prisma.gifMoodUserRating.upsert({
where: { sessionId_userId: { sessionId, userId } },
update: { rating },
create: { sessionId, userId, rating },
});
}
// ============================================
// Session Sharing
// ============================================
export const shareGifMoodSession = gifMoodShareEvents.share;
export async function shareGifMoodSessionToTeam(
sessionId: string,
ownerId: string,
teamId: string,
role: ShareRole = 'EDITOR'
) {
const session = await prisma.gifMoodSession.findFirst({
where: { id: sessionId, userId: ownerId },
});
if (!session) {
throw new Error('Session not found or not owned');
}
const teamMembers = await prisma.teamMember.findMany({
where: { teamId },
include: {
user: { select: { id: true, name: true, email: true } },
},
});
if (teamMembers.length === 0) {
throw new Error('Team has no members');
}
const shares = await Promise.all(
teamMembers
.filter((tm) => tm.userId !== ownerId)
.map((tm) =>
prisma.gMSessionShare.upsert({
where: {
sessionId_userId: { sessionId, userId: tm.userId },
},
update: { role },
create: {
sessionId,
userId: tm.userId,
role,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
})
)
);
return shares;
}
export const removeGifMoodShare = gifMoodShareEvents.removeShare;
export const getGifMoodSessionShares = gifMoodShareEvents.getShares;
// ============================================
// Session Events (for real-time sync)
// ============================================
export type GifMoodSessionEventType =
| 'GIF_ADDED'
| 'GIF_UPDATED'
| 'GIF_DELETED'
| 'SESSION_UPDATED';
export const createGifMoodSessionEvent = gifMoodShareEvents.createEvent;
export const getGifMoodSessionEvents = gifMoodShareEvents.getEvents;
export const getLatestGifMoodEventTimestamp = gifMoodShareEvents.getLatestEventTimestamp;