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
+11 -3
View File
@@ -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 (
<img
src={src}
alt={alt ?? `Foto di ${fallback}`}
onError={() => setErrore(true)}
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 listeners = new Set<() => void>();
let cache: Record<string, string> | undefined;
const BUCKET = "avatar-giocatori";
const NOME_FILE = "avatar.jpg";
function read(): Record<string, string> {
if (cache) return cache;
if (typeof window === "undefined") return {};
try {
cache = JSON.parse(window.localStorage.getItem(KEY) ?? "{}") as Record<string, string>;
} catch {
cache = {};
}
return cache;
function percorso(id: string) {
return `${id}/${NOME_FILE}`;
}
function write(next: Record<string, string>) {
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<string, string> = {};
const chiaveEsiste = (id: string) => ["avatar-esiste", id] as const;
export function useAvatars(): Record<string, string> {
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<string> {
/** Ridimensiona e comprime l'immagine scelta in un quadrato JPEG. */
function fileToBlob(file: File, size = 256): Promise<Blob> {
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<string> {
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;
}
+23 -8
View File
@@ -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<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 [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() {
<div className="premi rounded-3xl bg-card p-4 shadow-card">
<div className="flex flex-col items-center gap-4">
<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
ref={inputRef}
type="file"
@@ -138,12 +147,18 @@ function Profilo() {
>
Cambia immagine profilo
</button>
{foto ? (
{fotoEsiste.data ? (
<button
type="button"
onClick={() => {
rimuoviAvatar(g.id);
toast.success("Immagine rimossa");
onClick={async () => {
try {
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"
aria-label="Rimuovi immagine"