Design responsive, logo, et correctif RLS upload photo
Design & UX : - Renomme "Administration" en "Admin" (nav, page, docs). - Logo original (marteau de juge stylisé navy/or) en favicon (src/app/icon.svg, remplace le favicon.ico par défaut de create-next-app) et dans la navbar (components/logo.tsx). - Navbar : menu hamburger sur mobile (les liens débordaient à partir de ~4 entrées sur petit écran). - Leaderboard : lignes en deux niveaux sur mobile (infos membre / contrôles juges) au lieu d'un seul flex-wrap qui devenait illisible. - Contrôles de points juges (JudgePointControls) : boutons et champ montant réduits en mode compact pour tenir dans les colonnes du podium sur petit écran. - Podium : scroll horizontal de secours (overflow-x-auto) si le contenu dépasse malgré tout sur très petits écrans. - Table admin : pseudo/avatar sur une ligne, rôle/statut/actions sur une autre en mobile (le flex-1 sur l'input pseudo n'avait aucun effet en layout colonne). Correctif sécurité/fonctionnel : - storage.objects n'avait que des policies INSERT et UPDATE pour le bucket avatars, pas de policy SELECT. Postgres a besoin de lire la ligne existante pour évaluer la clause USING d'un UPDATE : sans SELECT, tout remplacement de photo (upsert) échouait avec "new row violates row-level security policy", même si les policies INSERT/UPDATE étaient correctes. Ajout d'une policy SELECT publique sur le bucket avatars (cohérent avec le fait que le bucket est déjà public en lecture). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ Application web pour un groupe d'amis (~12 personnes) qui anime un jeu et des ol
|
||||
|
||||
V1, V2 et V3 sont livrées :
|
||||
- **V1** : comptes, profils, leaderboard statique.
|
||||
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Administration.
|
||||
- **V2** : authentification par email réel (plus de pseudo+email interne), rôles `public`/`judge`, verrou de pseudo (modifiable une fois), section Admin.
|
||||
- **V3** : attribution de points par les juges (RPC sécurisée, +/- avec confirmation explicite), journal public « le crieur », podium top 3, flèches de progression, recadrage/compression photo côté client.
|
||||
|
||||
Hors périmètre pour l'instant (voir roadmap en fin de doc) : éditions/thèmes, le jeu du Tribunal lui-même (Char, roulette, ostracisme, timer du Gardien), mode grand écran.
|
||||
|
||||
@@ -10,7 +10,7 @@ Application web pour animer et scorer le jeu **« Le Tribunal »** entre amis pe
|
||||
- 👤 **Connexion / déconnexion**, pseudo affiché partout (jamais l'email)
|
||||
- 🖼️ **Profil éditable** : pseudo modifiable **une seule fois** puis figé (sauf par un juge), photo recadrée en rond, redimensionnée et compressée côté client
|
||||
- 🏆 **Leaderboard en direct** (Supabase Realtime) : podium top 3 (or/argent/bronze, gestion des égalités), flèches ▲▼ de progression depuis le dernier « round »
|
||||
- ⚖️ **Rôles** : `public` / `judge`. Les juges attribuent des points (+/- avec montant personnalisé et motif, confirmation explicite avant envoi) et gèrent les membres depuis une section **Administration**
|
||||
- ⚖️ **Rôles** : `public` / `judge`. Les juges attribuent des points (+/- avec montant personnalisé et motif, confirmation explicite avant envoi) et gèrent les membres depuis une section **Admin**
|
||||
- 📣 **Le crieur** : journal public et en direct de toutes les attributions de points
|
||||
- 📱 **Responsive**, pensé mobile-first et propre en vidéoprojection
|
||||
|
||||
@@ -137,7 +137,7 @@ Le profil (`public.profiles`) est créé automatiquement à l'inscription via un
|
||||
|
||||
### Verrou de pseudo
|
||||
|
||||
Chaque membre peut changer son pseudo **une seule fois** depuis `/profile`. Dès l'enregistrement, `pseudo_locked` passe à `true` et le champ devient en lecture seule (« pseudo figé — contacte un juge pour le changer »). Un juge peut toujours modifier le pseudo de quiconque et, s'il le souhaite, redéverrouiller un compte depuis **Administration**.
|
||||
Chaque membre peut changer son pseudo **une seule fois** depuis `/profile`. Dès l'enregistrement, `pseudo_locked` passe à `true` et le champ devient en lecture seule (« pseudo figé — contacte un juge pour le changer »). Un juge peut toujours modifier le pseudo de quiconque et, s'il le souhaite, redéverrouiller un compte depuis **Admin**.
|
||||
|
||||
### Rôles & administration
|
||||
|
||||
@@ -148,7 +148,7 @@ select id, pseudo from public.profiles;
|
||||
update public.profiles set role = 'judge' where id = '<uuid-du-membre>';
|
||||
```
|
||||
|
||||
Ensuite, un juge peut promouvoir/rétrograder d'autres membres et éditer leur pseudo depuis la page **Administration** (visible uniquement par les juges, et vérifié côté serveur — pas seulement caché en front).
|
||||
Ensuite, un juge peut promouvoir/rétrograder d'autres membres et éditer leur pseudo depuis la page **Admin** (visible uniquement par les juges, et vérifié côté serveur — pas seulement caché en front).
|
||||
|
||||
### Email transactionnel
|
||||
|
||||
@@ -171,7 +171,7 @@ Le service email intégré de Supabase (sans SMTP custom) limite l'envoi à quel
|
||||
- **Attribution de points** : depuis le leaderboard, un juge clique `+`/`−` (montant personnalisable, motif optionnel) — le delta reste **en attente** (affiché en couleur) jusqu'à un clic sur **Confirmer** (envoie la RPC `award_points`) ou **Annuler**. Le client n'écrit jamais directement la colonne `points` : la RPC vérifie le rôle juge côté serveur, applique le delta (négatif autorisé, pas de plancher à 0) et journalise l'opération.
|
||||
- **Le crieur** (`/journal`) : fil de tous les événements (« Untel a donné +5 à Untel — motif »), visible par tous les authentifiés, alimenté par Supabase Realtime.
|
||||
- **Podium** : top 3 mis en avant (or/argent/bronze), gère proprement les égalités (rangs partagés) et les groupes de moins de 3 membres.
|
||||
- **Flèches de progression** : ▲/▼/= comparent le rang actuel au dernier repère. Un juge fige un nouveau repère via **Nouveau round** dans **Administration**.
|
||||
- **Flèches de progression** : ▲/▼/= comparent le rang actuel au dernier repère. Un juge fige un nouveau repère via **Nouveau round** dans **Admin**.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -78,46 +78,49 @@ function MemberRow({ member, isSelf }: { member: Member; isSelf: boolean }) {
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-2 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm sm:flex-row sm:items-center sm:gap-4">
|
||||
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar pseudo={member.pseudo} avatarUrl={member.avatar_url} size="sm" />
|
||||
<input
|
||||
type="text"
|
||||
value={pseudo}
|
||||
onChange={(event) => setPseudo(event.target.value)}
|
||||
className="w-full flex-1 rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold sm:w-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={pseudo}
|
||||
onChange={(event) => setPseudo(event.target.value)}
|
||||
className="flex-1 rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.target.value as Role)}
|
||||
disabled={isSelf}
|
||||
className="rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold disabled:opacity-50"
|
||||
>
|
||||
<option value="public">public</option>
|
||||
<option value="judge">judge</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.target.value as Role)}
|
||||
disabled={isSelf}
|
||||
className="rounded-md border border-navy/20 bg-white px-2 py-1 text-sm outline-none focus:border-gold disabled:opacity-50"
|
||||
>
|
||||
<option value="public">public</option>
|
||||
<option value="judge">judge</option>
|
||||
</select>
|
||||
{pseudoLocked ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUnlock}
|
||||
disabled={saving}
|
||||
className="whitespace-nowrap rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50"
|
||||
>
|
||||
Déverrouiller le pseudo
|
||||
</button>
|
||||
) : (
|
||||
<span className="whitespace-nowrap text-xs text-ink/50">pseudo libre</span>
|
||||
)}
|
||||
|
||||
{pseudoLocked ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUnlock}
|
||||
disabled={saving}
|
||||
className="whitespace-nowrap rounded-md border border-navy/20 px-2 py-1 text-xs text-navy hover:bg-navy/5 disabled:opacity-50"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="whitespace-nowrap rounded-md bg-navy px-3 py-1.5 text-sm font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-40 sm:ml-auto"
|
||||
>
|
||||
Déverrouiller le pseudo
|
||||
{saving ? "…" : "Enregistrer"}
|
||||
</button>
|
||||
) : (
|
||||
<span className="whitespace-nowrap text-xs text-ink/50">pseudo libre</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="whitespace-nowrap rounded-md bg-navy px-3 py-1.5 text-sm font-medium text-ivory transition hover:bg-navy/90 disabled:opacity-40"
|
||||
>
|
||||
{saving ? "…" : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-700">{error}</p>}
|
||||
{success && !dirty && <p className="text-xs text-green-700">OK</p>}
|
||||
|
||||
@@ -33,7 +33,7 @@ export default async function AdminPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl px-4 py-8">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold text-navy">Administration</h1>
|
||||
<h1 className="text-2xl font-semibold text-navy">Admin</h1>
|
||||
<ResetRoundButton />
|
||||
</div>
|
||||
<AdminMembersTable members={members ?? []} currentUserId={user.id} />
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||
<rect x="58" y="74" width="30" height="12" rx="3" fill="#0F2748" />
|
||||
<rect x="54" y="70" width="38" height="6" rx="3" fill="#0F2748" opacity="0.7" />
|
||||
<g transform="rotate(45 50 46)">
|
||||
<rect x="30" y="10" width="18" height="46" rx="6" fill="#C9A227" />
|
||||
<rect x="18" y="8" width="42" height="20" rx="6" fill="#0F2748" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 420 B |
@@ -99,20 +99,25 @@ export function LeaderboardView({
|
||||
return (
|
||||
<li
|
||||
key={profile.id}
|
||||
className={`flex flex-wrap items-center gap-3 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm transition-colors ${
|
||||
className={`flex flex-col gap-2 rounded-lg border border-navy/10 bg-white px-4 py-3 shadow-sm transition-colors sm:flex-row sm:items-center sm:gap-3 ${
|
||||
flashingId === profile.id ? "animate-points-flash" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 shrink-0 text-center font-semibold text-navy/60">
|
||||
{profile.rank}
|
||||
</span>
|
||||
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
|
||||
<span className="flex-1 truncate font-medium">{profile.pseudo}</span>
|
||||
<ProgressBadge direction={progress.direction} amount={progress.amount} />
|
||||
<span className="font-semibold text-navy">{profile.points}</span>
|
||||
{isJudge && (
|
||||
<JudgePointControls memberId={profile.id} onApplyDelta={applyOptimisticDelta} />
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-6 shrink-0 text-center font-semibold text-navy/60">
|
||||
{profile.rank}
|
||||
</span>
|
||||
<Avatar pseudo={profile.pseudo} avatarUrl={profile.avatar_url} size="sm" />
|
||||
<span className="flex-1 truncate font-medium sm:flex-initial">{profile.pseudo}</span>
|
||||
<ProgressBadge direction={progress.direction} amount={progress.amount} />
|
||||
<span className="font-semibold text-navy sm:hidden">{profile.points}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:ml-auto">
|
||||
<span className="hidden font-semibold text-navy sm:inline">{profile.points}</span>
|
||||
{isJudge && (
|
||||
<JudgePointControls memberId={profile.id} onApplyDelta={applyOptimisticDelta} />
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -50,12 +50,16 @@ export function JudgePointControls({
|
||||
setReason("");
|
||||
}
|
||||
|
||||
const buttonHeight = compact ? "h-6" : "h-7";
|
||||
const buttonWidth = compact ? "w-6" : "w-7";
|
||||
const inputWidth = compact ? "w-9" : "w-12";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1 sm:gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPending((p) => p - parsedAmount)}
|
||||
className="h-7 w-7 shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5"
|
||||
className={`${buttonHeight} ${buttonWidth} shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5`}
|
||||
aria-label={`Retirer ${parsedAmount} points`}
|
||||
>
|
||||
−
|
||||
@@ -65,12 +69,12 @@ export function JudgePointControls({
|
||||
min={1}
|
||||
value={amount}
|
||||
onChange={(event) => setAmount(event.target.value)}
|
||||
className="h-7 w-12 rounded-md border border-navy/20 px-1 text-center text-xs"
|
||||
className={`${buttonHeight} ${inputWidth} rounded-md border border-navy/20 px-1 text-center text-xs`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPending((p) => p + parsedAmount)}
|
||||
className="h-7 w-7 shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5"
|
||||
className={`${buttonHeight} ${buttonWidth} shrink-0 rounded-md border border-navy/20 text-sm font-bold text-navy hover:bg-navy/5`}
|
||||
aria-label={`Ajouter ${parsedAmount} points`}
|
||||
>
|
||||
+
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export function Logo({ className = "h-7 w-7" }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 100 100" fill="none" className={className} aria-hidden>
|
||||
<rect x="58" y="74" width="30" height="12" rx="3" fill="#C9A227" />
|
||||
<rect x="54" y="70" width="38" height="6" rx="3" fill="#C9A227" opacity="0.7" />
|
||||
<g transform="rotate(45 50 46)">
|
||||
<rect x="30" y="10" width="18" height="46" rx="6" fill="#F4ECD8" />
|
||||
<rect x="18" y="8" width="42" height="20" rx="6" fill="#C9A227" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
+71
-17
@@ -4,15 +4,25 @@ import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { Logo } from "@/components/logo";
|
||||
|
||||
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
function NavLink({
|
||||
href,
|
||||
onNavigate,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
onNavigate?: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname.startsWith(href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`rounded-md px-2 py-1 transition-colors ${
|
||||
onClick={onNavigate}
|
||||
className={`block rounded-md px-3 py-2 transition-colors sm:px-2 sm:py-1 ${
|
||||
isActive ? "bg-ivory/10 text-gold" : "hover:text-gold"
|
||||
}`}
|
||||
>
|
||||
@@ -24,6 +34,7 @@ function NavLink({ href, children }: { href: string; children: React.ReactNode }
|
||||
export function NavBar({ isJudge }: { isJudge: boolean }) {
|
||||
const router = useRouter();
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
async function handleSignOut() {
|
||||
setSigningOut(true);
|
||||
@@ -33,25 +44,68 @@ export function NavBar({ isJudge }: { isJudge: boolean }) {
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
const links = (
|
||||
<>
|
||||
<NavLink href="/leaderboard" onNavigate={() => setMenuOpen(false)}>
|
||||
Leaderboard
|
||||
</NavLink>
|
||||
<NavLink href="/journal" onNavigate={() => setMenuOpen(false)}>
|
||||
Le crieur
|
||||
</NavLink>
|
||||
<NavLink href="/profile" onNavigate={() => setMenuOpen(false)}>
|
||||
Profil
|
||||
</NavLink>
|
||||
{isJudge && (
|
||||
<NavLink href="/admin" onNavigate={() => setMenuOpen(false)}>
|
||||
Admin
|
||||
</NavLink>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
disabled={signingOut}
|
||||
className="block w-full rounded-md px-3 py-2 text-left transition-colors hover:text-gold disabled:opacity-50 sm:w-auto sm:px-2 sm:py-1"
|
||||
>
|
||||
{signingOut ? "…" : "Déconnexion"}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-10 bg-navy text-ivory shadow-md">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
||||
<span className="font-semibold tracking-wide">Le Tribunal</span>
|
||||
<div className="flex items-center gap-1 text-sm sm:gap-2">
|
||||
<NavLink href="/leaderboard">Leaderboard</NavLink>
|
||||
<NavLink href="/journal">Le crieur</NavLink>
|
||||
<NavLink href="/profile">Profil</NavLink>
|
||||
{isJudge && <NavLink href="/admin">Administration</NavLink>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
disabled={signingOut}
|
||||
className="rounded-md px-2 py-1 transition-colors hover:text-gold disabled:opacity-50"
|
||||
>
|
||||
{signingOut ? "…" : "Déconnexion"}
|
||||
</button>
|
||||
</div>
|
||||
<Link href="/leaderboard" className="flex items-center gap-2 font-semibold tracking-wide">
|
||||
<Logo className="h-6 w-6 sm:h-7 sm:w-7" />
|
||||
<span>Le Tribunal</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden items-center gap-1 text-sm sm:flex sm:gap-2">{links}</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
aria-label={menuOpen ? "Fermer le menu" : "Ouvrir le menu"}
|
||||
aria-expanded={menuOpen}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md hover:bg-ivory/10 sm:hidden"
|
||||
>
|
||||
<span className="sr-only">Menu</span>
|
||||
{menuOpen ? (
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M6 6l12 12M18 6L6 18" strokeLinecap="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 7h16M4 12h16M4 17h16" strokeLinecap="round" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{menuOpen && (
|
||||
<div className="flex flex-col gap-1 border-t border-ivory/10 px-4 py-2 text-sm sm:hidden">
|
||||
{links}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,8 +36,8 @@ export function Podium({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8 rounded-xl border border-navy/10 bg-white px-4 py-6 shadow-sm sm:px-8">
|
||||
<div className="flex items-end justify-center gap-4 sm:gap-8">
|
||||
<div className="mb-8 overflow-x-auto rounded-xl border border-navy/10 bg-white px-2 py-6 shadow-sm sm:px-8">
|
||||
<div className="flex min-w-fit items-end justify-center gap-2 sm:gap-8">
|
||||
{topRanks.map((rank) => {
|
||||
const members = byRank.get(rank)!;
|
||||
const style = SLOT_STYLES[rank] ?? SLOT_STYLES[3];
|
||||
@@ -52,7 +52,7 @@ export function Podium({
|
||||
avatarUrl={member.avatar_url}
|
||||
size={rank === 1 ? "lg" : "md"}
|
||||
/>
|
||||
<span className="max-w-[7rem] truncate text-sm font-medium text-ink">
|
||||
<span className="max-w-[6rem] truncate text-sm font-medium text-ink sm:max-w-[7rem]">
|
||||
{member.pseudo}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-navy/60">{member.points} pts</span>
|
||||
@@ -67,7 +67,7 @@ export function Podium({
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={`flex w-20 sm:w-28 items-start justify-center rounded-t-md ${style.bg} pt-2 ring-1 sm:ring-2 ${style.pedestal} ${style.ring}`}
|
||||
className={`flex w-16 sm:w-28 items-start justify-center rounded-t-md ${style.bg} pt-2 ring-1 sm:ring-2 ${style.pedestal} ${style.ring}`}
|
||||
>
|
||||
<span className="text-lg font-bold text-navy sm:text-2xl">{rank}</span>
|
||||
</div>
|
||||
|
||||
+10
-1
@@ -198,7 +198,16 @@ insert into storage.buckets (id, name, public)
|
||||
values ('avatars', 'avatars', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
-- Bucket public => la lecture se fait via l'URL publique, pas besoin de policy SELECT.
|
||||
-- Le bucket public sert la lecture via l'URL publique (hors RLS), mais une
|
||||
-- policy SELECT reste nécessaire : pour un upload en upsert (remplacement
|
||||
-- d'une photo existante), Postgres doit pouvoir lire la ligne existante en
|
||||
-- interne pour évaluer la clause USING de l'UPDATE — sans SELECT, l'upsert
|
||||
-- échoue avec "new row violates row-level security policy" même si les
|
||||
-- policies INSERT/UPDATE sont correctes.
|
||||
drop policy if exists "Avatars are publicly viewable" on storage.objects;
|
||||
create policy "Avatars are publicly viewable"
|
||||
on storage.objects for select
|
||||
using (bucket_id = 'avatars');
|
||||
|
||||
drop policy if exists "Users can upload their own avatar" on storage.objects;
|
||||
create policy "Users can upload their own avatar"
|
||||
|
||||
Reference in New Issue
Block a user