diff --git a/frontend/src/components/admin/FaceRecognitionCard.tsx b/frontend/src/components/admin/FaceRecognitionCard.tsx index 5ca8203f..7d47604d 100644 --- a/frontend/src/components/admin/FaceRecognitionCard.tsx +++ b/frontend/src/components/admin/FaceRecognitionCard.tsx @@ -226,7 +226,9 @@ export const FaceRecognitionCard: React.FC = ({ eventI // Rendered in two places — replacing the spinner mid-scan, or under the // final counts once a scan has ended with failures it can explain. const sidecarNotice = sidecarWarning && health ? ( -
+

{sidecarWarning === 'unauthorized' && t('admin.faces.sidecarUnauthorized', { @@ -243,7 +245,9 @@ export const FaceRecognitionCard: React.FC = ({ eventI defaultValue: `Can't reach the face-detection service at ${health.url}, so the ${status.pending} queued photos aren't being processed. Nothing is lost — the scan resumes on its own once the service is up. Start it with \`docker compose --profile faces up -d\`, and note it exits immediately unless FACE_ML_TOKEN is set to the same value as the backend — there is no default.`, })} {health.error && ( - + {health.error} )} @@ -256,10 +260,10 @@ export const FaceRecognitionCard: React.FC = ({ eventI

-

+

{t('admin.faces.title', { defaultValue: 'People in this gallery' })}

-

+

{t('admin.faces.subtitle', { defaultValue: 'Group photos by the people in them, so guests can find and download their own.', })} @@ -269,7 +273,7 @@ export const FaceRecognitionCard: React.FC = ({ eventI {/* Consent obligation. Stated plainly and up front, because by the time someone has switched this on they have already processed the data. */} -

+

{t('admin.faces.consentNotice', { @@ -285,13 +289,13 @@ export const FaceRecognitionCard: React.FC = ({ eventI checked={data.enabled} disabled={saving || isArchived} onChange={(e) => patch({ enabled: e.target.checked })} - className="mt-1 rounded border-neutral-300 text-primary-600 focus:ring-primary-500" + className="mt-1 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-primary-600 focus:ring-primary-500" /> - + {t('admin.faces.enable', { defaultValue: 'Detect people in this gallery' })} - + {t('admin.faces.enableHint', { defaultValue: 'Existing photos are scanned in the background. Faces and their numeric signatures are stored in your database; they are never included in backups or exports.', })} @@ -306,13 +310,13 @@ export const FaceRecognitionCard: React.FC = ({ eventI checked={data.visible_to_guests} disabled={saving || isArchived} onChange={(e) => patch({ visible_to_guests: e.target.checked })} - className="mt-1 rounded border-neutral-300 text-primary-600 focus:ring-primary-500" + className="mt-1 rounded border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-700 text-primary-600 focus:ring-primary-500" /> - + {t('admin.faces.visible', { defaultValue: 'Show the people bar to guests' })} - + {t('admin.faces.visibleHint', { defaultValue: 'Off means you get the grouping as a private tool and guests see an unchanged gallery.', })} @@ -326,7 +330,7 @@ export const FaceRecognitionCard: React.FC = ({ eventI admin deserves to know that before starting a 2,000-photo backfill rather than discovering it in their storage graph. */} {data.enabled && ( -

+

{t('admin.faces.previewNotice', { @@ -340,7 +344,7 @@ export const FaceRecognitionCard: React.FC = ({ eventI it sits apart from the two toggles above. Without a control here the rule engine had no way to be switched on at all. */} {data.enabled && ( -

+
{/* While scanning, the warning replaces the spinner — a progress indicator that cannot progress is the misleading part. Once the scan has ended the counts are what the admin came for, so the @@ -413,7 +417,7 @@ export const FaceRecognitionCard: React.FC = ({ eventI )} {status.failed > 0 && ( - + {' · '} {t('admin.faces.failed', { count: status.failed, @@ -435,7 +439,7 @@ export const FaceRecognitionCard: React.FC = ({ eventI rows — worth doing, but a bigger change than this. */} {!status.in_progress && sidecarNotice && (
-

+

{t('admin.faces.sidecarStateNow', { defaultValue: 'Service state right now — some of the failures above may have a different cause, but a re-scan will not succeed until this is fixed:', })} @@ -485,7 +489,7 @@ export const FaceRecognitionCard: React.FC = ({ eventI disabled={saving} onClick={purge} leftIcon={} - className="text-red-600 border-red-200 hover:bg-red-50" + className="text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/30" > {t('admin.faces.delete', { defaultValue: 'Delete all face data' })} diff --git a/frontend/src/components/admin/PeopleManagerModal.tsx b/frontend/src/components/admin/PeopleManagerModal.tsx index 5fe2ca57..cfce275c 100644 --- a/frontend/src/components/admin/PeopleManagerModal.tsx +++ b/frontend/src/components/admin/PeopleManagerModal.tsx @@ -98,7 +98,7 @@ const FaceThumb: React.FC<{ return ( {!canBox && ( -

+

{t('admin.people.contextNoBox', { defaultValue: 'This photo has no stored dimensions, so the detected face cannot be outlined.', })} @@ -305,20 +305,20 @@ export const PeopleManagerModal: React.FC = ({

-
+
-

+

{t('admin.people.title', { defaultValue: 'People in this gallery' })}

-

+

{t('admin.people.subtitle', { defaultValue: 'Rename, merge people who were split apart, or hide someone from guests.', })}

-
@@ -344,7 +344,7 @@ export const PeopleManagerModal: React.FC = ({ 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.', })} @@ -354,7 +354,7 @@ export const PeopleManagerModal: React.FC = ({ // 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.', })} @@ -363,7 +363,7 @@ export const PeopleManagerModal: React.FC = ({ <> {truncated && ( -

+

{t('admin.people.contextTruncated', { limit: PERSON_FACES_LIMIT, defaultValue: `Showing the first ${PERSON_FACES_LIMIT} appearances of this person.`, @@ -373,7 +373,7 @@ export const PeopleManagerModal: React.FC = ({ )}

-
+
- + {faces.length ? `${index + 1} / ${faces.length}${truncated ? '+' : ''}` : '—'} @@ -465,7 +465,7 @@ export const PeopleManagerModal: React.FC = ({
)}
-
+
@@ -473,7 +473,7 @@ export const PeopleManagerModal: React.FC = ({ ) : splitting ? ( <> -
+
{t('admin.people.splitHelp', { defaultValue: 'Pick the photos that are NOT this person. They become a new entry, and everything else stays.', })} @@ -490,7 +490,7 @@ export const PeopleManagerModal: React.FC = ({ onClick={() => setSplitFaceIds((p) => p.includes(face.id) ? p.filter((x) => x !== face.id) : [...p, face.id])} className={`relative rounded-lg overflow-hidden border-2 transition-colors ${ - picked ? 'border-primary-600' : 'border-transparent hover:border-neutral-300' + picked ? 'border-primary-600' : 'border-transparent hover:border-neutral-300 dark:hover:border-neutral-600' }`} > = ({
)}
-
- +
+ {t('admin.people.splitSelected', { count: splitFaceIds.length, defaultValue: `${splitFaceIds.length} selected`, @@ -534,7 +534,7 @@ export const PeopleManagerModal: React.FC = ({ {/* --- people grid --------------------------------------------- */}
{isLoading ? : people.length === 0 ? ( -

+

{t('admin.people.empty', { defaultValue: 'No people detected yet.' })}

) : ( @@ -545,7 +545,7 @@ export const PeopleManagerModal: React.FC = ({
)} -

+

{t('admin.people.photoCount', { count: person.total_face_count ?? person.face_count, defaultValue: `${person.total_face_count ?? person.face_count} photos`, @@ -614,7 +614,7 @@ export const PeopleManagerModal: React.FC = ({ disabled={busy} title={t('admin.people.contextAction', { defaultValue: 'See this person in their photo' })} onClick={() => setViewing({ person, index: -1 })} - className="p-2 text-neutral-400 hover:text-neutral-700 rounded" + className="p-2 text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 rounded" > @@ -623,7 +623,7 @@ export const PeopleManagerModal: React.FC = ({ disabled={busy} title={t('admin.people.coverAction', { defaultValue: 'Choose which photo represents this person' })} onClick={() => setCoverFor(person)} - className="p-2 text-neutral-400 hover:text-neutral-700 rounded" + className="p-2 text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 rounded" > @@ -632,7 +632,7 @@ export const PeopleManagerModal: React.FC = ({ disabled={busy} title={t('admin.people.splitAction', { defaultValue: 'Split out photos that are someone else' })} onClick={() => { setSplitting(person); setSplitFaceIds([]); }} - className="p-2 text-neutral-400 hover:text-neutral-700 rounded" + className="p-2 text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 rounded" > @@ -641,7 +641,7 @@ export const PeopleManagerModal: React.FC = ({ disabled={busy} title={t('admin.people.hideAction', { defaultValue: 'Hide from guests' })} onClick={() => setFlag(person, 'is_hidden', !person.is_hidden)} - className={`p-2 rounded ${person.is_hidden ? 'text-primary-600' : 'text-neutral-400 hover:text-neutral-700'}`} + className={`p-2 rounded ${person.is_hidden ? 'text-primary-600' : 'text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200'}`} > @@ -650,7 +650,7 @@ export const PeopleManagerModal: React.FC = ({ disabled={busy} title={t('admin.people.ignoreAction', { defaultValue: 'Not a real person — ignore' })} onClick={() => setFlag(person, 'is_ignored', !person.is_ignored)} - className={`p-2 rounded ${person.is_ignored ? 'text-red-600' : 'text-neutral-400 hover:text-neutral-700'}`} + className={`p-2 rounded ${person.is_ignored ? 'text-red-600' : 'text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200'}`} > @@ -664,8 +664,8 @@ export const PeopleManagerModal: React.FC = ({ {/* Merge only becomes available at two, and the wording names the target explicitly so nobody has to guess which name survives. */} -

- +
+ {selected.length > 0 ? t('admin.people.selectedCount', { count: selected.length, diff --git a/frontend/src/components/gallery/PeopleSheet.tsx b/frontend/src/components/gallery/PeopleSheet.tsx index 2e1df6a4..af339142 100644 --- a/frontend/src/components/gallery/PeopleSheet.tsx +++ b/frontend/src/components/gallery/PeopleSheet.tsx @@ -60,17 +60,24 @@ export const PeopleSheet: React.FC = ({ role="dialog" aria-modal="true" aria-label={t('gallery.people.title', { defaultValue: 'People in this gallery' })} - className="relative w-full sm:max-w-2xl bg-white rounded-t-2xl sm:rounded-2xl shadow-xl max-h-[85vh] flex flex-col" + className="relative w-full sm:max-w-2xl rounded-t-2xl sm:rounded-2xl shadow-xl max-h-[85vh] flex flex-col" + style={{ backgroundColor: 'var(--color-surface)', color: 'var(--color-text)' }} > -
-

+
+

{t('gallery.people.title', { defaultValue: 'People in this gallery' })}

+ {/* Same inverted-hover trap as the strip's dismiss button: hover has + to add contrast on a dark gallery, not remove it. */} @@ -79,13 +86,22 @@ export const PeopleSheet: React.FC = ({ {people.some((p) => p.label) && (
- + setQuery(e.target.value)} placeholder={t('gallery.people.searchPlaceholder', { defaultValue: 'Find a person' })} - className="w-full pl-9 pr-3 py-2 text-sm border border-neutral-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + className="w-full pl-9 pr-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent" + style={{ + backgroundColor: 'var(--color-background)', + borderColor: 'var(--color-surface-border)', + color: 'var(--color-text)', + }} />
@@ -106,11 +122,21 @@ export const PeopleSheet: React.FC = ({ > {photo && ( = ({ )} - + {person.label || t('gallery.people.unnamedCount', { count: person.face_count, defaultValue: `${person.face_count} photos`, })} {person.label && ( - {person.face_count} + + {person.face_count} + )} @@ -147,7 +182,7 @@ export const PeopleSheet: React.FC = ({
{filtered.length === 0 && ( -

+

{t('gallery.people.noMatches', { defaultValue: 'No one matches that name.' })}

)} @@ -157,7 +192,10 @@ export const PeopleSheet: React.FC = ({ plain language — this copy never says "biometric" or "recognition", because those words describe our implementation, not the guest's experience. */} -
+

{t('gallery.people.privacyNote', { diff --git a/frontend/src/components/gallery/PeopleStrip.tsx b/frontend/src/components/gallery/PeopleStrip.tsx index 8ca6ccb8..a85e05fd 100644 --- a/frontend/src/components/gallery/PeopleStrip.tsx +++ b/frontend/src/components/gallery/PeopleStrip.tsx @@ -62,14 +62,23 @@ const PersonAvatar: React.FC = ({ className="flex flex-col items-center gap-1.5 flex-shrink-0 group focus:outline-none" style={{ width: `${size + 8}px` }} > + {/* Every colour here comes from the gallery theme tokens, not fixed + neutrals — see the note on the label below. The ring-offset token + matters as much as the ring itself: left at its Tailwind default the + selected avatar wears a white halo on a dark gallery. */} {photo && person.cover ? ( = ({ style={cropStyle || { width: '100%', height: '100%', objectFit: 'cover' }} /> ) : ( - + )} @@ -189,11 +201,16 @@ export const PeopleStrip: React.FC = ({ )} + {/* Opacity, not a neutral pair: a fixed `hover:text-neutral-600` + makes this DARKER on hover, so on a dark gallery the control + disappears exactly when you reach for it. Driving it from + --color-text means hover always adds contrast, either way. */} @@ -205,7 +222,7 @@ export const PeopleStrip: React.FC = ({ who arrives mid-scan gets a working gallery and a growing strip. */} {scan?.in_progress && (

-
+
{t('gallery.people.scanning', { scanned: scan.scanned, @@ -214,7 +231,10 @@ export const PeopleStrip: React.FC = ({ })}
-
+