Sincronizza le foto profilo dei giocatori su Supabase Storage

Le foto caricate in Profilo vivevano solo in localStorage: ogni giocatore
vedeva la propria foto solo sul proprio dispositivo, mai quella dei
compagni nella rosa (Squadra). Ora vengono caricate in un bucket
pubblico dedicato (avatar-giocatori, migration m6) e il componente
Avatar carica l'URL pubblico con fallback su numero/iniziali se assente
o non ancora caricata.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 14:32:19 +02:00
co-authored by Claude Sonnet 5
parent f6036f21ec
commit d2d62b6799
5 changed files with 120 additions and 57 deletions
+1
View File
@@ -21,6 +21,7 @@ non in questo file.
| Bucket | Scopo | Note | | Bucket | Scopo | Note |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `profili-giocatore` | Documento d'identità, certificato medico e foto tessera, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato** e destinato a restare tale: contiene documenti e dati sanitari, che non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella, l'admin può scaricare tutto tramite signed URL a scadenza breve. Creato dalla migration `m3_bucket_profili`. | | `profili-giocatore` | Documento d'identità, certificato medico e foto tessera, in cartelle per giocatore (`<giocatore_id>/<sezione>.<est>`). | **Privato** e destinato a restare tale: contiene documenti e dati sanitari, che non devono mai avere URL pubblici (DD-016 regola 4). Il giocatore gestisce solo la propria cartella, l'admin può scaricare tutto tramite signed URL a scadenza breve. Creato dalla migration `m3_bucket_profili`. |
| `avatar-giocatori` | Foto profilo mostrate nel cerchio avatar (Squadra, Profilo), un file per giocatore (`<giocatore_id>/avatar.jpg`). | **Pubblico**: foto informali, non documenti sensibili. Qualsiasi autenticato può caricare/sostituire/eliminare un file (nessun controllo per-proprietario, la maggior parte dei giocatori non ha ancora `auth_user_id` collegato, DD-018). Letto da `src/lib/avatar-store.ts`. Creato dalla migration `m6_avatar_giocatori`. |
## Eventi e presenze ## Eventi e presenze
+11 -3
View File
@@ -1,23 +1,31 @@
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAvatar } from "@/lib/avatar-store"; import { urlAvatar } from "@/lib/avatar-store";
export function Avatar({ export function Avatar({
id, id,
fallback, fallback,
className, className,
alt, alt,
bust,
}: { }: {
id: string; id: string;
fallback: string; fallback: string;
className?: string; className?: string;
alt?: string; alt?: string;
/** Forza il ricaricamento ignorando la cache: usato dopo aver cambiato la propria foto. */
bust?: number;
}) { }) {
const src = useAvatar(id); const [errore, setErrore] = useState(false);
if (src) { useEffect(() => setErrore(false), [id, bust]);
if (!errore) {
const src = bust ? `${urlAvatar(id)}?v=${bust}` : urlAvatar(id);
return ( return (
<img <img
src={src} src={src}
alt={alt ?? `Foto di ${fallback}`} alt={alt ?? `Foto di ${fallback}`}
onError={() => setErrore(true)}
className={cn("shrink-0 rounded-2xl object-cover", className)} className={cn("shrink-0 rounded-2xl object-cover", className)}
/> />
); );
+47 -46
View File
@@ -1,60 +1,41 @@
import { useSyncExternalStore } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
const KEY = "crapp-avatars-v1"; const BUCKET = "avatar-giocatori";
const listeners = new Set<() => void>(); const NOME_FILE = "avatar.jpg";
let cache: Record<string, string> | undefined;
function read(): Record<string, string> { function percorso(id: string) {
if (cache) return cache; return `${id}/${NOME_FILE}`;
if (typeof window === "undefined") return {};
try {
cache = JSON.parse(window.localStorage.getItem(KEY) ?? "{}") as Record<string, string>;
} catch {
cache = {};
}
return cache;
} }
function write(next: Record<string, string>) { /** URL pubblico e stabile: il bucket è pubblico, nessuna richiesta di rete. */
cache = next; export function urlAvatar(id: string): string {
try { return supabase.storage.from(BUCKET).getPublicUrl(percorso(id)).data.publicUrl;
window.localStorage.setItem(KEY, JSON.stringify(next));
} catch {
/* quota o storage non disponibile */
}
listeners.forEach((l) => l());
} }
const EMPTY: Record<string, string> = {}; const chiaveEsiste = (id: string) => ["avatar-esiste", id] as const;
export function useAvatars(): Record<string, string> { /** Solo per il proprio profilo: sapere se mostrare "rimuovi immagine". */
return useSyncExternalStore( export function useAvatarEsiste(id: string | undefined) {
(cb) => { return useQuery({
listeners.add(cb); queryKey: chiaveEsiste(id ?? ""),
return () => listeners.delete(cb); enabled: !!id,
staleTime: 60_000,
queryFn: async () => {
const { data, error } = await supabase.storage.from(BUCKET).list(id!, { search: NOME_FILE });
if (error) throw error;
return (data ?? []).some((f) => f.name === NOME_FILE);
}, },
() => read(), });
() => EMPTY,
);
} }
export function useAvatar(id: string | undefined): string | null { export function useInvalidaAvatarEsiste() {
const all = useAvatars(); const qc = useQueryClient();
return id ? (all[id] ?? null) : null; return (id: string) => qc.invalidateQueries({ queryKey: chiaveEsiste(id) });
} }
export function rimuoviAvatar(id: string) { /** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */
const next = { ...read() }; function fileToBlob(file: File, size = 256): Promise<Blob> {
delete next[id];
write(next);
}
export function salvaAvatar(id: string, dataUrl: string) {
write({ ...read(), [id]: dataUrl });
}
/** Ridimensiona e comprime l'immagine scelta per stare in localStorage. */
export function fileToAvatar(file: File, size = 256): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onerror = () => reject(new Error("Lettura file fallita")); reader.onerror = () => reject(new Error("Lettura file fallita"));
@@ -79,10 +60,30 @@ export function fileToAvatar(file: File, size = 256): Promise<string> {
size, size,
size, size,
); );
resolve(canvas.toDataURL("image/jpeg", 0.82)); canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("Conversione fallita"))),
"image/jpeg",
0.82,
);
}; };
img.src = reader.result as string; img.src = reader.result as string;
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
} }
/** Ridimensiona, comprime e carica la foto profilo: sovrascrive quella precedente. */
export async function caricaAvatar(id: string, file: File) {
const blob = await fileToBlob(file);
const { error } = await supabase.storage.from(BUCKET).upload(percorso(id), blob, {
contentType: "image/jpeg",
upsert: true,
cacheControl: "60",
});
if (error) throw error;
}
export async function rimuoviAvatar(id: string) {
const { error } = await supabase.storage.from(BUCKET).remove([percorso(id)]);
if (error) throw error;
}
+23 -8
View File
@@ -5,7 +5,12 @@ import { Flame, Camera, Trash2, Bell, LogOut, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits"; import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits";
import { Avatar } from "@/components/crapp/Avatar"; import { Avatar } from "@/components/crapp/Avatar";
import { fileToAvatar, salvaAvatar, rimuoviAvatar, useAvatar } from "@/lib/avatar-store"; import {
caricaAvatar,
rimuoviAvatar,
useAvatarEsiste,
useInvalidaAvatarEsiste,
} from "@/lib/avatar-store";
import { SerieGriglia } from "@/components/crapp/SerieCard"; import { SerieGriglia } from "@/components/crapp/SerieCard";
import { CollezioneBadge } from "@/components/crapp/CollezioneBadge"; import { CollezioneBadge } from "@/components/crapp/CollezioneBadge";
import { ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo"; import { ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo";
@@ -47,7 +52,9 @@ function Profilo() {
const admin = useIsAdmin(); const admin = useIsAdmin();
const ultimoMese = usePresenzeUltimoMese(g?.id); const ultimoMese = usePresenzeUltimoMese(g?.id);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const foto = useAvatar(g?.id); const fotoEsiste = useAvatarEsiste(g?.id);
const invalidaAvatarEsiste = useInvalidaAvatarEsiste();
const [bust, setBust] = useState(0);
const [notifiche, setNotifiche] = useState(false); const [notifiche, setNotifiche] = useState(false);
const [inCorso, setInCorso] = useState(false); const [inCorso, setInCorso] = useState(false);
const [supportate, setSupportate] = useState(true); const [supportate, setSupportate] = useState(true);
@@ -97,7 +104,9 @@ function Profilo() {
e.target.value = ""; e.target.value = "";
if (!file || !g) return; if (!file || !g) return;
try { try {
salvaAvatar(g.id, await fileToAvatar(file)); await caricaAvatar(g.id, file);
setBust(Date.now());
invalidaAvatarEsiste(g.id);
toast.success("Immagine profilo aggiornata"); toast.success("Immagine profilo aggiornata");
} catch { } catch {
toast.error("Non sono riuscito a caricare l'immagine"); toast.error("Non sono riuscito a caricare l'immagine");
@@ -112,7 +121,7 @@ function Profilo() {
<div className="premi rounded-3xl bg-card p-4 shadow-card"> <div className="premi rounded-3xl bg-card p-4 shadow-card">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div className="relative"> <div className="relative">
<Avatar id={g.id} fallback={g.iniziali} className="h-20 w-20 text-2xl" /> <Avatar id={g.id} fallback={g.iniziali} className="h-20 w-20 text-2xl" bust={bust} />
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
@@ -138,12 +147,18 @@ function Profilo() {
> >
Cambia immagine profilo Cambia immagine profilo
</button> </button>
{foto ? ( {fotoEsiste.data ? (
<button <button
type="button" type="button"
onClick={() => { onClick={async () => {
rimuoviAvatar(g.id); try {
toast.success("Immagine rimossa"); await rimuoviAvatar(g.id);
setBust(Date.now());
invalidaAvatarEsiste(g.id);
toast.success("Immagine rimossa");
} catch {
toast.error("Non sono riuscito a rimuovere l'immagine");
}
}} }}
className="grid h-9 w-9 place-items-center rounded-xl bg-secondary text-muted-foreground" className="grid h-9 w-9 place-items-center rounded-xl bg-secondary text-muted-foreground"
aria-label="Rimuovi immagine" aria-label="Rimuovi immagine"
@@ -0,0 +1,38 @@
-- M6 — Bucket pubblico per le foto profilo dei giocatori (avatar).
--
-- Sostituisce l'attuale localStorage per-dispositivo (src/lib/avatar-store.ts):
-- senza un bucket condiviso ogni giocatore vedeva la propria foto solo sul
-- proprio telefono, mai quella dei compagni nella rosa (Squadra).
--
-- Bucket pubblico: sono foto profilo informali di una squadra amatoriale, non
-- documenti sensibili (quelli restano nel bucket privato profili-giocatore,
-- DD-016 regola 4). Segue il modello "chiunque autenticato può" già usato per
-- mvp_voti/eventi_app, non il modello per-proprietario di profili-giocatore:
-- la maggior parte dei giocatori non ha ancora auth_user_id collegato
-- (DD-018, oggi 2 su 17), quindi un controllo per-proprietario bloccherebbe
-- l'upload per quasi tutta la squadra. L'accesso all'app richiede comunque
-- login Google (DD-011, vedi src/routes/__root.tsx), quindi auth.uid() è
-- sempre valorizzato per chi usa davvero l'app.
--
-- Percorso file: '<giocatore_id>/avatar.jpg' (un solo file per giocatore,
-- sovrascritto a ogni caricamento).
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
VALUES ('avatar-giocatori', 'avatar-giocatori', true, 2097152, ARRAY['image/jpeg']);
CREATE POLICY "Chiunque puo vedere gli avatar" ON storage.objects
FOR SELECT TO anon, authenticated
USING (bucket_id = 'avatar-giocatori');
CREATE POLICY "Gli autenticati caricano gli avatar" ON storage.objects
FOR INSERT TO authenticated
WITH CHECK (bucket_id = 'avatar-giocatori');
CREATE POLICY "Gli autenticati sostituiscono gli avatar" ON storage.objects
FOR UPDATE TO authenticated
USING (bucket_id = 'avatar-giocatori')
WITH CHECK (bucket_id = 'avatar-giocatori');
CREATE POLICY "Gli autenticati eliminano gli avatar" ON storage.objects
FOR DELETE TO authenticated
USING (bucket_id = 'avatar-giocatori');