Miniatures de portrait : le recadrage s'applique partout (#205)

Seuls l'arbre et la fiche appliquaient le cadrage enregistré du portrait. Les miniatures
(auteur et « Sur la photo » dans la visionneuse, recherche, liens de la fiche, ajout d'un
proche, compte, menu du compte, choix d'un souvenir, recherche ailleurs) affichaient la
photo brute. Un composant commun, PortraitThumb, place l'image avec imagePlacement,
comme la fiche. Signalé par Phyll sur Discord (29/10).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 authored and GitHub committed 2026-11-01 19:51:53 +01:00
1 parent 1565552141
commit f990e1c5de
12 files changed
+52 -11

No files matched your search

+1
View File
@@ -3535,6 +3535,7 @@ function App() {
userDisplayName={accountDisplayName}
userEmail={accountEmail}
userAvatarPhoto={accountAvatarPhoto}
userAvatarCrop={linkedSelfPerson?.avatarCrop ?? null}
linkedPersonName={linkedSelfPerson ? `${linkedSelfPerson.firstName || ''} ${linkedSelfPerson.lastName || ''}`.trim() : ''}
activeTreeName={activeAccountTreeName}
annotationTools={editMode.isActive ? {
+3 -2
View File
@@ -3,6 +3,7 @@ import { X } from 'lucide-react'
import './AccountScreens.css'
import { t } from '../i18n/index.js'
import { initialOf } from '../utils/initial.js'
import PortraitThumb from './PortraitThumb.jsx'
const AccountPanel = ({
visible,
@@ -51,7 +52,7 @@ const AccountPanel = ({
<div className="ap-linked">
<span className="ap-avatar" aria-hidden="true">
{linkedPerson?.photo ? <img src={linkedPerson.photo} alt="" /> : initialOf(linkedPerson?.firstName || userDisplayName, 'C')}
{linkedPerson?.photo ? <PortraitThumb src={linkedPerson.photo} crop={linkedPerson.avatarCrop} /> : initialOf(linkedPerson?.firstName || userDisplayName, 'C')}
</span>
<p className="pz-small">
{linkedPerson
@@ -87,7 +88,7 @@ const AccountPanel = ({
onClick={() => onSelectPerson?.(person.id)}
>
<span className="ap-item-avatar" aria-hidden="true">
{person.photo ? <img src={person.photo} alt="" /> : initialOf(person.firstName)}
{person.photo ? <PortraitThumb src={person.photo} crop={person.avatarCrop} /> : initialOf(person.firstName)}
</span>
<span className="ap-item-name">{person.firstName} <em>{person.lastName || ''}</em></span>
{isSelected && <span className="pz-tag pz-tag--accent">{t("C'est moi")}</span>}
+2 -1
View File
@@ -8,6 +8,7 @@ import { firstNameLabel } from '../utils/personName.js'
import { SEX_CHOICES } from '../utils/personSex.js'
import { initialOf } from '../utils/initial.js'
import { findLikelyDuplicates } from '../utils/duplicates.js'
import PortraitThumb from './PortraitThumb.jsx'
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
function withEmphasis(text) {
@@ -392,7 +393,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
<li key={p.id}>
<button type="button" className="apn-dropdown-item" onClick={() => handleAddRelation(p)}>
<span className="apn-avatar" aria-hidden="true">
{p.photo ? <img src={p.photo} alt="" /> : initialOf(p.firstName)}
{p.photo ? <PortraitThumb src={p.photo} crop={p.avatarCrop} /> : initialOf(p.firstName)}
</span>
<span className="apn-dropdown-name">{firstNameLabel(p)} <em>{p.lastName || ''}</em></span>
{(p.birthYear || p.birthDate) && (
+2 -1
View File
@@ -3,6 +3,7 @@ import { ImagePlus, Search, UserPlus, X } from 'lucide-react'
import './FamilyOnboarding.css'
import { t } from '../i18n/index.js'
import { HELP_URL } from '../utils/siteLinks.js'
import PortraitThumb from './PortraitThumb.jsx'
// Carte d'accueil de la famille qui arrive par le lien : ce qu'on peut faire, en deux phrases
export function FamilyWelcome({ visible, treeName, onAddSouvenir, onLook }) {
@@ -117,7 +118,7 @@ export function SouvenirPicker({ visible, persons = [], rootPersonId = null, onP
<li key={person.id}>
<button type="button" className="fo-person" onClick={() => onPick?.(person)}>
<span className="fo-thumb" aria-hidden="true">
{person.photo ? <img src={person.photo} alt="" /> : <span>{person.firstName?.[0] || '?'}</span>}
{person.photo ? <PortraitThumb src={person.photo} crop={person.avatarCrop} /> : <span>{person.firstName?.[0] || '?'}</span>}
</span>
<span className="fo-name">
<strong>{person.firstName} {person.lastName}</strong>
+2 -1
View File
@@ -3,6 +3,7 @@ import { Check, Search, UserPlus, X } from 'lucide-react'
import { t } from '../i18n/index.js'
import { firstNameLabel, fullNameLabel } from '../utils/personName.js'
import './MediaPeople.css'
import PortraitThumb from './PortraitThumb.jsx'
const MAX_RESULTS = 6
@@ -10,7 +11,7 @@ const normalize = (value) => String(value || '').normalize('NFD').replace(/[̀-
function PersonAvatar({ person }) {
return person?.photo
? <img className="mp-avatar" src={person.photo} alt="" />
? <PortraitThumb className="mp-avatar" src={person.photo} crop={person.avatarCrop} />
: <span className="mp-avatar mp-avatar--initial" aria-hidden="true">{person?.firstName?.[0] || '?'}</span>
}
+2 -1
View File
@@ -15,6 +15,7 @@ import { eventTitle } from '../utils/lifeTimeline.js'
import { getAccountErrorMessage } from '../utils/errorMessages.js'
import { growFromTransform, takeOrigin } from '../utils/openOrigin.js'
import { useSheetDrag, useSheetMode } from '../hooks/useSheetDrag.js'
import PortraitThumb from './PortraitThumb.jsx'
const HTTP_URL_RE = /^https?:\/\/\S+$/i
const MAX_TITLE_LENGTH = 280
@@ -648,7 +649,7 @@ const MediaViewer = ({
<div className="mv-meta">
<span className="mv-person">
{person?.photo ? (
<img className="mv-person-avatar" src={person.photo} alt="" />
<PortraitThumb className="mv-person-avatar" src={person.photo} crop={person.avatarCrop} />
) : (
<span className="mv-person-avatar mv-person-initial">{person?.firstName?.[0] || '?'}</span>
)}
+3 -2
View File
@@ -25,6 +25,7 @@ import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js'
import { ProposalBanner } from './ProposalCard.jsx'
import { initialsOf } from '../utils/initial.js'
import { blurPlaceholder } from '../utils/blurhash.js'
import PortraitThumb from './PortraitThumb.jsx'
// Aucune modification en cours (référence stable)
const NO_EDITS = Object.freeze({})
@@ -647,7 +648,7 @@ const PersonCard = ({
const renderRelationRow = (target, relationType, meta, removable = true) => (
<div key={`${relationType}-${target?.id}`} className="pc-rel-row">
<span className="pc-rel-avatar" aria-hidden="true">
{target?.photo ? <img src={target.photo} alt="" /> : <span>{target?.firstName?.[0] || '?'}</span>}
{target?.photo ? <PortraitThumb src={target.photo} crop={target.avatarCrop} /> : <span>{target?.firstName?.[0] || '?'}</span>}
</span>
<span className="pc-rel-name">{target ? fullNameLabel(target) : ''}</span>
{meta && <span className="pc-rel-meta">{meta}</span>}
@@ -1353,7 +1354,7 @@ const RelationSearchInline = forwardRef(function RelationSearchInline(
<li key={p.id}>
<button type="button" className="pc-search-item" onClick={() => onSelect(p)}>
<span className="pc-rel-avatar" aria-hidden="true">
{p.photo ? <img src={p.photo} alt="" /> : <span>{p.firstName[0]}</span>}
{p.photo ? <PortraitThumb src={p.photo} crop={p.avatarCrop} /> : <span>{p.firstName[0]}</span>}
</span>
<span className="pc-rel-name">{firstNameLabel(p)} <em>{p.lastName || ''}</em></span>
{p.birthYear && <span className="pc-rel-meta">{p.birthYear}</span>}
@@ -0,0 +1,5 @@
/* Cadre de la miniature : remplit son conteneur, sauf si une classe passée (mp-avatar, nav-avatar-photo…) en décide
autrement. :where ne pèse rien, la classe passée l'emporte toujours. */
:where(.portrait-thumb) { position: relative; display: block; width: 100%; height: 100%; border-radius: inherit; }
.portrait-thumb { overflow: hidden; }
.portrait-thumb > img { display: block; width: 100%; height: 100%; object-fit: cover; }
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react'
import { imagePlacement } from '../utils/portraitCrop.js'
import './PortraitThumb.css'
// Portrait en miniature (listes, puces, visionneuse, menu du compte) avec le recadrage enregistré :
// le même cadrage que la fiche et l'arbre (portraitCrop.js). Le cadre est carré ; l'image se place
// une fois sa taille connue, et reste en « cover » centré jusque-là ou sans cadrage.
export default function PortraitThumb({ src, crop = null, alt = '', className = '' }) {
const [size, setSize] = useState(null)
useEffect(() => { setSize(null) }, [src])
const placement = size && crop ? imagePlacement(size.w, size.h, 1, crop) : null
const style = placement
? { position: 'absolute', maxWidth: 'none', left: `${placement.left}%`, top: `${placement.top}%`, width: `${placement.width}%`, height: `${placement.height}%` }
: undefined
return (
<span className={`portrait-thumb${className ? ` ${className}` : ''}`}>
<img
src={src}
alt={alt}
onLoad={(event) => setSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })}
style={style}
/>
</span>
)
}
@@ -9,6 +9,7 @@ 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'
import PortraitThumb from './PortraitThumb.jsx'
// Ce que chaque source interrogée couvre et ce qui part vers elle : affiché avant qu'on coche
const SOURCE_INFO = {
@@ -387,7 +388,7 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
{candidates.map(({ person, yearOff }) => (
<li key={person.id}>
<button type="button" className={`apn-dropdown-item${picker.relative?.id === person.id ? ' is-selected' : ''}`} aria-pressed={picker.mode === 'new' ? picker.relative?.id === person.id : undefined} onClick={() => handlePick(person)} disabled={picker.saving}>
<span className="apn-avatar" aria-hidden="true">{person.photo ? <img src={person.photo} alt="" /> : (person.firstName || '?').charAt(0)}</span>
<span className="apn-avatar" aria-hidden="true">{person.photo ? <PortraitThumb src={person.photo} crop={person.avatarCrop} /> : (person.firstName || '?').charAt(0)}</span>
<span className="apn-dropdown-name">{person.firstName} <em>{person.lastName}</em></span>
<span className={`apn-dropdown-date${yearOff && picker.mode === 'existing' ? ' sp-year-off' : ''}`} title={yearOff && picker.mode === 'existing' ? t("Pas la même année de naissance que ce résultat") : undefined}>{personYears(person)}</span>
</button>
@@ -13,6 +13,7 @@ import { PHONE_QUERY } from '../../utils/sheetDrag'
import './ContextualNavbar.css'
import { initialOf } from '../../utils/initial.js'
import Kbd from '../Kbd.jsx'
import PortraitThumb from '../PortraitThumb.jsx'
const ANNOT_TOOLS = [
{ id: 'pointer', label: t('Sélection'), Icon: MousePointer2 },
@@ -42,6 +43,7 @@ function ContextualNavbar({
userDisplayName = '',
userEmail = '',
userAvatarPhoto = '',
userAvatarCrop = null,
linkedPersonName = '',
activeTreeName = '',
onEditClick,
@@ -399,7 +401,7 @@ function ContextualNavbar({
{!authenticated ? (
<User size={20} strokeWidth={2} aria-hidden="true" />
) : userAvatarPhoto ? (
<img className="nav-avatar-photo" src={userAvatarPhoto} alt="" />
<PortraitThumb className="nav-avatar-photo" src={userAvatarPhoto} crop={userAvatarCrop} />
) : (
<span className="nav-avatar-initial" aria-hidden="true">{avatarInitial}</span>
)}
@@ -1,6 +1,7 @@
import './SearchResultsDropdown.css'
import { t } from '../../i18n/index.js'
import { firstNameLabel } from '../../utils/personName.js'
import PortraitThumb from '../PortraitThumb.jsx'
// Raison du match renvoyée par usePersonSearch (valeur française), traduite à l'affichage
const REASON_LABELS = {
@@ -58,7 +59,7 @@ function SearchResultsDropdown({
{/* Photo miniature */}
<div className="search-result-thumb">
{person.photo ? (
<img src={person.photo} alt="" />
<PortraitThumb src={person.photo} crop={person.avatarCrop} />
) : (
<div className="search-result-initials">
{person.firstName?.[0] || '?'}