mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
1 parent
1565552141
commit
f990e1c5de
12 files changed
+52
-11
No files matched your search
@@ -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,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>}
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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] || '?'}
|
||||
|
||||
Reference in new issue
Block a user