diff --git a/frontend/src/components/admin/PeopleManagerModal.tsx b/frontend/src/components/admin/PeopleManagerModal.tsx
index 0de4cf72..5fe2ca57 100644
--- a/frontend/src/components/admin/PeopleManagerModal.tsx
+++ b/frontend/src/components/admin/PeopleManagerModal.tsx
@@ -17,12 +17,13 @@ import React, { useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
-import { X, Check, Merge, Scissors, EyeOff, Ban, Loader2, Image as ImageIcon } from 'lucide-react';
+import { X, Check, Merge, Scissors, EyeOff, Ban, Loader2, Image as ImageIcon, Maximize2, ChevronLeft, ChevronRight } from 'lucide-react';
import { Button, Loading } from '../common';
import { api } from '../../config/api';
import { faceCropStyle } from '../gallery/faceCrop';
-import { adminFacePreviewUrl } from '../gallery/imageTiers';
+import { adminFacePreviewUrl, adminPhotoPreviewUrl } from '../gallery/imageTiers';
+
/** Mirrors PERSON_FACES_LIMIT in adminEvents/faces.js. The endpoint caps a
* person's face list, so any surface that renders it has to admit when it is
@@ -110,6 +111,64 @@ const FaceThumb: React.FC<{
);
};
+/**
+ * One face shown in its source photo, with the detected box drawn (#1096).
+ *
+ * Positioned in PERCENTAGES of the original frame rather than measured pixels:
+ * the container carries the photo's aspect ratio, so the same four numbers land
+ * correctly at any rendered size with no resize listener. Same reasoning as
+ * faceCropStyle, and it holds for the same reason — provided the rendition is
+ * the whole frame, which is why this asks for a preview and never a thumbnail.
+ */
+const FaceInContext: React.FC<{
+ eventId: number;
+ face: PersonFace;
+}> = ({ eventId, face }) => {
+ const { t } = useTranslation();
+ const [bx, by, bw, bh] = face.bbox || [];
+ // Without the original dimensions there is nothing to take a ratio of. Show
+ // the photo and say so, rather than draw a box in the wrong place — the
+ // failure mode that made #1100 look like a bad detector.
+ const canBox = !!(face.photo_width && face.photo_height && bw && bh);
+
+ return (
+
+ {/* max-w-full matters as much as max-h: with only a height cap a
+ panorama derives its width from the aspect ratio and overflows the
+ modal sideways, taking part of the outlined face off-screen. */}
+
+
+ {canBox && (
+
+ )}
+
+ {!canBox && (
+
+ {t('admin.people.contextNoBox', {
+ defaultValue: 'This photo has no stored dimensions, so the detected face cannot be outlined.',
+ })}
+
+ )}
+
+ );
+};
+
export const PeopleManagerModal: React.FC = ({
eventId, open, onClose, onChanged,
}) => {
@@ -124,6 +183,10 @@ export const PeopleManagerModal: React.FC = ({
// one and a human's do not always agree — a cluster whose avatar is softer
// or turned away stays that way in the guest-facing strip too.
const [coverFor, setCoverFor] = useState(null);
+ // Face-in-context viewer (#1096). Third mode alongside split and cover: the
+ // job is deciding whether two similar clusters are the same person, and a
+ // 64px avatar cannot answer that — who they are standing next to can.
+ const [viewing, setViewing] = useState<{ person: AdminPerson; index: number } | null>(null);
const [busy, setBusy] = useState(false);
const { data, isLoading, refetch } = useQuery<{ people: AdminPerson[] }>({
@@ -134,7 +197,7 @@ export const PeopleManagerModal: React.FC = ({
// Both pickers browse the same face list; only the action on a click
// differs. Keyed by person id so switching between them reuses the cache.
- const facesFor = splitting || coverFor;
+ const facesFor = splitting || coverFor || viewing?.person || null;
const { data: faceData, isLoading: facesLoading } = useQuery<{ faces: PersonFace[] }>({
queryKey: ['admin-person-faces', eventId, facesFor?.id],
@@ -260,8 +323,83 @@ export const PeopleManagerModal: React.FC = ({
- {/* --- cover picker ------------------------------------------------ */}
- {coverFor ? (
+ {/* --- face in context ---------------------------------------------- */}
+ {viewing ? (() => {
+ const faces = faceData?.faces || [];
+ // -1 means "whichever face the row is showing". Resolved here rather
+ // than at click time because the list is not loaded yet then — and
+ // index 0 is NOT the answer: it is the top-scoring face, which stops
+ // being the cover the moment someone picks a different one.
+ const coverIdx = faces.findIndex((f) => f.id === viewing.person.cover?.face_id);
+ const index = viewing.index >= 0
+ ? Math.min(viewing.index, Math.max(faces.length - 1, 0))
+ : Math.max(coverIdx, 0);
+ const face = faces[index];
+ const step = (delta: number) => setViewing((v) =>
+ v && faces.length ? { ...v, index: (index + delta + faces.length) % faces.length } : v);
+ // The endpoint caps a person's list; say so rather than let the
+ // counter imply this is everything they appear in.
+ // Against the person's REAL total, not the cap: someone with exactly
+ // 500 faces has a complete list and should not be told otherwise.
+ const truncated = (viewing.person.total_face_count ?? faces.length) > faces.length;
+ return (
+ <>
+
+ {t('admin.people.contextHelp', {
+ defaultValue: 'The detected face, outlined in its original photo — who they were standing next to is usually what settles whether two similar people are the same one.',
+ })}
+
+
+ {facesLoading ? : !face ? (
+ // facesLoading goes false with an empty array on a zero-face
+ // person or a failed request; without this the panel span
+ // forever on a spinner that would never resolve.
+
+ {t('admin.people.contextUnavailable', {
+ defaultValue: 'No photo could be loaded for this person.',
+ })}
+
+ ) : (
+ <>
+
+ {truncated && (
+
+ {t('admin.people.contextTruncated', {
+ limit: PERSON_FACES_LIMIT,
+ defaultValue: `Showing the first ${PERSON_FACES_LIMIT} appearances of this person.`,
+ })}
+
+ {/* Five 32px actions plus a 64px avatar exceed a
+ 320px row. flex-wrap alone does not help — the
+ toolbar still claims its max-content width first
+ and the name collapses to nothing. Capping the
+ basis makes the buttons wrap to a second line and
+ leaves the label its space. */}
+