"use client"; 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 { MyReactionRow, ReactionCountRow, WallHistoryRow, WallNoteRow } from "./page"; const MAX_LENGTH = 200; // Les Archontes peuvent désormais publier aussi (plus réservé aux // Citoyens) : 12 places pour couvrir tout le groupe, pas seulement les // Citoyens — doit rester aligné avec la borne côté serveur dans // post_wall_note (supabase/schema.sql). const MAX_NOTES_PER_DAY = 12; 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) — // donne l'effet "punaisé en vrac" sans risquer un hydration mismatch. function rotationFor(id: string): number { let hash = 0; for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) | 0; return (Math.abs(hash) % 7) - 3; } function formatDateTime(iso: string): string { return new Date(iso).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short", timeZone: "Europe/Paris", }); } // Clé de date locale (Europe/Paris) au format sv-SE (YYYY-MM-DD, triable), // même pattern que calendrier/day-card.tsx. function localDateKey(iso: string): string { return new Date(iso).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }); } function formatDateLabel(dateKey: string): string { const label = new Date(`${dateKey}T12:00:00`).toLocaleDateString("fr-FR", { weekday: "long", day: "numeric", month: "long", timeZone: "Europe/Paris", }); return label.charAt(0).toUpperCase() + label.slice(1); } function buildReactionCounts(rows: ReactionCountRow[]): Record> { const map: Record> = {}; 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> { const map: Record> = {}; 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; mine: Set; onToggle: (emoji: string) => void; }) { return (
{REACTION_EMOJIS.map((emoji) => { const count = counts[emoji] ?? 0; const active = mine.has(emoji); return ( ); })}
); } function NoteCard({ note, reactionCounts, myReactions, onToggleReaction, }: { note: WallNoteRow; reactionCounts: Record; myReactions: Set; onToggleReaction: (emoji: string) => void; }) { return (
{note.image_url && ( )}

{note.text}

); } 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(null); const [imagePreview, setImagePreview] = useState(null); const [imageError, setImageError] = useState(null); const [posting, setPosting] = useState(false); const [error, setError] = useState(null); const [historyDateFilter, setHistoryDateFilter] = useState("all"); const refetch = useCallback(async () => { const supabase = createClient(); 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() : 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" }); setMyNoteToday( myNotes.find( (n) => new Date(n.created_at).toLocaleDateString("sv-SE", { timeZone: "Europe/Paris" }) === today, ) ?? null, ); } if (hist) setHistory(hist); if (counts) setReactionCounts(buildReactionCounts(counts)); if (mine) setMyReactions(buildMyReactions(mine)); }, [isJudge, currentUserId]); useEffect(() => { const supabase = createClient(); let channel: ReturnType | null = null; let cancelled = false; waitForRealtimeAuth(supabase).then(() => { if (cancelled) return; 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(); }); return () => { cancelled = true; if (channel) supabase.removeChannel(channel); }; }, [refetch]); function handleImageChange(event: ChangeEvent) { 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); const trimmed = text.trim(); if (!trimmed) { setError("Écris une note avant de publier."); return; } setPosting(true); const supabase = createClient(); 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) { setError( rpcError.message.includes("already posted") ? "Tu as déjà publié une note aujourd'hui." : rpcError.message.includes("wall full") ? "Le mur est déjà complet pour aujourd'hui." : "Une erreur est survenue, réessaie.", ); refetch(); return; } 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) => b.localeCompare(a), ); const filteredHistory = historyDateFilter === "all" ? history : history.filter((n) => localDateKey(n.created_at) === historyDateFilter); return (

{notes.length} / {MAX_NOTES_PER_DAY}{" "} aujourd'hui

{notes.length === 0 ? (

Le mur est encore vide aujourd'hui.

) : (
{notes.map((note) => ( handleToggleReaction(note.id, emoji)} /> ))}
)}
{myNoteToday ? (

Ta note d'aujourd'hui est publiée.

{myNoteToday.image_url && ( )}

{myNoteToday.text}

Reviens demain pour en publier une nouvelle.

) : wallFull ? (

Le mur est complet pour aujourd'hui, reviens demain !

) : (