V2 + V3 : rôles, points, journal, podium et recadrage photo
V2 — authentification et administration :
- Authentification par email + mot de passe (email confirmé, un compte
par email), abandon de l'email interne dérivé du pseudo.
- Rôles public/judge sur profiles, section Administration (juges
uniquement, vérifiée côté serveur) pour gérer les membres.
- Verrou de pseudo : modifiable une fois par son propriétaire puis figé,
contournable par un juge.
- RLS étendue par un trigger BEFORE UPDATE (enforce_profile_update) pour
verrouiller les colonnes sensibles (role, points, pseudo_locked, pseudo
figé) — la RLS seule ne peut pas exprimer une règle par colonne.
V3 — points, journal, podium, progression, photo :
- RPC award_points() (SECURITY DEFINER) : seul point d'écriture de la
colonne points, vérifie le rôle juge côté serveur, delta signé sans
plancher à 0, trace chaque opération dans points_log.
- Leaderboard temps réel (Supabase Realtime) avec podium top 3
(égalités gérées), flèches de progression (previous_rank), et
contrôles de points juges avec confirmation explicite (plus de
debounce auto) avant envoi.
- Page /journal ("le crieur") : fil live et public des attributions de
points.
- Recadrage photo carré + compression client (react-easy-crop + canvas)
au signup et sur le profil.
- Passe de polish visuel : cartes/boutons cohérents, lien actif dans la
nav, podium retravaillé.
schema.sql, README.md et CLAUDE.md mis à jour en conséquence (schéma
idempotent, instructions SMTP/rôles/migration, conventions RLS+trigger
documentées pour les futures colonnes sensibles).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
type Entry = {
|
||||
id: number;
|
||||
target_id: string;
|
||||
judge_id: string;
|
||||
delta: number;
|
||||
reason: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString("fr-FR", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
timeZone: "Europe/Paris",
|
||||
});
|
||||
}
|
||||
|
||||
export function JournalView({
|
||||
initialEntries,
|
||||
pseudoById,
|
||||
}: {
|
||||
initialEntries: Entry[];
|
||||
pseudoById: Record<string, string>;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<Entry[]>(initialEntries);
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = createClient();
|
||||
const channel = supabase
|
||||
.channel("journal-points-log")
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "INSERT", schema: "public", table: "points_log" },
|
||||
(payload) => {
|
||||
const row = payload.new as Entry;
|
||||
setEntries((current) => [row, ...current]);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-sm text-ink/70">Aucun événement pour l'instant.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="flex flex-col gap-2">
|
||||
{entries.map((entry) => {
|
||||
const judgePseudo = pseudoById[entry.judge_id] ?? "Un juge";
|
||||
const targetPseudo = pseudoById[entry.target_id] ?? "un membre";
|
||||
const sign = entry.delta > 0 ? `+${entry.delta}` : `${entry.delta}`;
|
||||
return (
|
||||
<li
|
||||
key={entry.id}
|
||||
className="rounded-lg border border-navy/10 bg-white px-4 py-3 text-sm shadow-sm"
|
||||
>
|
||||
<span className="font-medium text-navy">{judgePseudo}</span> a donné{" "}
|
||||
<span className={entry.delta >= 0 ? "font-semibold text-green-700" : "font-semibold text-red-700"}>
|
||||
{sign}
|
||||
</span>{" "}
|
||||
à <span className="font-medium text-navy">{targetPseudo}</span>
|
||||
{entry.reason && <span className="text-ink/70"> — {entry.reason}</span>}
|
||||
<div className="mt-1 text-xs text-ink/50">{formatTime(entry.created_at)}</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { JournalView } from "./journal-view";
|
||||
|
||||
export default async function JournalPage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const { data: profiles } = await supabase.from("profiles").select("id, pseudo");
|
||||
const pseudoById = Object.fromEntries((profiles ?? []).map((p) => [p.id, p.pseudo]));
|
||||
|
||||
const { data: entries } = await supabase
|
||||
.from("points_log")
|
||||
.select("id, target_id, judge_id, delta, reason, created_at")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(200);
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl px-4 py-8">
|
||||
<h1 className="mb-2 text-2xl font-semibold text-navy">Le crieur</h1>
|
||||
<p className="mb-6 text-sm text-ink/60">Journal public de toutes les attributions de points.</p>
|
||||
<JournalView initialEntries={entries ?? []} pseudoById={pseudoById} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user