5e75939b94
Build and deploy / deploy (push) Successful in 36s
post_wall_note() n'a plus de vérification de rôle : un Archonte peut désormais publier une note anonyme comme n'importe quel Citoyen. La policy Storage d'upload sur wall-images perd aussi son filtre par rôle (sinon possible de poster une note mais pas d'y joindre une image). Limite quotidienne relevée de 10 à 12 notes pour couvrir tout le groupe plutôt que les seuls Citoyens. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
503 lines
18 KiB
TypeScript
503 lines
18 KiB
TypeScript
"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<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)` }}
|
|
>
|
|
<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 }, { 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" });
|
|
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<typeof supabase.channel> | 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<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);
|
|
|
|
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 (
|
|
<div className="flex flex-col gap-6">
|
|
<div>
|
|
<p className="mb-3 text-center text-xs font-medium tracking-wide text-marble/60 uppercase">
|
|
{notes.length} / {MAX_NOTES_PER_DAY}{" "}
|
|
aujourd'hui
|
|
</p>
|
|
{notes.length === 0 ? (
|
|
<p className="text-center text-sm text-marble/60">Le mur est encore vide aujourd'hui.</p>
|
|
) : (
|
|
<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}
|
|
reactionCounts={reactionCounts[note.id] ?? {}}
|
|
myReactions={myReactions[note.id] ?? new Set()}
|
|
onToggleReaction={(emoji) => handleToggleReaction(note.id, emoji)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
|
|
{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>
|
|
<p className="mt-2 text-xs text-text-mut">Reviens demain pour en publier une nouvelle.</p>
|
|
</div>
|
|
) : wallFull ? (
|
|
<p className="text-sm text-text-mut">Le mur est complet pour aujourd'hui, reviens demain !</p>
|
|
) : (
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-2">
|
|
<textarea
|
|
value={text}
|
|
onChange={(event) => setText(event.target.value)}
|
|
maxLength={MAX_LENGTH}
|
|
rows={2}
|
|
placeholder="Écrire une note anonyme…"
|
|
className="rounded-md border border-gold/30 bg-white/50 px-3 py-2 text-sm text-text-marble outline-none focus:border-gold"
|
|
/>
|
|
<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}
|
|
</p>
|
|
)}
|
|
<button
|
|
type="submit"
|
|
disabled={posting}
|
|
className="rounded-md bg-ink-2 px-4 py-2 font-heading text-sm tracking-wide text-gold-bright uppercase transition hover:bg-ink disabled:opacity-50"
|
|
>
|
|
{posting ? "Publication…" : "Publier anonymement"}
|
|
</button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
|
|
{isJudge && (
|
|
<div className="marble-surface rounded-2xl border border-gold/40 p-4 shadow-sm">
|
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
<p className="font-heading text-sm tracking-wide text-text-mut uppercase">Historique complet</p>
|
|
{historyDates.length > 0 && (
|
|
<select
|
|
value={historyDateFilter}
|
|
onChange={(event) => setHistoryDateFilter(event.target.value)}
|
|
className="rounded-md border border-gold/30 bg-white/50 px-2 py-1 text-xs text-text-marble outline-none focus:border-gold"
|
|
>
|
|
<option value="all">Tous les jours</option>
|
|
{historyDates.map((dateKey) => (
|
|
<option key={dateKey} value={dateKey}>
|
|
{formatDateLabel(dateKey)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
{filteredHistory.length === 0 ? (
|
|
<p className="text-sm text-text-mut">
|
|
{history.length === 0 ? "Aucune note publiée pour l'instant." : "Aucune note ce jour-là."}
|
|
</p>
|
|
) : (
|
|
<ul className="flex flex-col divide-y divide-gold/10">
|
|
{filteredHistory.map((note) => (
|
|
<li key={note.id} className="flex items-start gap-2 py-1.5 text-sm">
|
|
<Avatar
|
|
pseudo={note.profiles?.pseudo ?? "?"}
|
|
avatarUrl={note.profiles?.avatar_url ?? null}
|
|
size="sm"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-xs font-medium text-text-mut">
|
|
{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>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|