Files
CRAPP/src/components/crapp/ui-bits.tsx
T
davideandClaude Sonnet 5.5 77e5daaa7a Caratteri più grandi in tutta l'app (DD-049); etichetta «Cacche» nella rosa
Ogni testo sotto i 16 px sale di un passo in tutte le pagine (12→13, 13→14,
14→15, 15→16); restano invariate le barre a pillola dei menu (navigazione in
basso e sottosezioni) e i badge da 10-11 px. Nella scheda giocatore di Squadra
l'etichetta «Cacche/partita» diventa «Cacche» (il valore resta la media).
Documentazione: DD-049, CHANGELOG, ARCHITECTURE, PROJECT_STATE, moduli squadra
e allenatore.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-06 11:50:28 +02:00

412 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useEffect,
useId,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react";
import { Link } from "@tanstack/react-router";
import { AnimatePresence, motion } from "motion/react";
import { ChevronDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { inizialiDa, statoMeta, type Stato } from "@/lib/crapp-data";
import { nomeCompleto } from "@/lib/giocatori-squadra";
import { useGiocatoreBase } from "@/lib/user-store";
import { useMotoRidotto } from "@/lib/motion";
import { molla, proietta } from "@/lib/molla";
import {
pallinoNotifiche,
useEliminaNotifica,
useNotificheMie,
useSegnaLette,
type NotificaUtente,
} from "@/lib/notifiche-utente";
import { Avatar } from "@/components/crapp/Avatar";
import { Reveal } from "@/components/motion/Reveal";
import { Numero } from "@/components/motion/Numero";
export function TeamLogo({
className,
/**
* Di default l'icona della PWA, che ha dentro il nome dell'app. Dove serve lo
* stemma della squadra e basta si passa `/logo-nerorosso.svg`.
*/
src = "/icon-192.png",
}: {
className?: string;
src?: string;
}) {
return (
<img
src={src}
alt="CRAP Volley"
width={192}
height={192}
decoding="async"
className={cn("shrink-0 rounded-2xl object-cover shadow-pop", className)}
/>
);
}
/**
* Superficie standard dell'app: era ripetuta a mano una ventina di volte come
* `rounded-3xl bg-card p-4 shadow-card`, quindi cambiare raggio od ombra
* voleva dire toccare venti file.
*
* Gerarchia dei raggi: contenitore `3xl` → elemento interno `2xl` →
* controllo `full`.
*/
export function Card({
className,
as: Tag = "div",
...props
}: ComponentPropsWithoutRef<"div"> & { as?: "div" | "article" | "section" }) {
return <Tag className={cn("premi rounded-3xl bg-card p-4 shadow-card", className)} {...props} />;
}
/** "or ora" / "tra 3 ore" / "2 giorni fa" — così basta guardare senza fare i conti. */
function tempoRelativo(dataIso: string): string {
const rtf = new Intl.RelativeTimeFormat("it", { numeric: "auto" });
const minuti = Math.round((new Date(dataIso).getTime() - Date.now()) / 60_000);
if (Math.abs(minuti) < 60) return rtf.format(minuti, "minute");
const ore = Math.round(minuti / 60);
if (Math.abs(ore) < 24) return rtf.format(ore, "hour");
return rtf.format(Math.round(ore / 24), "day");
}
/**
* Una riga del pannello notifiche, eliminabile con uno swipe a sinistra o con la ×: non
* c'è pulizia automatica delle notifiche vecchie (M17), quindi è l'unico modo per un
* giocatore di togliersele di torno. Soglia e proiezione della velocità di rilascio come
* lo swipe del calendario (`src/routes/calendario.tsx`), ma qui in una sola direzione: non
* c'è "indietro", solo "via".
*/
function RigaNotifica({
notifica,
onElimina,
}: {
notifica: NotificaUtente;
onElimina: (id: string) => void;
}) {
const ridotto = useMotoRidotto();
return (
<motion.li
layout={!ridotto}
initial={false}
exit={{ opacity: 0, height: 0, marginTop: 0, marginBottom: 0 }}
transition={molla.ui}
className="relative overflow-hidden rounded-xl"
>
<motion.div
drag={ridotto ? false : "x"}
dragConstraints={{ left: -80, right: 0 }}
dragElastic={{ left: 0.2, right: 0 }}
dragMomentum={false}
onDragEnd={(_, info) => {
const arrivo = info.offset.x + proietta(info.velocity.x);
if (arrivo < -60) onElimina(notifica.id);
}}
className="touch-pan-y bg-card p-3 pr-8 text-[15px]"
>
<p className="font-semibold">{notifica.titolo}</p>
{notifica.corpo ? (
<p className="mt-0.5 whitespace-pre-line text-muted-foreground">{notifica.corpo}</p>
) : null}
<p className="mt-1 text-[13px] text-muted-foreground">{tempoRelativo(notifica.creataIl)}</p>
</motion.div>
<button
type="button"
onClick={() => onElimina(notifica.id)}
aria-label="Rimuovi notifica"
className="absolute right-1 top-1 rounded-full p-1.5 text-muted-foreground active:scale-90"
>
<X className="h-3.5 w-3.5" />
</button>
</motion.li>
);
}
/**
* Accesso al profilo in alto a destra: la BottomNav ha quattro voci e questa è
* l'unica porta verso `/profilo`. Sulla pagina del profilo si passa `azione` a
* `PageHeader` con il logo che porta alla home.
*
* Usa `useGiocatoreBase` (sola anagrafica) e non `useIo`: qui serve solo id e
* iniziali, mentre `useIo` calcola l'intera rosa con statistiche (MVP, pagelle,
* cacche, palloni, infortuni). Essendo in un componente montato su quasi ogni
* pagina, quei moduli finirebbero nel bundle condiviso di tutte le rotte.
*
* Sopra l'avatar sta il pallino del centro notifiche (M17): il tap sull'avatar
* porta sempre al profilo, quello sul pallino apre il pannello.
*/
export function LinkProfilo() {
const g = useGiocatoreBase();
if (!g) return <TeamLogo className="h-12 w-12" />;
return (
<div className="relative shrink-0">
<Link
to="/profilo"
aria-label="Il tuo profilo"
className="premi block rounded-2xl ring-2 ring-primary-foreground/30"
>
<Avatar id={g.id} fallback={inizialiDa(nomeCompleto(g))} className="h-12 w-12 text-lg" />
</Link>
<PallinoNotifiche />
</div>
);
}
/**
* Pallino del centro notifiche (M17) sull'angolo dell'avatar (aspetto deciso da
* `pallinoNotifiche()`). Copre quattro sorgenti generate lato database (mai dal client):
* messaggi admin, promemoria automatici, turno palloni, sollecito presenze — vedi
* `src/lib/notifiche-utente.ts`.
*
* Nel progetto non c'è una libreria dropdown: il pannello è un `div` posizionato a mano,
* chiuso al click fuori o con Escape. Aprirlo segna tutte le notifiche come lette; per
* toglierle di mezzo per sempre serve lo swipe o la × di `RigaNotifica`.
*/
function PallinoNotifiche() {
const { notifiche, nonLette } = useNotificheMie();
const segnaLette = useSegnaLette();
const eliminaNotifica = useEliminaNotifica();
const [aperto, setAperto] = useState(false);
const riquadro = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!aperto) return;
const chiudiSeFuori = (e: MouseEvent) => {
if (riquadro.current && !riquadro.current.contains(e.target as Node)) setAperto(false);
};
const chiudiConEsc = (e: KeyboardEvent) => {
if (e.key === "Escape") setAperto(false);
};
document.addEventListener("mousedown", chiudiSeFuori);
document.addEventListener("keydown", chiudiConEsc);
return () => {
document.removeEventListener("mousedown", chiudiSeFuori);
document.removeEventListener("keydown", chiudiConEsc);
};
}, [aperto]);
const pallino = pallinoNotifiche(notifiche.length, nonLette);
if (!pallino) return null;
function alClick() {
const stavaChiuso = !aperto;
setAperto(stavaChiuso);
if (stavaChiuso && nonLette > 0) segnaLette.mutate();
}
// Tolta l'ultima, il pallino sparisce: chiuso anche il pannello, così alla prossima
// notifica non si riapre da solo.
function elimina(id: string) {
if (notifiche.length === 1) setAperto(false);
eliminaNotifica.mutate(id);
}
return (
<div ref={riquadro}>
{/* `after:` allarga l'area di tocco oltre i 20px del pallino senza ingrandirlo. */}
<button
type="button"
onClick={alClick}
aria-label={nonLette > 0 ? `Notifiche, ${nonLette} da leggere` : "Notifiche"}
aria-expanded={aperto}
className={cn(
"absolute -right-1.5 -top-1.5 flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-[11px] font-bold shadow-pop ring-2 ring-primary-foreground after:absolute after:-inset-3 active:scale-90",
pallino.daLeggere
? "bg-accent-grad text-accent-foreground"
: "bg-muted text-muted-foreground",
)}
>
{pallino.testo}
</button>
{aperto ? (
<div className="absolute right-0 top-14 z-50 w-72 max-w-[calc(100vw-2.5rem)] rounded-2xl bg-card p-2 text-foreground shadow-card">
<ul className="max-h-80 overflow-y-auto">
<AnimatePresence initial={false}>
{notifiche.map((n) => (
<RigaNotifica key={n.id} notifica={n} onElimina={elimina} />
))}
</AnimatePresence>
</ul>
</div>
) : null}
</div>
);
}
export function PageHeader({
titolo,
sottotitolo,
azione,
}: {
titolo: string;
sottotitolo?: string;
/** Sostituisce il link al profilo in alto a destra. */
azione?: ReactNode;
}) {
return (
<header className="bg-hero px-5 pb-8 pt-7 text-primary-foreground">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4">
<div className="min-w-0">
<h1 className="truncate font-display-lg text-3xl uppercase">{titolo}</h1>
{sottotitolo ? (
<p className="mt-1 truncate text-[15px] text-primary-foreground/80">{sottotitolo}</p>
) : null}
</div>
{azione ?? <LinkProfilo />}
</div>
</header>
);
}
export function Section({
titolo,
azione,
children,
indice = 0,
}: {
titolo?: string;
azione?: ReactNode;
children: ReactNode;
indice?: number;
}) {
return (
<Reveal as="section" indice={indice} className="px-5 py-4">
{titolo || azione ? (
<div className="mb-3 flex items-center justify-between gap-3">
{titolo ? <h2 className="font-display-sm text-[20px] uppercase">{titolo}</h2> : <span />}
{azione}
</div>
) : null}
{children}
</Reveal>
);
}
/** Sezione con titolo cliccabile: il contenuto si apre e chiude. `anteprima` resta sempre visibile. */
export function SezioneTendina({
titolo,
children,
anteprima,
azione,
defaultAperta = false,
indice = 0,
}: {
titolo: string;
children: ReactNode;
anteprima?: ReactNode;
azione?: ReactNode;
defaultAperta?: boolean;
indice?: number;
}) {
const [aperta, setAperta] = useState(defaultAperta);
const id = useId();
return (
<Reveal as="section" indice={indice} className="px-5 py-4">
<button
type="button"
onClick={() => setAperta((v) => !v)}
className="mb-3 flex min-h-11 w-full items-center justify-between gap-3 text-left active:scale-[0.99]"
aria-expanded={aperta}
aria-controls={id}
>
<h2 className="font-display-sm text-lg uppercase">{titolo}</h2>
<span className="flex shrink-0 items-center gap-2">
{azione}
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
aperta && "rotate-180",
)}
/>
</span>
</button>
{anteprima}
<div id={id}>{aperta ? children : null}</div>
</Reveal>
);
}
/**
* Classi condivise di input, select e textarea nei form dell'app. `h-10`:
* `input[type="date"]` ha al suo interno segmenti e icona nativi che, anche
* con `appearance-none`, restano un filo più alti di un input di testo a
* parità di padding — un'altezza esplicita allinea tutti i campi tra loro.
*/
export const classiInput =
"h-10 w-full min-w-0 rounded-xl border border-border bg-background px-3 py-2 text-[15px]";
/**
* Etichetta + controllo di un form. `min-w-0`: dentro `grid-cols-2` questa label
* è l'elemento di griglia e ha `min-width: auto`, quindi si allarga fino al
* contenuto invece di stare nella colonna. Con un controllo nativo largo dentro
* (una data o un'ora su iOS) la coppia sfonda la card.
*/
export function Campo({ label, children }: { label: string; children: ReactNode }) {
return (
<label className="block min-w-0">
<span className="text-[13px] font-bold uppercase tracking-wide text-muted-foreground">
{label}
</span>
<span className="mt-1 block">{children}</span>
</label>
);
}
/**
* `<select>` nativo con la stessa altezza degli altri campi del form. Il
* controllo nativo di un `<select>` ignora parzialmente il padding di
* `classiInput` (soprattutto su Android), risultando più alto o più basso
* degli input accanto: `appearance-none` lo riporta a una scatola CSS
* normale, la freccia va poi ridisegnata a mano perché sparisce con lui.
*/
export function Select(props: ComponentPropsWithoutRef<"select">) {
return (
<span className="relative block">
<select {...props} className={cn(classiInput, "appearance-none pr-8", props.className)} />
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</span>
);
}
export function StatoBadge({ stato, className }: { stato: Stato; className?: string }) {
const meta = statoMeta[stato];
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-[13px] font-bold uppercase",
meta.className,
className,
)}
>
{meta.label}
</span>
);
}
export function StatTile({
valore,
label,
hint,
}: {
valore: ReactNode;
label: string;
hint?: string;
}) {
return (
<div className="premi rounded-2xl bg-card p-3 shadow-card">
<p className="font-display text-2xl leading-none">
{typeof valore === "number" ? <Numero valore={valore} /> : valore}
</p>
<p className="mt-1 text-[13px] font-semibold uppercase tracking-wide text-muted-foreground">
{label}
</p>
{hint ? <p className="mt-0.5 text-[13px] text-accent">{hint}</p> : null}
</div>
);
}