Ajoute images et réactions sur les notes du Mur de la Honte
Build and deploy / deploy (push) Successful in 36s
Build and deploy / deploy (push) Successful in 36s
Chaque note peut désormais porter une image facultative : compressée et redimensionnée côté client (resizeImageToBlob, sans forcer un carré comme pour les avatars) avant l'upload, avec une limite dure côté bucket Supabase (3 Mo, webp/jpeg/png uniquement) en filet de sécurité. Le chemin de stockage ne contient jamais le user_id, contrairement aux avatars, pour ne pas faire fuiter l'auteur via l'URL publique de l'image. Ajoute aussi des réactions emoji (👍😂😱❤️🔥) sur chaque note, avec le même principe d'anonymat que le reste du Mur : le total par emoji est public (wall_note_reaction_counts), mais personne ne voit qui a réagi. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+255
-24
@@ -1,12 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { useCallback, useEffect, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
import Image from "next/image";
|
||||
import { Avatar } from "@/components/avatar";
|
||||
import { IconCamera, IconClose } from "@/components/icons";
|
||||
import { resizeImageToBlob } from "@/lib/image";
|
||||
import { createClient, waitForRealtimeAuth } from "@/lib/supabase/client";
|
||||
import type { WallHistoryRow, WallNoteRow } from "./page";
|
||||
import type { MyReactionRow, ReactionCountRow, WallHistoryRow, WallNoteRow } from "./page";
|
||||
|
||||
const MAX_LENGTH = 200;
|
||||
const MAX_NOTES_PER_DAY = 10;
|
||||
const MAX_IMAGE_SOURCE_BYTES = 20 * 1024 * 1024;
|
||||
const REACTION_EMOJIS = ["👍", "😂", "😱", "❤️", "🔥"];
|
||||
|
||||
// Rotation déterministe par note (jamais Math.random() au rendu, cf. la
|
||||
// même contrainte déjà rencontrée cette session avec les hooks React) —
|
||||
@@ -41,55 +46,143 @@ function formatDateLabel(dateKey: string): string {
|
||||
return label.charAt(0).toUpperCase() + label.slice(1);
|
||||
}
|
||||
|
||||
function NoteCard({ note }: { note: WallNoteRow }) {
|
||||
function buildReactionCounts(rows: ReactionCountRow[]): Record<string, Record<string, number>> {
|
||||
const map: Record<string, Record<string, number>> = {};
|
||||
for (const row of rows) {
|
||||
if (!map[row.note_id]) map[row.note_id] = {};
|
||||
map[row.note_id][row.emoji] = row.count;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function buildMyReactions(rows: MyReactionRow[]): Record<string, Set<string>> {
|
||||
const map: Record<string, Set<string>> = {};
|
||||
for (const row of rows) {
|
||||
if (!map[row.note_id]) map[row.note_id] = new Set();
|
||||
map[row.note_id].add(row.emoji);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function ReactionBar({
|
||||
counts,
|
||||
mine,
|
||||
onToggle,
|
||||
}: {
|
||||
counts: Record<string, number>;
|
||||
mine: Set<string>;
|
||||
onToggle: (emoji: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{REACTION_EMOJIS.map((emoji) => {
|
||||
const count = counts[emoji] ?? 0;
|
||||
const active = mine.has(emoji);
|
||||
return (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => onToggle(emoji)}
|
||||
className={`flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-xs transition ${
|
||||
active ? "border-gold bg-gold/20" : "border-gold/20 hover:bg-gold/10"
|
||||
}`}
|
||||
>
|
||||
<span>{emoji}</span>
|
||||
{count > 0 && <span className="text-[0.6rem] text-text-mut">{count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteCard({
|
||||
note,
|
||||
reactionCounts,
|
||||
myReactions,
|
||||
onToggleReaction,
|
||||
}: {
|
||||
note: WallNoteRow;
|
||||
reactionCounts: Record<string, number>;
|
||||
myReactions: Set<string>;
|
||||
onToggleReaction: (emoji: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="marble-surface flex min-h-[7rem] flex-col justify-between gap-2 rounded-lg border border-gold/30 p-3 shadow-md"
|
||||
style={{ transform: `rotate(${rotationFor(note.id)}deg)` }}
|
||||
>
|
||||
<p className="text-sm text-text-marble">{note.text}</p>
|
||||
<span className="self-end h-2 w-2 rounded-full bg-oxblood" aria-hidden />
|
||||
<div className="flex flex-col gap-2">
|
||||
{note.image_url && (
|
||||
<Image
|
||||
src={note.image_url}
|
||||
alt=""
|
||||
width={300}
|
||||
height={200}
|
||||
className="max-h-32 w-full rounded-md object-cover"
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-text-marble">{note.text}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<ReactionBar counts={reactionCounts} mine={myReactions} onToggle={onToggleReaction} />
|
||||
<span className="h-2 w-2 shrink-0 rounded-full bg-oxblood" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MurView({
|
||||
isJudge,
|
||||
currentUserId,
|
||||
initialNotes,
|
||||
initialMyNoteToday,
|
||||
initialHistory,
|
||||
initialReactionCounts,
|
||||
initialMyReactions,
|
||||
}: {
|
||||
isJudge: boolean;
|
||||
currentUserId: string;
|
||||
initialNotes: WallNoteRow[];
|
||||
initialMyNoteToday: WallNoteRow | null;
|
||||
initialHistory: WallHistoryRow[];
|
||||
initialReactionCounts: ReactionCountRow[];
|
||||
initialMyReactions: MyReactionRow[];
|
||||
}) {
|
||||
const [notes, setNotes] = useState(initialNotes);
|
||||
const [myNoteToday, setMyNoteToday] = useState(initialMyNoteToday);
|
||||
const [history, setHistory] = useState(initialHistory);
|
||||
const [reactionCounts, setReactionCounts] = useState(() => buildReactionCounts(initialReactionCounts));
|
||||
const [myReactions, setMyReactions] = useState(() => buildMyReactions(initialMyReactions));
|
||||
const [text, setText] = useState("");
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [historyDateFilter, setHistoryDateFilter] = useState("all");
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const supabase = createClient();
|
||||
const [{ data: todayNotes }, { data: myNotes }, { data: hist }] = await Promise.all([
|
||||
supabase.rpc("wall_notes_today"),
|
||||
isJudge
|
||||
? Promise.resolve({ data: null })
|
||||
: supabase
|
||||
.from("wall_notes")
|
||||
.select("id, text, created_at")
|
||||
.order("created_at", { ascending: false }),
|
||||
isJudge
|
||||
? supabase
|
||||
.from("wall_notes")
|
||||
.select("id, user_id, text, created_at, profiles(pseudo, avatar_url)")
|
||||
.order("created_at", { ascending: false })
|
||||
.returns<WallHistoryRow[]>()
|
||||
: Promise.resolve({ data: null }),
|
||||
]);
|
||||
const [{ data: todayNotes }, { data: myNotes }, { data: hist }, { data: counts }, { data: mine }] =
|
||||
await Promise.all([
|
||||
supabase.rpc("wall_notes_today"),
|
||||
isJudge
|
||||
? Promise.resolve({ data: null })
|
||||
: supabase
|
||||
.from("wall_notes")
|
||||
.select("id, text, image_url, created_at")
|
||||
.order("created_at", { ascending: false }),
|
||||
isJudge
|
||||
? supabase
|
||||
.from("wall_notes")
|
||||
.select("id, user_id, text, image_url, created_at, profiles(pseudo, avatar_url)")
|
||||
.order("created_at", { ascending: false })
|
||||
.returns<WallHistoryRow[]>()
|
||||
: Promise.resolve({ data: null }),
|
||||
supabase.rpc("wall_note_reaction_counts"),
|
||||
supabase.from("wall_note_reactions").select("note_id, emoji").eq("user_id", currentUserId),
|
||||
]);
|
||||
if (todayNotes) setNotes(todayNotes);
|
||||
if (myNotes) {
|
||||
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
||||
@@ -100,7 +193,9 @@ export function MurView({
|
||||
);
|
||||
}
|
||||
if (hist) setHistory(hist);
|
||||
}, [isJudge]);
|
||||
if (counts) setReactionCounts(buildReactionCounts(counts));
|
||||
if (mine) setMyReactions(buildMyReactions(mine));
|
||||
}, [isJudge, currentUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
@@ -112,6 +207,7 @@ export function MurView({
|
||||
channel = supabase
|
||||
.channel("wall-notes-changes")
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "wall_notes" }, refetch)
|
||||
.on("postgres_changes", { event: "*", schema: "public", table: "wall_note_reactions" }, refetch)
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
@@ -121,6 +217,37 @@ export function MurView({
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setImageError("Choisis une image.");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_IMAGE_SOURCE_BYTES) {
|
||||
setImageError("Image trop lourde, choisis-en une autre.");
|
||||
return;
|
||||
}
|
||||
|
||||
setImageError(null);
|
||||
setImageFile(file);
|
||||
setImagePreview((current) => {
|
||||
if (current) URL.revokeObjectURL(current);
|
||||
return URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
setImageFile(null);
|
||||
setImagePreview((current) => {
|
||||
if (current) URL.revokeObjectURL(current);
|
||||
return null;
|
||||
});
|
||||
setImageError(null);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
@@ -133,7 +260,33 @@ export function MurView({
|
||||
|
||||
setPosting(true);
|
||||
const supabase = createClient();
|
||||
const { error: rpcError } = await supabase.rpc("post_wall_note", { p_text: trimmed });
|
||||
|
||||
let imageUrl: string | null = null;
|
||||
if (imageFile) {
|
||||
try {
|
||||
const blob = await resizeImageToBlob(imageFile);
|
||||
const path = `${crypto.randomUUID()}.webp`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("wall-images")
|
||||
.upload(path, blob, { contentType: "image/webp" });
|
||||
if (uploadError) {
|
||||
setPosting(false);
|
||||
setError("Impossible d'envoyer l'image, réessaie.");
|
||||
return;
|
||||
}
|
||||
const { data: publicUrlData } = supabase.storage.from("wall-images").getPublicUrl(path);
|
||||
imageUrl = publicUrlData.publicUrl;
|
||||
} catch {
|
||||
setPosting(false);
|
||||
setError("Impossible de traiter l'image, réessaie.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { error: rpcError } = await supabase.rpc("post_wall_note", {
|
||||
p_text: trimmed,
|
||||
p_image_url: imageUrl,
|
||||
});
|
||||
setPosting(false);
|
||||
|
||||
if (rpcError) {
|
||||
@@ -149,9 +302,34 @@ export function MurView({
|
||||
}
|
||||
|
||||
setText("");
|
||||
clearImage();
|
||||
refetch();
|
||||
}
|
||||
|
||||
async function handleToggleReaction(noteId: string, emoji: string) {
|
||||
// Optimiste : le canal Realtime confirmera (ou corrigera) juste après.
|
||||
setMyReactions((current) => {
|
||||
const next = new Set(current[noteId] ?? []);
|
||||
const wasActive = next.has(emoji);
|
||||
if (wasActive) next.delete(emoji);
|
||||
else next.add(emoji);
|
||||
return { ...current, [noteId]: next };
|
||||
});
|
||||
setReactionCounts((current) => {
|
||||
const wasActive = myReactions[noteId]?.has(emoji) ?? false;
|
||||
const noteCounts = { ...(current[noteId] ?? {}) };
|
||||
noteCounts[emoji] = Math.max(0, (noteCounts[emoji] ?? 0) + (wasActive ? -1 : 1));
|
||||
return { ...current, [noteId]: noteCounts };
|
||||
});
|
||||
|
||||
const supabase = createClient();
|
||||
const { error: rpcError } = await supabase.rpc("toggle_wall_note_reaction", {
|
||||
p_note_id: noteId,
|
||||
p_emoji: emoji,
|
||||
});
|
||||
if (rpcError) refetch();
|
||||
}
|
||||
|
||||
const wallFull = notes.length >= MAX_NOTES_PER_DAY;
|
||||
|
||||
const historyDates = Array.from(new Set(history.map((n) => localDateKey(n.created_at)))).sort((a, b) =>
|
||||
@@ -172,7 +350,13 @@ export function MurView({
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||
{notes.map((note) => (
|
||||
<NoteCard key={note.id} note={note} />
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
reactionCounts={reactionCounts[note.id] ?? {}}
|
||||
myReactions={myReactions[note.id] ?? new Set()}
|
||||
onToggleReaction={(emoji) => handleToggleReaction(note.id, emoji)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -183,6 +367,15 @@ export function MurView({
|
||||
{myNoteToday ? (
|
||||
<div>
|
||||
<p className="text-sm text-olive">Ta note d'aujourd'hui est publiée.</p>
|
||||
{myNoteToday.image_url && (
|
||||
<Image
|
||||
src={myNoteToday.image_url}
|
||||
alt=""
|
||||
width={300}
|
||||
height={200}
|
||||
className="mt-2 max-h-40 w-full rounded-md object-cover"
|
||||
/>
|
||||
)}
|
||||
<p className="mt-2 rounded-md border border-gold/20 bg-white/40 px-3 py-2 text-sm text-text-marble">
|
||||
{myNoteToday.text}
|
||||
</p>
|
||||
@@ -203,6 +396,35 @@ export function MurView({
|
||||
<p className="text-right text-[0.65rem] text-text-mut">
|
||||
{text.length}/{MAX_LENGTH}
|
||||
</p>
|
||||
|
||||
{imagePreview ? (
|
||||
<div className="relative w-32">
|
||||
<Image
|
||||
src={imagePreview}
|
||||
alt=""
|
||||
width={128}
|
||||
height={96}
|
||||
unoptimized
|
||||
className="h-24 w-32 rounded-md object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearImage}
|
||||
className="absolute -top-2 -right-2 rounded-full bg-ink-2 p-1 text-marble"
|
||||
aria-label="Retirer l'image"
|
||||
>
|
||||
<IconClose className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="inline-flex w-fit cursor-pointer items-center gap-1.5 rounded-md border border-gold/30 px-3 py-1.5 text-xs font-medium text-text-marble hover:bg-gold/10">
|
||||
<IconCamera className="h-4 w-4" />
|
||||
Ajouter une image (optionnel)
|
||||
<input type="file" accept="image/*" onChange={handleImageChange} className="hidden" />
|
||||
</label>
|
||||
)}
|
||||
{imageError && <p className="text-xs text-oxblood">{imageError}</p>}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-oxblood">
|
||||
{error}
|
||||
@@ -257,6 +479,15 @@ export function MurView({
|
||||
{note.profiles?.pseudo ?? "un Citoyen"} — {formatDateTime(note.created_at)}
|
||||
</p>
|
||||
<p className="text-text-marble">{note.text}</p>
|
||||
{note.image_url && (
|
||||
<Image
|
||||
src={note.image_url}
|
||||
alt=""
|
||||
width={200}
|
||||
height={150}
|
||||
className="mt-1 max-h-32 rounded-md object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
+15
-3
@@ -5,6 +5,7 @@ import { MurView } from "./mur-view";
|
||||
export type WallNoteRow = {
|
||||
id: string;
|
||||
text: string;
|
||||
image_url: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
@@ -12,10 +13,14 @@ export type WallHistoryRow = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
text: string;
|
||||
image_url: string | null;
|
||||
created_at: string;
|
||||
profiles: { pseudo: string; avatar_url: string | null } | null;
|
||||
};
|
||||
|
||||
export type ReactionCountRow = { note_id: string; emoji: string; count: number };
|
||||
export type MyReactionRow = { note_id: string; emoji: string };
|
||||
|
||||
function isToday(iso: string): boolean {
|
||||
const today = new Date().toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
||||
const noteDate = new Date(iso).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" });
|
||||
@@ -39,13 +44,17 @@ export default async function MurPage() {
|
||||
.single();
|
||||
const isJudge = callerProfile?.role === "judge";
|
||||
|
||||
const { data: todayNotes } = await supabase.rpc("wall_notes_today");
|
||||
const [{ data: todayNotes }, { data: reactionCounts }, { data: myReactions }] = await Promise.all([
|
||||
supabase.rpc("wall_notes_today"),
|
||||
supabase.rpc("wall_note_reaction_counts"),
|
||||
supabase.from("wall_note_reactions").select("note_id, emoji").eq("user_id", user.id),
|
||||
]);
|
||||
|
||||
let myNoteToday: WallNoteRow | null = null;
|
||||
if (!isJudge) {
|
||||
const { data: myNotes } = await supabase
|
||||
.from("wall_notes")
|
||||
.select("id, text, created_at")
|
||||
.select("id, text, image_url, created_at")
|
||||
.eq("user_id", user.id)
|
||||
.order("created_at", { ascending: false });
|
||||
myNoteToday = (myNotes ?? []).find((n) => isToday(n.created_at)) ?? null;
|
||||
@@ -55,7 +64,7 @@ export default async function MurPage() {
|
||||
if (isJudge) {
|
||||
const { data } = await supabase
|
||||
.from("wall_notes")
|
||||
.select("id, user_id, text, created_at, profiles(pseudo, avatar_url)")
|
||||
.select("id, user_id, text, image_url, created_at, profiles(pseudo, avatar_url)")
|
||||
.order("created_at", { ascending: false })
|
||||
.returns<WallHistoryRow[]>();
|
||||
history = data ?? [];
|
||||
@@ -71,9 +80,12 @@ export default async function MurPage() {
|
||||
</div>
|
||||
<MurView
|
||||
isJudge={isJudge}
|
||||
currentUserId={user.id}
|
||||
initialNotes={todayNotes ?? []}
|
||||
initialMyNoteToday={myNoteToday}
|
||||
initialHistory={history}
|
||||
initialReactionCounts={reactionCounts ?? []}
|
||||
initialMyReactions={myReactions ?? []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -38,3 +38,38 @@ export async function cropImageToBlob(
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Redimensionne (sans jamais agrandir) au format d'origine — contrairement
|
||||
// à cropImageToBlob qui force un carré pour les avatars — à maxDimension
|
||||
// sur le plus grand côté, et compresse en WebP avant l'envoi vers le
|
||||
// bucket. Le bucket a aussi sa propre limite de taille côté serveur
|
||||
// (défense en profondeur) : cette compression n'est qu'un premier filtre,
|
||||
// pas la seule garantie.
|
||||
export async function resizeImageToBlob(file: File, maxDimension = 1600, quality = 0.82): Promise<Blob> {
|
||||
const src = URL.createObjectURL(file);
|
||||
try {
|
||||
const image = await loadImage(src);
|
||||
const scale = Math.min(1, maxDimension / Math.max(image.width, image.height));
|
||||
const outputWidth = Math.round(image.width * scale);
|
||||
const outputHeight = Math.round(image.height * scale);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = outputWidth;
|
||||
canvas.height = outputHeight;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
throw new Error("Canvas non supporté par ce navigateur");
|
||||
}
|
||||
ctx.drawImage(image, 0, 0, outputWidth, outputHeight);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error("Échec de la compression de l'image"))),
|
||||
"image/webp",
|
||||
quality,
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(src);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user