diff --git a/docs/DATABASE.md b/docs/DATABASE.md index 97b398a..151f74a 100644 --- a/docs/DATABASE.md +++ b/docs/DATABASE.md @@ -21,6 +21,7 @@ non in questo file. | Bucket | Scopo | Note | | ------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `profili-giocatore` | Documento d'identità, certificato medico e foto tessera, in cartelle per giocatore (`/.`). | **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 (`/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 diff --git a/src/components/crapp/Avatar.tsx b/src/components/crapp/Avatar.tsx index a51370e..6e9f81d 100644 --- a/src/components/crapp/Avatar.tsx +++ b/src/components/crapp/Avatar.tsx @@ -1,23 +1,31 @@ +import { useEffect, useState } from "react"; import { cn } from "@/lib/utils"; -import { useAvatar } from "@/lib/avatar-store"; +import { urlAvatar } from "@/lib/avatar-store"; export function Avatar({ id, fallback, className, alt, + bust, }: { id: string; fallback: string; className?: string; alt?: string; + /** Forza il ricaricamento ignorando la cache: usato dopo aver cambiato la propria foto. */ + bust?: number; }) { - const src = useAvatar(id); - if (src) { + const [errore, setErrore] = useState(false); + useEffect(() => setErrore(false), [id, bust]); + + if (!errore) { + const src = bust ? `${urlAvatar(id)}?v=${bust}` : urlAvatar(id); return ( {alt setErrore(true)} className={cn("shrink-0 rounded-2xl object-cover", className)} /> ); diff --git a/src/lib/avatar-store.ts b/src/lib/avatar-store.ts index 591915c..d564576 100644 --- a/src/lib/avatar-store.ts +++ b/src/lib/avatar-store.ts @@ -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 listeners = new Set<() => void>(); -let cache: Record | undefined; +const BUCKET = "avatar-giocatori"; +const NOME_FILE = "avatar.jpg"; -function read(): Record { - if (cache) return cache; - if (typeof window === "undefined") return {}; - try { - cache = JSON.parse(window.localStorage.getItem(KEY) ?? "{}") as Record; - } catch { - cache = {}; - } - return cache; +function percorso(id: string) { + return `${id}/${NOME_FILE}`; } -function write(next: Record) { - cache = next; - try { - window.localStorage.setItem(KEY, JSON.stringify(next)); - } catch { - /* quota o storage non disponibile */ - } - listeners.forEach((l) => l()); +/** URL pubblico e stabile: il bucket è pubblico, nessuna richiesta di rete. */ +export function urlAvatar(id: string): string { + return supabase.storage.from(BUCKET).getPublicUrl(percorso(id)).data.publicUrl; } -const EMPTY: Record = {}; +const chiaveEsiste = (id: string) => ["avatar-esiste", id] as const; -export function useAvatars(): Record { - return useSyncExternalStore( - (cb) => { - listeners.add(cb); - return () => listeners.delete(cb); +/** Solo per il proprio profilo: sapere se mostrare "rimuovi immagine". */ +export function useAvatarEsiste(id: string | undefined) { + return useQuery({ + queryKey: chiaveEsiste(id ?? ""), + 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 { - const all = useAvatars(); - return id ? (all[id] ?? null) : null; +export function useInvalidaAvatarEsiste() { + const qc = useQueryClient(); + return (id: string) => qc.invalidateQueries({ queryKey: chiaveEsiste(id) }); } -export function rimuoviAvatar(id: string) { - const next = { ...read() }; - 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 { +/** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */ +function fileToBlob(file: File, size = 256): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error("Lettura file fallita")); @@ -79,10 +60,30 @@ export function fileToAvatar(file: File, size = 256): Promise { 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; }; 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; +} diff --git a/src/routes/profilo.tsx b/src/routes/profilo.tsx index 3eef052..1818376 100644 --- a/src/routes/profilo.tsx +++ b/src/routes/profilo.tsx @@ -5,7 +5,12 @@ import { Flame, Camera, Trash2, Bell, LogOut, ShieldCheck } from "lucide-react"; import { cn } from "@/lib/utils"; import { PageHeader, Section, StatTile } from "@/components/crapp/ui-bits"; 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 { CollezioneBadge } from "@/components/crapp/CollezioneBadge"; import { ProfiloAmministrativo } from "@/components/crapp/ProfiloAmministrativo"; @@ -47,7 +52,9 @@ function Profilo() { const admin = useIsAdmin(); const ultimoMese = usePresenzeUltimoMese(g?.id); const inputRef = useRef(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 [inCorso, setInCorso] = useState(false); const [supportate, setSupportate] = useState(true); @@ -97,7 +104,9 @@ function Profilo() { e.target.value = ""; if (!file || !g) return; try { - salvaAvatar(g.id, await fileToAvatar(file)); + await caricaAvatar(g.id, file); + setBust(Date.now()); + invalidaAvatarEsiste(g.id); toast.success("Immagine profilo aggiornata"); } catch { toast.error("Non sono riuscito a caricare l'immagine"); @@ -112,7 +121,7 @@ function Profilo() {
- + Cambia immagine profilo - {foto ? ( + {fotoEsiste.data ? (