diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 866f919..b0b58c6 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -24,6 +24,7 @@ import { removeManager } from '../api/managersApi.js' import { t } from '../i18n/index.js' import { readGedcomFile } from '../utils/gedcomText.js' import { readGedcomBundle, zipErrorMessage } from '../utils/gedcomBundle.js' +import Skeleton from './Skeleton.jsx' function normalizeSlug(value) { return String(value || '') @@ -442,7 +443,7 @@ const AdminPanel = ({
{loading ? ( -

{t('Chargement…')}

+ ) : ( <> {errorMessage &&
{errorMessage}
} diff --git a/frontend/src/components/ContributionPanel.jsx b/frontend/src/components/ContributionPanel.jsx index 3719f04..4c00f87 100644 --- a/frontend/src/components/ContributionPanel.jsx +++ b/frontend/src/components/ContributionPanel.jsx @@ -8,6 +8,7 @@ import PzBusy from './PzBusy.jsx' import { HELP_URL } from '../utils/siteLinks.js' import { t, dateLocale } from '../i18n/index.js' import { sexLabel } from '../utils/personSex.js' +import Skeleton from './Skeleton.jsx' // ─── helpers ──────────────────────────────────────────────────────────────── @@ -471,7 +472,7 @@ const ContributionPanel = ({ {errorMessage &&
{errorMessage}
} {loading ? ( -
{t('Chargement…')}
+ ) : sessions.length === 0 ? (
{t("Rien à relire pour l'instant. Les propositions de la famille apparaîtront ici.")}
) : ( diff --git a/frontend/src/components/LifeTimeline.jsx b/frontend/src/components/LifeTimeline.jsx index 63534c3..7370d68 100644 --- a/frontend/src/components/LifeTimeline.jsx +++ b/frontend/src/components/LifeTimeline.jsx @@ -4,6 +4,7 @@ import { t } from '../i18n/index.js' import { buildLifeTimeline } from '../utils/lifeTimeline.js' import './LifeTimeline.css' import { blurPlaceholder } from '../utils/blurhash.js' +import Skeleton from './Skeleton.jsx' // Onglet Vie de la fiche : naissance, unions et décès en grands repères, les autres événements // plus petits entre eux, la famille proche en retrait. Les sources restent repliées. @@ -71,6 +72,9 @@ export default function LifeTimeline({ const noticeLine = notice &&

{notice}

if (entries.length === 0) { + // Premier chargement, aucune date connue sur la fiche : la place de la frise, au lieu d'un « pas encore de + // date » qui serait faux une seconde après. Avec des dates connues, elles s'affichent tout de suite. + if (loading && !life) return return (

{t('Pas encore de date connue pour {name}.', { name: person.firstName })}

diff --git a/frontend/src/components/SearchSourcesPanel.jsx b/frontend/src/components/SearchSourcesPanel.jsx index ddc216b..cd5f932 100644 --- a/frontend/src/components/SearchSourcesPanel.jsx +++ b/frontend/src/components/SearchSourcesPanel.jsx @@ -8,6 +8,7 @@ import { fetchSearchSources, searchSource } from '../api/searchSourcesApi.js' import { SEARCH_LINKS, SEARCH_LINK_REGIONS } from '../data/searchLinks.js' import { changesFromResult, personFromResult } from '../utils/searchResultChanges.js' import { fullNameLabel } from '../utils/personName.js' +import Skeleton from './Skeleton.jsx' // Ce que chaque source interrogée couvre et ce qui part vers elle : affiché avant qu'on coche const SOURCE_INFO = { @@ -283,7 +284,7 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f

{t('Où chercher')}

- {sources === null &&

{t('Chargement…')}

} + {sources === null && } {sources?.length === 0 && (

{t("Ce serveur n'interroge aucune source : aucun nom ne quitte l'instance. Les liens plus bas ouvrent les sites dans votre navigateur.")}

)} diff --git a/frontend/src/components/Skeleton.css b/frontend/src/components/Skeleton.css new file mode 100644 index 0000000..c9bd481 --- /dev/null +++ b/frontend/src/components/Skeleton.css @@ -0,0 +1,14 @@ +/* Blocs gris de chargement (Skeleton.jsx) : immobiles, couleur des séparateurs */ +.pz-skel { display: grid; gap: 14px; padding: 4px 0; } +.pz-skel-row { display: flex; align-items: center; gap: 12px; } +.pz-skel-text { display: grid; gap: 7px; flex: 1; min-width: 0; } +.pz-skel-bar { display: block; height: 12px; border-radius: 6px; background: var(--pz-line); } +.pz-skel-bar--thin { height: 9px; background: var(--pz-line-soft); } +.pz-skel-dot { flex: none; width: 32px; height: 32px; border-radius: 50%; background: var(--pz-line); } + +/* Frise : année, point sur le rail, texte */ +.pz-skel--timeline { gap: 22px; padding: 8px 0 4px; } +.pz-skel--timeline .pz-skel-year { flex: none; width: 34px; height: 11px; } +.pz-skel--timeline .pz-skel-dot { width: 10px; height: 10px; } + +.pz-skel--lines .pz-skel-bar { height: 10px; } diff --git a/frontend/src/components/Skeleton.jsx b/frontend/src/components/Skeleton.jsx new file mode 100644 index 0000000..5d4008b --- /dev/null +++ b/frontend/src/components/Skeleton.jsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react' +import { t } from '../i18n/index.js' +import './Skeleton.css' + +// Blocs gris à la forme de ce qui arrive, à la place d'un « Chargement… ». Rien avant SHOW_AFTER : une réponse +// rapide ne fait pas clignoter l'écran. Aucun mouvement (pas de reflet qui passe), juste la place réservée. +const SHOW_AFTER = 300 + +// variant : rows (avatar et deux lignes : personnes, visites, propositions), timeline (frise de l'onglet Vie), +// lines (paragraphes) +export default function Skeleton({ variant = 'rows', count = 3, label = '' }) { + const [shown, setShown] = useState(false) + useEffect(() => { + const timer = setTimeout(() => setShown(true), SHOW_AFTER) + return () => clearTimeout(timer) + }, []) + + return ( +
+ {label || t('Chargement…')} + {shown && Array.from({ length: count }, (_, i) => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/TreeManagersSection.jsx b/frontend/src/components/TreeManagersSection.jsx index 21338af..410d2fe 100644 --- a/frontend/src/components/TreeManagersSection.jsx +++ b/frontend/src/components/TreeManagersSection.jsx @@ -3,6 +3,7 @@ import { inviteManager, invitationUrl, listManagers, removeManager, renewInvitat import PzBusy from './PzBusy.jsx' import { dateLocale, t } from '../i18n/index.js' import { initialOf } from '../utils/initial.js' +import Skeleton from './Skeleton.jsx' // Onglet Gestion des paramètres de l'arbre (issue #145). Le propriétaire invite des co-gestionnaires par leur // adresse : Parenthèse n'envoie pas de mail, il donne un lien à transmettre. Les co-gestionnaires voient la liste. @@ -102,7 +103,7 @@ const TreeManagersSection = ({ treeId, authToken, currentUserId = null, oidcName } } - if (state.loading) return

{t('Chargement…')}

+ if (state.loading) return if (state.error) return
{state.error}
const { canManage, managers, invitations } = state.data diff --git a/frontend/src/components/TreeVisitsSection.jsx b/frontend/src/components/TreeVisitsSection.jsx index 409c74f..955d631 100644 --- a/frontend/src/components/TreeVisitsSection.jsx +++ b/frontend/src/components/TreeVisitsSection.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { listTreeVisits } from '../api/treeApi' import { dateLocale, t } from '../i18n/index.js' +import Skeleton from './Skeleton.jsx' // Onglet Visites des paramètres de l'arbre : qui est venu, combien de fois. // Le journal est tenu par le serveur à chaque ouverture de l'arbre (backend/src/lib/tree-visits.ts). @@ -37,7 +38,7 @@ const TreeVisitsSection = ({ treeId, authToken }) => { return () => { active = false } }, [treeId, authToken]) - if (state.loading) return

{t('Chargement…')}

+ if (state.loading) return if (state.error) return
{state.error}
const { summary, recent } = state.data diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index 8ffbe6c..791f6e4 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -432,6 +432,7 @@ export default { 'Naissance de {name}': 'Birth of {name}', 'Décès à {place}': 'Died in {place}', 'Ajouter un moment': 'Add a moment', + 'Chargement de la frise': 'Loading the timeline', 'Modifier « {title} »': 'Edit “{title}”', 'Déménagement': 'Move', 'L’enregistrement n’a pas abouti. Réessayez dans un instant.': 'Saving did not go through. Try again in a moment.',