init import projet
This commit is contained in:
@@ -0,0 +1,531 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { MainTabBar } from "@/features/predictions/components/main-tab-bar";
|
||||
import styles from "@/features/predictions/styles/predictions.module.css";
|
||||
import historyStyles from "@/features/predictions/styles/history.module.css";
|
||||
import { getApiUrl, PROJECT_CHANGED_EVENT } from "@/lib/auth";
|
||||
import { getPredictionActivity } from "@/lib/predictions-client";
|
||||
import type { PredictionActivity } from "@/types/predictions";
|
||||
|
||||
/* ---- helpers ---- */
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(value);
|
||||
return new Intl.DateTimeFormat("fr-FR", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatRelativeDate(value: string) {
|
||||
const date = new Date(value);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMinutes = Math.floor(diffMs / 60_000);
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
|
||||
if (diffMinutes < 1) return "À l'instant";
|
||||
if (diffMinutes < 60) return `Il y a ${diffMinutes} min`;
|
||||
if (diffHours < 24) return `Il y a ${diffHours}h`;
|
||||
if (diffDays === 1) return "Hier";
|
||||
if (diffDays < 7) return `Il y a ${diffDays} jours`;
|
||||
return formatDate(value);
|
||||
}
|
||||
|
||||
function getDisplayName(user: NonNullable<PredictionActivity["user"]>) {
|
||||
return user.displayName ?? user.username;
|
||||
}
|
||||
|
||||
function resolveProfileSrc(apiUrl: string, url: string | null | undefined): string | null {
|
||||
if (!url) return null;
|
||||
if (url.startsWith("http")) return url;
|
||||
return `${apiUrl}${url}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse les valeurs encodées dans le message du serveur.
|
||||
* Format : "Nom a change CardTitle: Field1: val1 | Field2: val2"
|
||||
* Format création : "Nom a pronostique CardTitle" (pas de valeurs)
|
||||
*/
|
||||
function parseMessageFieldValues(message: string): Array<{ label: string; rawValue: string }> {
|
||||
// Cherche le séparateur entre le titre de la carte et les valeurs de champs
|
||||
// ex: "...Sexe du bebe: Sexe: fille" → après le premier ": " on a "Sexe: fille"
|
||||
const colonIdx = message.indexOf(": ");
|
||||
if (colonIdx === -1) return [];
|
||||
|
||||
const valuesStr = message.slice(colonIdx + 2);
|
||||
// Évite de parser des messages sans valeurs structurées (pas de ": " interne)
|
||||
if (!valuesStr.includes(": ")) return [];
|
||||
|
||||
return valuesStr
|
||||
.split(" | ")
|
||||
.map((part) => {
|
||||
const idx = part.indexOf(": ");
|
||||
if (idx === -1) return null;
|
||||
return { label: part.slice(0, idx), rawValue: part.slice(idx + 2) };
|
||||
})
|
||||
.filter((v): v is { label: string; rawValue: string } => v !== null && v.rawValue !== "(vide)" && v.rawValue.trim() !== "");
|
||||
}
|
||||
|
||||
function isSexField(label: string): boolean {
|
||||
const l = label.toLowerCase();
|
||||
return l.includes("sexe") || l.includes("sex") || l.includes("genre");
|
||||
}
|
||||
|
||||
function isWeightField(label: string): boolean {
|
||||
const l = label.toLowerCase();
|
||||
return l.includes("poid") || l.includes("weight");
|
||||
}
|
||||
|
||||
function isNamesField(label: string): boolean {
|
||||
const l = label.toLowerCase();
|
||||
return l.includes("prenom") || l.includes("name");
|
||||
}
|
||||
|
||||
function getMeasureUnit(label: string): string | null {
|
||||
const l = label.toLowerCase();
|
||||
if (l.includes("taille") || l.includes("height") || l.includes("périm") || l.includes("cranien") || l.includes("crânien")) return "cm";
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---- Composant : valeur structurée depuis message parsé ---- */
|
||||
function ParsedValueDisplay({ label, rawValue }: { label: string; rawValue: string }) {
|
||||
if (isWeightField(label)) {
|
||||
const num = parseFloat(rawValue);
|
||||
if (!isNaN(num)) {
|
||||
const kg = num >= 100 ? num / 1000 : num;
|
||||
return (
|
||||
<div className={historyStyles.valueRow}>
|
||||
<span className={historyStyles.fieldLabel}>{label}</span>
|
||||
<span className={styles.recapWeightBadge}>{kg.toFixed(3)}{"\u00A0"}kg</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSexField(label)) {
|
||||
const isGarcon = rawValue.toLowerCase().includes("gar") || rawValue.toLowerCase() === "garcon" || rawValue.toLowerCase() === "garçon";
|
||||
return (
|
||||
<div className={historyStyles.valueRow}>
|
||||
<span className={historyStyles.fieldLabel}>{label}</span>
|
||||
<div
|
||||
className={`${styles.recapSexAnswer} ${isGarcon ? styles.recapSexAnswerGarcon : styles.recapSexAnswerFille}`}
|
||||
style={{ padding: "0.3rem 0.65rem", display: "inline-flex" }}
|
||||
>
|
||||
<Image
|
||||
src={isGarcon ? "/images/pictos/choix-garcon.png" : "/images/pictos/choix-fille.png"}
|
||||
alt=""
|
||||
width={28}
|
||||
height={28}
|
||||
className={styles.recapSexIcon}
|
||||
/>
|
||||
<span className={styles.recapSexLabel} style={{ fontSize: "0.95rem" }}>{rawValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNamesField(label)) {
|
||||
const names = rawValue.split(/[,;]+/).map((n) => n.trim()).filter(Boolean);
|
||||
return (
|
||||
<div className={historyStyles.valueRow}>
|
||||
<span className={historyStyles.fieldLabel}>{label}</span>
|
||||
<div className={styles.recapParticipantNamesList}>
|
||||
{names.map((name) => (
|
||||
<span key={name} className={styles.recapParticipantNameChip}>{name}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const num = parseFloat(rawValue);
|
||||
if (!isNaN(num) && rawValue.trim() !== "") {
|
||||
const unit = getMeasureUnit(label);
|
||||
return (
|
||||
<div className={historyStyles.valueRow}>
|
||||
<span className={historyStyles.fieldLabel}>{label}</span>
|
||||
<span className={historyStyles.valuePill}>{rawValue}{unit ? `\u00A0${unit}` : ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={historyStyles.valueRow}>
|
||||
<span className={historyStyles.fieldLabel}>{label}</span>
|
||||
<span className={historyStyles.valuePill}>{rawValue}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Composant : valeur structurée depuis entry.values (fallback) ---- */
|
||||
type ActivityEntryValue = NonNullable<PredictionActivity["entry"]>["values"][number];
|
||||
|
||||
function EntryValueDisplay({ value }: { value: ActivityEntryValue }) {
|
||||
const label = value.field.label;
|
||||
|
||||
if (isWeightField(label) && value.valueNumber != null) {
|
||||
const kg = value.valueNumber >= 100 ? value.valueNumber / 1000 : value.valueNumber;
|
||||
return (
|
||||
<div className={historyStyles.valueRow}>
|
||||
<span className={historyStyles.fieldLabel}>{label}</span>
|
||||
<span className={styles.recapWeightBadge}>{kg.toFixed(3)} kg</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSexField(label) && value.valueText) {
|
||||
const isGarcon = value.valueText.toLowerCase().includes("gar");
|
||||
return (
|
||||
<div className={historyStyles.valueRow}>
|
||||
<span className={historyStyles.fieldLabel}>{label}</span>
|
||||
<div className={`${styles.recapSexAnswer} ${isGarcon ? styles.recapSexAnswerGarcon : styles.recapSexAnswerFille}`} style={{ padding: "0.3rem 0.65rem", display: "inline-flex" }}>
|
||||
<Image src={isGarcon ? "/images/pictos/choix-garcon.png" : "/images/pictos/choix-fille.png"} alt="" width={28} height={28} className={styles.recapSexIcon} />
|
||||
<span className={styles.recapSexLabel} style={{ fontSize: "0.95rem" }}>{value.valueText}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isNamesField(label) && value.valueText) {
|
||||
const names = value.valueText.split(/[,;]+/).map((n) => n.trim()).filter(Boolean);
|
||||
return (
|
||||
<div className={historyStyles.valueRow}>
|
||||
<span className={historyStyles.fieldLabel}>{label}</span>
|
||||
<div className={styles.recapParticipantNamesList}>
|
||||
{names.map((name) => <span key={name} className={styles.recapParticipantNameChip}>{name}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.valueNumber != null) {
|
||||
const unit = getMeasureUnit(label);
|
||||
return (
|
||||
<div className={historyStyles.valueRow}>
|
||||
<span className={historyStyles.fieldLabel}>{label}</span>
|
||||
<span className={historyStyles.valuePill}>{value.valueNumber}{unit ? `\u00A0${unit}` : ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (value.valueText) {
|
||||
return (
|
||||
<div className={historyStyles.valueRow}>
|
||||
<span className={historyStyles.fieldLabel}>{label}</span>
|
||||
<span className={historyStyles.valuePill}>{value.valueText}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type ActivityGlyphKind =
|
||||
| "prediction"
|
||||
| "update"
|
||||
| "score"
|
||||
| "validated"
|
||||
| "lock"
|
||||
| "unlock"
|
||||
| "target"
|
||||
| "card"
|
||||
| "trash"
|
||||
| "log";
|
||||
|
||||
function getActivityGlyphKind(type: string): ActivityGlyphKind {
|
||||
switch (type) {
|
||||
case "PREDICTION_CREATED": return "prediction";
|
||||
case "PREDICTION_UPDATED": return "update";
|
||||
case "SCORE_SUGGESTED": return "score";
|
||||
case "SCORE_VALIDATED": return "validated";
|
||||
case "GAME_CLOSED": return "lock";
|
||||
case "GAME_OPENED": return "unlock";
|
||||
case "OUTCOME_SET": return "target";
|
||||
case "CARD_CREATED":
|
||||
case "CARD_UPDATED": return "card";
|
||||
case "CARD_DELETED": return "trash";
|
||||
default: return "log";
|
||||
}
|
||||
}
|
||||
|
||||
function ActivityGlyph({ kind }: Readonly<{ kind: ActivityGlyphKind }>) {
|
||||
const commonProps = {
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.9,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
viewBox: "0 0 24 24",
|
||||
ariaHidden: true,
|
||||
};
|
||||
|
||||
switch (kind) {
|
||||
case "prediction":
|
||||
return (
|
||||
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
|
||||
<path d="M4 20l4.3-1.1L18.6 8.6a2.2 2.2 0 0 0-3.1-3.1L5.2 15.8 4 20Z" />
|
||||
<path d="m13.8 7.2 3 3" />
|
||||
</svg>
|
||||
);
|
||||
case "update":
|
||||
return (
|
||||
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
|
||||
<path d="M19 8a7 7 0 0 0-11.8-2L5 8" />
|
||||
<path d="M5 4v4h4" />
|
||||
<path d="M5 16a7 7 0 0 0 11.8 2L19 16" />
|
||||
<path d="M19 20v-4h-4" />
|
||||
</svg>
|
||||
);
|
||||
case "score":
|
||||
return (
|
||||
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
|
||||
<path d="m12 3 2.5 5.1 5.6.8-4 3.9.9 5.5L12 15.8 7 18.3l.9-5.5-4-3.9 5.6-.8Z" />
|
||||
</svg>
|
||||
);
|
||||
case "validated":
|
||||
return (
|
||||
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
|
||||
<circle cx="12" cy="10" r="5.5" />
|
||||
<path d="m9.8 10.2 1.5 1.6 3-3.2" />
|
||||
<path d="M10.2 15.2 8.8 20l3.2-1.9L15.2 20l-1.4-4.8" />
|
||||
</svg>
|
||||
);
|
||||
case "lock":
|
||||
return (
|
||||
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
|
||||
<rect x="5" y="11" width="14" height="9" rx="2.5" />
|
||||
<path d="M8 11V8.5a4 4 0 1 1 8 0V11" />
|
||||
</svg>
|
||||
);
|
||||
case "unlock":
|
||||
return (
|
||||
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
|
||||
<rect x="5" y="11" width="14" height="9" rx="2.5" />
|
||||
<path d="M16 11V8.5a4 4 0 0 0-7.7-1.4" />
|
||||
</svg>
|
||||
);
|
||||
case "target":
|
||||
return (
|
||||
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
|
||||
<circle cx="12" cy="12" r="7" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 2v3" />
|
||||
<path d="M22 12h-3" />
|
||||
<path d="M12 22v-3" />
|
||||
<path d="M2 12h3" />
|
||||
</svg>
|
||||
);
|
||||
case "card":
|
||||
return (
|
||||
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
|
||||
<rect x="4" y="6" width="12" height="14" rx="2.5" />
|
||||
<path d="M8 10h4" />
|
||||
<path d="M8 14h6" />
|
||||
<path d="M16 9h2.5a1.5 1.5 0 0 1 1.5 1.5V17" />
|
||||
</svg>
|
||||
);
|
||||
case "trash":
|
||||
return (
|
||||
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
|
||||
<path d="M4 7h16" />
|
||||
<path d="M9 4h6" />
|
||||
<path d="m6 7 1 12h10l1-12" />
|
||||
<path d="M10 11v5" />
|
||||
<path d="M14 11v5" />
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg viewBox={commonProps.viewBox} fill={commonProps.fill} stroke={commonProps.stroke} strokeWidth={commonProps.strokeWidth} strokeLinecap={commonProps.strokeLinecap} strokeLinejoin={commonProps.strokeLinejoin} aria-hidden>
|
||||
<path d="M7 7h10" />
|
||||
<path d="M7 12h10" />
|
||||
<path d="M7 17h6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getActivityBadgeClass(type: string): string {
|
||||
if (type === "GAME_CLOSED" || type === "GAME_OPENED") return historyStyles.badgeSystem;
|
||||
if (type === "SCORE_VALIDATED") return historyStyles.badgeSuccess;
|
||||
if (type === "SCORE_SUGGESTED") return historyStyles.badgeWarning;
|
||||
if (type === "PREDICTION_CREATED" || type === "PREDICTION_UPDATED") return historyStyles.badgePrimary;
|
||||
return historyStyles.badgeMuted;
|
||||
}
|
||||
|
||||
function UserAvatar({ user, apiUrl }: { user: NonNullable<PredictionActivity["user"]>; apiUrl: string }) {
|
||||
const name = getDisplayName(user);
|
||||
const initials = name
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? "")
|
||||
.join("") || "?";
|
||||
const imageSrc = resolveProfileSrc(apiUrl, user.profileImageUrl);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.recapAvatar} ${styles.recapAvatarSm}`}
|
||||
style={user.profileBgColor ? { background: user.profileBgColor } : undefined}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{imageSrc ? <img src={imageSrc} alt="" /> : <span>{initials}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PredictionsHistoryPage() {
|
||||
const apiUrl = getApiUrl();
|
||||
const [activity, setActivity] = useState<PredictionActivity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState("");
|
||||
const [projectRefreshKey, setProjectRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const onProjectChanged = () => {
|
||||
setProjectRefreshKey((current) => current + 1);
|
||||
};
|
||||
window.addEventListener(PROJECT_CHANGED_EVENT, onProjectChanged);
|
||||
return () => window.removeEventListener(PROJECT_CHANGED_EVENT, onProjectChanged);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setMessage("");
|
||||
getPredictionActivity()
|
||||
.then((payload) => setActivity(payload))
|
||||
.catch((error) => setMessage(error instanceof Error ? error.message : "Impossible de charger l'historique"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectRefreshKey]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={`${styles.recapHero} ${styles.historyHero}`}>
|
||||
<div className={styles.recapHeroContent}>
|
||||
<span className={styles.recapHeroEyebrow}>Activité</span>
|
||||
<h1>Historique des actions</h1>
|
||||
<p>Toutes les modifications du projet, en temps réel.</p>
|
||||
<div className={styles.recapHeroStats}>
|
||||
<div className={styles.recapHeroStatChip}>
|
||||
<span className={`${styles.recapHeroStatValue} ${historyStyles.heroStatIcon}`}>
|
||||
<ActivityGlyph kind="prediction" />
|
||||
</span>
|
||||
<span className={styles.recapHeroStatLabel}>Pronostics</span>
|
||||
</div>
|
||||
<div className={styles.recapHeroStatChip}>
|
||||
<span className={`${styles.recapHeroStatValue} ${historyStyles.heroStatIcon}`}>
|
||||
<ActivityGlyph kind="update" />
|
||||
</span>
|
||||
<span className={styles.recapHeroStatLabel}>Modifs</span>
|
||||
</div>
|
||||
{/* <div className={styles.recapHeroStatChip}>
|
||||
<span className={styles.recapHeroStatValue}>⭐</span>
|
||||
<span className={styles.recapHeroStatLabel}>Scores</span>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.historyHeroIllustration} aria-hidden="true">
|
||||
<span className={styles.historyHeroLine} />
|
||||
<span className={styles.historyHeroDot1} />
|
||||
<span className={styles.historyHeroDot2} />
|
||||
<span className={styles.historyHeroDot3} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<MainTabBar />
|
||||
|
||||
{!loading && !message && activity.length > 0 && (
|
||||
<div className={styles.recapSectionHeading} style={{ padding: "0 0.5rem" }}>
|
||||
<p>{activity.length} événement{activity.length !== 1 ? "s" : ""} enregistré{activity.length !== 1 ? "s" : ""}.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className={styles.loadingScreen}>
|
||||
<p>Chargement de l'historique…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && message && (
|
||||
<p className={historyStyles.errorMsg}>{message}</p>
|
||||
)}
|
||||
|
||||
{!loading && !message && activity.length === 0 && (
|
||||
<p className={styles.empty} style={{ margin: "0 0.5rem" }}>Aucun événement pour le moment.</p>
|
||||
)}
|
||||
|
||||
{!loading && !message && activity.length > 0 && (
|
||||
<div className={historyStyles.timelineList}>
|
||||
{activity.map((item) => {
|
||||
// Priorité : parser les valeurs depuis le message (format serveur fiable)
|
||||
const parsedValues = parseMessageFieldValues(item.message);
|
||||
// Fallback : entry.values (état courant de l'entrée, peut différer)
|
||||
const fallbackValues = parsedValues.length === 0 ? (item.entry?.values ?? []) : [];
|
||||
const hasDisplayValues = parsedValues.length > 0 || fallbackValues.length > 0;
|
||||
|
||||
// Extraire le nom de l'auteur depuis le message si item.user est null
|
||||
// Ex: "Valerie a change X: ..." → "Valerie"
|
||||
const authorFromMessage = !item.user
|
||||
? item.message.split(/\s+a\s+(change|pronostique|cree|fixe|suggere|valide)/i)?.[0] ?? null
|
||||
: null;
|
||||
const activityGlyphKind = getActivityGlyphKind(item.type);
|
||||
|
||||
return (
|
||||
<article key={item.id} className={historyStyles.timelineCard}>
|
||||
{/* Left: avatar ou icône système */}
|
||||
<div className={historyStyles.timelineAvatarCol}>
|
||||
{item.user ? (
|
||||
<UserAvatar user={item.user} apiUrl={apiUrl} />
|
||||
) : (
|
||||
<div className={historyStyles.systemIcon} aria-hidden="true">
|
||||
<span className={historyStyles.systemIconGlyph}>
|
||||
<ActivityGlyph kind={activityGlyphKind} />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={historyStyles.timelineLine} aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
{/* Right: contenu */}
|
||||
<div className={historyStyles.timelineContent}>
|
||||
<div className={historyStyles.timelineHeader}>
|
||||
<div className={historyStyles.timelineHeadLeft}>
|
||||
{(item.user || authorFromMessage) && (
|
||||
<strong className={historyStyles.userName}>
|
||||
{item.user ? getDisplayName(item.user) : authorFromMessage}
|
||||
</strong>
|
||||
)}
|
||||
<span className={`${historyStyles.activityLabel} ${getActivityBadgeClass(item.type)}`}>
|
||||
<span className={historyStyles.activityLabelIcon} aria-hidden="true">
|
||||
<ActivityGlyph kind={activityGlyphKind} />
|
||||
</span>
|
||||
<span className={historyStyles.activityLabelText}>{item.card?.title ?? item.message}</span>
|
||||
</span>
|
||||
</div>
|
||||
<time className={historyStyles.timelineTime} dateTime={item.createdAt} title={formatDate(item.createdAt)}>
|
||||
{formatRelativeDate(item.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{hasDisplayValues && (
|
||||
<div className={historyStyles.valuesBlock}>
|
||||
{parsedValues.map((v) => (
|
||||
<ParsedValueDisplay key={v.label} label={v.label} rawValue={v.rawValue} />
|
||||
))}
|
||||
{fallbackValues.map((v) => (
|
||||
<EntryValueDisplay key={v.fieldId} value={v} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user