feat(gif-mood): hide/reveal GIFs from other participants

- Add hidden field to GifMoodUserRating (schema + migration)
- Add setGifMoodUserHidden service + action with SSE broadcast
- Current user sees Cacher/Révéler toggle in their section header
- Other users see a locked placeholder with item count when hidden

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 16:35:43 +01:00
parent ab00627a09
commit e7ce98320d
5 changed files with 135 additions and 8 deletions

View File

@@ -0,0 +1,21 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_GifMoodUserRating" (
"id" TEXT NOT NULL PRIMARY KEY,
"sessionId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"rating" INTEGER,
"hidden" BOOLEAN NOT NULL DEFAULT false,
"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
);
INSERT INTO "new_GifMoodUserRating" ("createdAt", "id", "rating", "sessionId", "updatedAt", "userId") SELECT "createdAt", "id", "rating", "sessionId", "updatedAt", "userId" FROM "GifMoodUserRating";
DROP TABLE "GifMoodUserRating";
ALTER TABLE "new_GifMoodUserRating" RENAME TO "GifMoodUserRating";
CREATE INDEX "GifMoodUserRating_sessionId_idx" ON "GifMoodUserRating"("sessionId");
CREATE UNIQUE INDEX "GifMoodUserRating_sessionId_userId_key" ON "GifMoodUserRating"("sessionId", "userId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -561,7 +561,8 @@ model GifMoodUserRating {
session GifMoodSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
rating Int // 1-5
rating Int? // 1-5
hidden Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -310,6 +310,45 @@ export async function setGifMoodUserRating(sessionId: string, rating: number) {
}
}
export async function setGifMoodUserHidden(sessionId: string, hidden: boolean) {
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.setGifMoodUserHidden(sessionId, authSession.user.id, hidden);
const user = await getUserById(authSession.user.id);
if (user) {
const event = await gifMoodService.createGifMoodSessionEvent(
sessionId,
authSession.user.id,
'SESSION_UPDATED',
{ hidden, userId: authSession.user.id }
);
broadcastToGifMoodSession(sessionId, {
type: 'SESSION_UPDATED',
payload: { hidden, 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 hidden:', error);
return { success: false, error: 'Erreur lors de la mise à jour' };
}
}
// ============================================
// Sharing Actions
// ============================================

View File

@@ -16,7 +16,7 @@ import {
arrayMove,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { setGifMoodUserRating, reorderGifMoodItems } from '@/actions/gif-mood';
import { setGifMoodUserRating, reorderGifMoodItems, setGifMoodUserHidden } from '@/actions/gif-mood';
import { Avatar } from '@/components/ui/Avatar';
import { GifMoodCard } from './GifMoodCard';
import { GifMoodAddForm } from './GifMoodAddForm';
@@ -55,7 +55,7 @@ interface GifMoodBoardProps {
name: string | null;
email: string;
};
ratings: { userId: string; rating: number }[];
ratings: { userId: string; rating: number | null; hidden: boolean }[];
canEdit: boolean;
}
@@ -218,6 +218,7 @@ export function GifMoodBoard({
}: GifMoodBoardProps) {
const [cols, setCols] = useState(4);
const [, startReorderTransition] = useTransition();
const [, startHiddenTransition] = useTransition();
// Optimistic reorder state for the current user's items
const [optimisticItems, setOptimisticItems] = useState<GifMoodItem[]>([]);
@@ -304,7 +305,10 @@ export function GifMoodBoard({
isCurrentUser && optimisticItems.length > 0 ? optimisticItems : serverItems;
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;
const userRatingEntry = ratings.find((r) => r.userId === user.id);
const userRating = userRatingEntry?.rating ?? null;
const isHidden = userRatingEntry?.hidden ?? false;
const showHidden = !isCurrentUser && isHidden;
return (
<section key={user.id}>
@@ -338,10 +342,60 @@ export function GifMoodBoard({
/>
</div>
</div>
{/* Hide/reveal toggle — current user only */}
{isCurrentUser && canEdit && userItems.length > 0 && (
<button
onClick={() => {
startHiddenTransition(async () => {
await setGifMoodUserHidden(sessionId, !isHidden);
});
}}
className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border transition-all ${
isHidden
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted hover:text-foreground hover:border-foreground/30'
}`}
title={isHidden ? 'Révéler mes GIFs' : 'Cacher mes GIFs'}
>
{isHidden ? (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
Révéler
</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/>
<line x1="1" y1="1" x2="23" y2="23"/>
</svg>
Cacher
</>
)}
</button>
)}
</div>
{/* Hidden placeholder for other users */}
{showHidden ? (
<div className="flex items-center justify-center rounded-2xl border border-dashed border-border/60 py-10 gap-3">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-muted/50" aria-hidden>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
<p className="text-sm text-muted/60">
{userItems.length > 0
? `${userItems.length} GIF${userItems.length !== 1 ? 's' : ''} caché${userItems.length !== 1 ? 's' : ''}`
: 'GIFs cachés'}
</p>
</div>
) : null}
{/* Grid */}
{isCurrentUser && canEdit ? (
{!showHidden && isCurrentUser && canEdit ? (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@@ -372,7 +426,7 @@ export function GifMoodBoard({
</div>
</SortableContext>
</DndContext>
) : (
) : !showHidden ? (
<div className={`grid ${GRID_COLS[cols]} gap-4 items-start`}>
{userItems.map((item) => (
<GifMoodCard
@@ -389,7 +443,7 @@ export function GifMoodBoard({
</div>
)}
</div>
)}
) : null}
</section>
);
})}

View File

@@ -69,7 +69,7 @@ const gifMoodByIdInclude = {
orderBy: { order: 'asc' as const },
},
shares: { include: { user: { select: { id: true, name: true, email: true } } } },
ratings: { select: { userId: true, rating: true } },
ratings: { select: { userId: true, rating: true, hidden: true } },
};
export async function getGifMoodSessionById(sessionId: string, userId: string) {
@@ -213,6 +213,18 @@ export async function upsertGifMoodUserRating(
});
}
export async function setGifMoodUserHidden(
sessionId: string,
userId: string,
hidden: boolean
) {
return prisma.gifMoodUserRating.upsert({
where: { sessionId_userId: { sessionId, userId } },
update: { hidden },
create: { sessionId, userId, hidden },
});
}
// ============================================
// Session Sharing
// ============================================