feat(chat): modifica dei messaggi e firma di chi risponde
Due mancanze emerse provando la chat a canali in produzione. **Modifica dei messaggi** (migration 0022, additiva, già applicata a prod). Modello Slack/Discord: si corregge un proprio messaggio senza limite di tempo, il testo precedente non si conserva, accanto all'ora compare «modificato». Scelta deliberata, annotata in STATUS.md. Il punto delicato non è la scrittura ma la propagazione: il poll chiede `created_at > since` e una modifica non cambia `created_at`, quindi l'altra parte vedrebbe il testo vecchio fino a un reload. Il filtro ora guarda anche `edited_at`, e il watermark del client è il massimo fra i due su tutti i messaggi — senza, il server rispedirebbe lo stesso messaggio a ogni giro per sempre. Il merge per id già esistente fa il resto, quindi niente duplicati. Il non-letto resta ancorato a `created_at` di proposito: correggere un refuso non deve riaccendere il pallino di un canale già letto. Si modifica solo ciò di cui si è autori — il controllo è su `author`, non solo sulla proprietà dell'entità, e lo rifà il server. **Firma in chat.** Il nome era la stringa "iamcavalli" cablata nel pannello: il cliente leggeva il marchio dove si aspetta una persona. Ora arriva da `settings` (nessuna migration) con foto via URL esterno, che rispetta il vincolo LOCKED #5 — l'upload su volume non esiste, la deroga per l'audit è scritta in CLAUDE.md ma non è mai stata costruita. Avatar rotto o assente ricade sul monogramma. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
editConversationMessage,
|
||||
markConversationRead,
|
||||
replyToConversation,
|
||||
} from "@/app/admin/conversazioni/actions";
|
||||
@@ -274,7 +275,9 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
||||
Nessun messaggio in questo canale.
|
||||
</p>
|
||||
) : (
|
||||
visibleMessages.map((m) => <MessageBubble key={m.id} message={m} />)
|
||||
visibleMessages.map((m) => (
|
||||
<MessageBubble key={m.id} message={m} clientId={thread.clientId} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -303,43 +306,143 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
||||
|
||||
function MessageBubble({
|
||||
message,
|
||||
clientId,
|
||||
}: {
|
||||
message: ConversationThread["messages"][number];
|
||||
clientId: string;
|
||||
}) {
|
||||
const isAdmin = message.author === "admin";
|
||||
// Solo task e deliverable: per una fase l'etichetta ripeterebbe il tab attivo.
|
||||
const showEntity =
|
||||
message.entityType === "task" || message.entityType === "deliverable";
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(message.body);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [saving, startSaving] = useTransition();
|
||||
|
||||
function startEditing() {
|
||||
setDraft(message.body);
|
||||
setFailed(false);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function save() {
|
||||
const next = draft.trim();
|
||||
if (!next || next === message.body) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.set("comment_id", message.id);
|
||||
fd.set("body", next);
|
||||
startSaving(async () => {
|
||||
try {
|
||||
await editConversationMessage(clientId, fd);
|
||||
setEditing(false);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex", isAdmin && "justify-end")}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] p-3.5 rounded-2xl shadow-sm space-y-1",
|
||||
isAdmin
|
||||
? "bg-primary text-primary-foreground rounded-tr-sm"
|
||||
: "bg-background border border-border text-foreground rounded-tl-sm"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-bold",
|
||||
isAdmin ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isAdmin ? "Tu (Admin)" : "Cliente"}
|
||||
</span>
|
||||
{showEntity && <EntityBadge label={message.entityLabel} />}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed whitespace-pre-wrap">{message.body}</p>
|
||||
<p
|
||||
<div className={cn("group flex", isAdmin && "justify-end")}>
|
||||
<div className="max-w-[75%]">
|
||||
<div
|
||||
className={cn(
|
||||
"text-[9px] font-mono",
|
||||
isAdmin ? "text-primary-foreground/50" : "text-muted-foreground/60"
|
||||
"p-3.5 rounded-2xl shadow-sm space-y-1",
|
||||
isAdmin
|
||||
? "bg-primary text-primary-foreground rounded-tr-sm"
|
||||
: "bg-background border border-border text-foreground rounded-tl-sm"
|
||||
)}
|
||||
>
|
||||
{formatMessageTime(message.created_at)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-bold",
|
||||
isAdmin ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isAdmin ? "Tu (Admin)" : "Cliente"}
|
||||
</span>
|
||||
{showEntity && <EntityBadge label={message.entityLabel} />}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
rows={3}
|
||||
autoFocus
|
||||
maxLength={2000}
|
||||
aria-label="Modifica il messaggio"
|
||||
className="resize-none text-xs text-foreground bg-background"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs leading-relaxed whitespace-pre-wrap">
|
||||
{message.body}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-[9px] font-mono",
|
||||
isAdmin ? "text-primary-foreground/50" : "text-muted-foreground/60"
|
||||
)}
|
||||
>
|
||||
{formatMessageTime(message.created_at)}
|
||||
{message.edited_at && " · modificato"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Fuori dalla bolla: sopra `bg-primary` un testo tenue non si leggerebbe. */}
|
||||
{isAdmin && (
|
||||
<div className={cn("mt-1 flex items-center gap-3", "justify-end")}>
|
||||
{editing ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={saving}
|
||||
className="text-[11px] font-semibold text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Salvo…" : "Salva"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(false)}
|
||||
className="text-[11px] font-medium text-muted-foreground hover:underline"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
{failed && (
|
||||
<span className="text-[10px] text-destructive">
|
||||
Non salvato, riprova
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
className="text-[11px] font-medium text-muted-foreground opacity-0 transition-opacity hover:underline focus-visible:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
>
|
||||
Modifica
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -73,6 +73,7 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
||||
toggleExpanded,
|
||||
} = useChatContext();
|
||||
const preview = usePreview();
|
||||
const { adminName, adminAvatarUrl } = chat;
|
||||
|
||||
const channels = useMemo(() => buildChannels(clientId, phases), [clientId, phases]);
|
||||
const index = useMemo(() => buildChannelIndex(clientId, phases), [clientId, phases]);
|
||||
@@ -103,6 +104,10 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
||||
// ── Non letti per canale ────────────────────────────────────────────────────
|
||||
// Un canale ha novità se l'ultimo messaggio dell'admin è più recente di quando
|
||||
// il cliente l'ha guardato. Senza ricevuta di lettura vale come mai letto.
|
||||
//
|
||||
// Si guarda created_at e NON edited_at, di proposito: correggere un refuso in
|
||||
// un messaggio già letto non deve far riaccendere il pallino, altrimenti ogni
|
||||
// correzione somiglia a un messaggio nuovo. Non è una svista da "sistemare".
|
||||
const unreadChannels = useMemo(() => {
|
||||
const lastAdminAt = new Map<string, number>();
|
||||
for (const m of messages) {
|
||||
@@ -135,10 +140,20 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
||||
// ── Polling ────────────────────────────────────────────────────────────────
|
||||
// `since` in una ref: se fosse una dipendenza dell'effetto, ogni messaggio
|
||||
// nuovo smonterebbe e rimonterebbe l'intervallo, rimettendo il timer a zero.
|
||||
//
|
||||
// Il watermark è il massimo fra created_at ed edited_at su TUTTI i messaggi,
|
||||
// non il created_at dell'ultimo: una modifica arriva dal server perché il
|
||||
// filtro guarda anche edited_at, ma se poi il watermark restasse indietro il
|
||||
// server continuerebbe a rispedire lo stesso messaggio a ogni giro, per sempre.
|
||||
// I messaggi non sono ordinati per edited_at, quindi va scorso l'intero elenco.
|
||||
const sinceRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const latest = messages[messages.length - 1];
|
||||
sinceRef.current = latest ? toDate(latest.created_at).toISOString() : null;
|
||||
let max = 0;
|
||||
for (const m of messages) {
|
||||
max = Math.max(max, toDate(m.created_at).getTime());
|
||||
if (m.edited_at) max = Math.max(max, toDate(m.edited_at).getTime());
|
||||
}
|
||||
sinceRef.current = max > 0 ? new Date(max).toISOString() : null;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -273,6 +288,45 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
||||
[token, clientId]
|
||||
);
|
||||
|
||||
// ── Modifica ───────────────────────────────────────────────────────────────
|
||||
// Ottimistica sfruttando il merge già in piedi: `polled` vince su `chat.messages`
|
||||
// a parità di id, quindi per far comparire subito il testo nuovo basta spingerci
|
||||
// dentro una copia. Se il PATCH fallisce si rispinge l'originale e il feed torna
|
||||
// com'era — senza uno stato "in modifica" separato da riconciliare.
|
||||
const applyEdit = useCallback(
|
||||
async (id: string, newBody: string): Promise<boolean> => {
|
||||
const original = messages.find((m) => m.id === id);
|
||||
if (!original) return false;
|
||||
|
||||
setPolled((prev) => [
|
||||
...prev,
|
||||
{ ...original, body: newBody, edited_at: new Date().toISOString() },
|
||||
]);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/client/comment", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, comment_id: id, body: newBody }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setPolled((prev) => [...prev, original]);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data.error ?? "Modifica non riuscita");
|
||||
return false;
|
||||
}
|
||||
const data = (await res.json()) as { comment?: ChatMessage };
|
||||
if (data.comment) setPolled((prev) => [...prev, data.comment!]);
|
||||
return true;
|
||||
} catch {
|
||||
setPolled((prev) => [...prev, original]);
|
||||
setError("Errore di rete");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[messages, token]
|
||||
);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = body.trim();
|
||||
@@ -439,6 +493,10 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
||||
index={index}
|
||||
activeChannel={activeChannel}
|
||||
clientId={clientId}
|
||||
adminName={adminName}
|
||||
adminAvatarUrl={adminAvatarUrl}
|
||||
canEdit={!preview}
|
||||
onEdit={applyEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -545,11 +603,20 @@ function MessageList({
|
||||
index,
|
||||
activeChannel,
|
||||
clientId,
|
||||
adminName,
|
||||
adminAvatarUrl,
|
||||
canEdit,
|
||||
onEdit,
|
||||
}: {
|
||||
messages: ChatMessage[];
|
||||
index: ReturnType<typeof buildChannelIndex>;
|
||||
activeChannel: string;
|
||||
clientId: string;
|
||||
adminName: string;
|
||||
adminAvatarUrl: string | null;
|
||||
/** Falso in anteprima admin: si guarda, non si scrive. */
|
||||
canEdit: boolean;
|
||||
onEdit: (id: string, body: string) => Promise<boolean>;
|
||||
}) {
|
||||
// Divider di giornata e raggruppamento si calcolano in un passaggio solo,
|
||||
// prima del JSX: dentro una .map() servirebbero variabili riassegnate a ogni
|
||||
@@ -571,67 +638,235 @@ function MessageList({
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map(({ message: m, at, showDivider, grouped }) => {
|
||||
const isClient = m.author === "client";
|
||||
const name = isClient ? "Tu" : "iamcavalli";
|
||||
// Dentro un canale-fase, i messaggi storici su un task o un deliverable
|
||||
// restano riconoscibili: il canale li raccoglie, il badge dice su cosa erano.
|
||||
const entityLabel =
|
||||
activeChannel !== clientId ? index.entityLabel.get(m.entity_id) : undefined;
|
||||
|
||||
return (
|
||||
<div key={m.id}>
|
||||
{showDivider && (
|
||||
<div className="my-4 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{formatDayDivider(at)}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("flex gap-3", grouped ? "mt-1" : "mt-5")}>
|
||||
{grouped ? (
|
||||
<div className="w-9 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<Avatar label={name} tone={isClient ? "client" : "admin"} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{!grouped && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-bold text-foreground">{name}</span>
|
||||
{entityLabel && (
|
||||
<span className="max-w-[160px] truncate rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
{entityLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground">{formatTime(at)}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
|
||||
{m.body}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{items.map(({ message: m, at, showDivider, grouped }) => (
|
||||
<MessageRow
|
||||
key={m.id}
|
||||
message={m}
|
||||
at={at}
|
||||
showDivider={showDivider}
|
||||
grouped={grouped}
|
||||
adminName={adminName}
|
||||
adminAvatarUrl={adminAvatarUrl}
|
||||
// Dentro un canale-fase, i messaggi storici su un task o un deliverable
|
||||
// restano riconoscibili: il canale li raccoglie, il badge dice su cosa erano.
|
||||
entityLabel={
|
||||
activeChannel !== clientId ? index.entityLabel.get(m.entity_id) : undefined
|
||||
}
|
||||
// Si modifica solo ciò che si è scritto. Il server ricontrolla comunque:
|
||||
// qui è solo per non mostrare un pulsante che verrebbe rifiutato.
|
||||
canEdit={canEdit && m.author === "client"}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({ label, tone }: { label: string; tone: "client" | "admin" }) {
|
||||
/**
|
||||
* Una riga del feed. È un componente a sé — e non JSX inline dentro la .map() —
|
||||
* perché la modifica ha bisogno di stato per messaggio: bozza, salvataggio in
|
||||
* corso, errore. Tenerlo qui evita di risollevarlo nel genitore, dove sarebbe
|
||||
* una mappa id -> stato da riconciliare a ogni poll.
|
||||
*/
|
||||
function MessageRow({
|
||||
message: m,
|
||||
at,
|
||||
showDivider,
|
||||
grouped,
|
||||
entityLabel,
|
||||
adminName,
|
||||
adminAvatarUrl,
|
||||
canEdit,
|
||||
onEdit,
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
at: Date;
|
||||
showDivider: boolean;
|
||||
grouped: boolean;
|
||||
entityLabel?: string;
|
||||
adminName: string;
|
||||
adminAvatarUrl: string | null;
|
||||
canEdit: boolean;
|
||||
onEdit: (id: string, body: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(m.body);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const isClient = m.author === "client";
|
||||
const name = isClient ? "Tu" : adminName;
|
||||
|
||||
// La bozza si riempie all'apertura, non con un effetto sincronizzato su m.body:
|
||||
// così un poll che arriva mentre stai scrivendo non ti cancella quello che hai
|
||||
// digitato.
|
||||
function startEditing() {
|
||||
setDraft(m.body);
|
||||
setFailed(false);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const next = draft.trim();
|
||||
if (!next || next === m.body) {
|
||||
setEditing(false);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const ok = await onEdit(m.id, next);
|
||||
setSaving(false);
|
||||
if (ok) setEditing(false);
|
||||
else setFailed(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showDivider && (
|
||||
<div className="my-4 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||
{formatDayDivider(at)}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("group flex gap-3", grouped ? "mt-1" : "mt-5")}>
|
||||
{grouped ? (
|
||||
<div className="w-9 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<Avatar
|
||||
label={name}
|
||||
tone={isClient ? "client" : "admin"}
|
||||
src={isClient ? null : adminAvatarUrl}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{!grouped && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-bold text-foreground">{name}</span>
|
||||
{entityLabel && (
|
||||
<span className="max-w-[160px] truncate rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||
{entityLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground">{formatTime(at)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<div className="mt-1">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void save();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
autoFocus
|
||||
maxLength={2000}
|
||||
aria-label="Modifica il messaggio"
|
||||
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={saving}
|
||||
className="text-[11px] font-semibold text-primary hover:underline disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Salvo…" : "Salva"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(false)}
|
||||
className="text-[11px] font-medium text-muted-foreground hover:underline"
|
||||
>
|
||||
Annulla
|
||||
</button>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Esc per annullare
|
||||
</span>
|
||||
{failed && (
|
||||
<span className="text-[10px] text-destructive">
|
||||
Non salvato, riprova
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
|
||||
{m.body}
|
||||
{m.edited_at && (
|
||||
<span className="ml-1.5 text-[10px] text-muted-foreground">
|
||||
(modificato)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startEditing}
|
||||
// Compare al passaggio del mouse, ma resta raggiungibile da
|
||||
// tastiera e sempre visibile dove il puntatore non esiste —
|
||||
// il portale si apre soprattutto dal telefono.
|
||||
className="mt-0.5 text-[11px] font-medium text-muted-foreground opacity-0 transition-opacity hover:underline focus-visible:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
>
|
||||
Modifica
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({
|
||||
label,
|
||||
tone,
|
||||
src,
|
||||
}: {
|
||||
label: string;
|
||||
tone: "client" | "admin";
|
||||
src?: string | null;
|
||||
}) {
|
||||
// Un URL esterno può sparire o essere sbagliato: in quel caso si torna al
|
||||
// monogramma invece di lasciare un cerchio vuoto.
|
||||
const [broken, setBroken] = useState(false);
|
||||
const showImage = !!src && !broken;
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[11px] font-bold",
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full text-[11px] font-bold",
|
||||
tone === "client"
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "bg-primary text-primary-foreground"
|
||||
)}
|
||||
>
|
||||
{monogram(label)}
|
||||
{showImage ? (
|
||||
// next/image richiederebbe di dichiarare gli host in next.config: qui
|
||||
// l'URL lo incolla l'utente e non è noto in fase di build.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src as string}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setBroken(true)}
|
||||
/>
|
||||
) : (
|
||||
monogram(label)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user