Every component added by #1074 was styled for light mode only. In the admin dark theme the three toggle labels rendered invisible — including "Detect people in this gallery", the switch that starts GDPR Art. 9 processing — and the Manage-people modal rendered as a light panel over a dark page because its shell was a hardcoded bg-white. Admin surfaces pair each neutral with a dark: variant; guest surfaces read the gallery theme tokens, because galleries carry their own dark themes that the admin dark class knows nothing about. Beyond the issue's inventory: the cover picker and face-in-context viewer that landed after it was filed, the magnifier chip whose bg-white/90 would have carried light glyphs, PeopleSheet's own hardcoded bg-white shell, the selected avatar's white ring-offset halo, and both dismiss buttons whose hover darkened into the background. External review found one defect, fixed: the sheet's avatars ring against --color-surface, not the page background.
This commit is contained in:
@@ -226,7 +226,9 @@ export const FaceRecognitionCard: React.FC<FaceRecognitionCardProps> = ({ 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 ? (
|
||||
<div className={`flex items-start gap-2 ${BURNS_PHOTOS.has(sidecarWarning) ? 'text-red-700' : 'text-amber-700'}`}>
|
||||
<div className={`flex items-start gap-2 ${BURNS_PHOTOS.has(sidecarWarning)
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: 'text-amber-700 dark:text-amber-300'}`}>
|
||||
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||
<p>
|
||||
{sidecarWarning === 'unauthorized' && t('admin.faces.sidecarUnauthorized', {
|
||||
@@ -243,7 +245,9 @@ export const FaceRecognitionCard: React.FC<FaceRecognitionCardProps> = ({ 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 && (
|
||||
<span className={`block mt-1 text-xs font-mono ${BURNS_PHOTOS.has(sidecarWarning) ? 'text-red-600' : 'text-amber-600'}`}>
|
||||
<span className={`block mt-1 text-xs font-mono ${BURNS_PHOTOS.has(sidecarWarning)
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-amber-600 dark:text-amber-400'}`}>
|
||||
{health.error}
|
||||
</span>
|
||||
)}
|
||||
@@ -256,10 +260,10 @@ export const FaceRecognitionCard: React.FC<FaceRecognitionCardProps> = ({ eventI
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<Users className="text-neutral-400 mt-0.5" size={20} />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-neutral-900">
|
||||
<h3 className="text-lg font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('admin.faces.title', { defaultValue: 'People in this gallery' })}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-500">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{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<FaceRecognitionCardProps> = ({ eventI
|
||||
|
||||
{/* Consent obligation. Stated plainly and up front, because by the time
|
||||
someone has switched this on they have already processed the data. */}
|
||||
<div className="flex gap-2 p-3 mb-4 rounded-lg bg-amber-50 border border-amber-100 text-sm text-amber-900">
|
||||
<div className="flex gap-2 p-3 mb-4 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-100 dark:border-amber-800 text-sm text-amber-900 dark:text-amber-200">
|
||||
<ShieldCheck size={16} className="flex-shrink-0 mt-0.5" />
|
||||
<p>
|
||||
{t('admin.faces.consentNotice', {
|
||||
@@ -285,13 +289,13 @@ export const FaceRecognitionCard: React.FC<FaceRecognitionCardProps> = ({ 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"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-neutral-800">
|
||||
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||
{t('admin.faces.enable', { defaultValue: 'Detect people in this gallery' })}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-500">
|
||||
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{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<FaceRecognitionCardProps> = ({ 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"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-neutral-800">
|
||||
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||
{t('admin.faces.visible', { defaultValue: 'Show the people bar to guests' })}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-500">
|
||||
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{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<FaceRecognitionCardProps> = ({ eventI
|
||||
admin deserves to know that before starting a 2,000-photo backfill
|
||||
rather than discovering it in their storage graph. */}
|
||||
{data.enabled && (
|
||||
<div className="flex gap-2 p-3 mt-3 rounded-lg bg-neutral-50 text-xs text-neutral-600">
|
||||
<div className="flex gap-2 p-3 mt-3 rounded-lg bg-neutral-50 dark:bg-neutral-800 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5 text-neutral-400" />
|
||||
<p>
|
||||
{t('admin.faces.previewNotice', {
|
||||
@@ -340,7 +344,7 @@ export const FaceRecognitionCard: React.FC<FaceRecognitionCardProps> = ({ 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 && (
|
||||
<label className="flex items-start gap-3 py-2 mt-2 pt-3 border-t border-neutral-100 cursor-pointer">
|
||||
<label className="flex items-start gap-3 py-2 mt-2 pt-3 border-t border-neutral-100 dark:border-neutral-700 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoCategories}
|
||||
@@ -358,13 +362,13 @@ export const FaceRecognitionCard: React.FC<FaceRecognitionCardProps> = ({ eventI
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-neutral-800">
|
||||
<span className="block text-sm font-medium text-neutral-800 dark:text-neutral-200">
|
||||
{t('admin.faces.autoCategories', { defaultValue: 'Sort photos into categories automatically' })}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-500">
|
||||
<span className="block text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('admin.faces.autoCategoriesHint', {
|
||||
defaultValue: 'Uses the number of faces to file photos as Details, Portraits, Small groups or Groups. Applies to every gallery, only ever fills an empty category, and never changes one you set yourself.',
|
||||
})}
|
||||
@@ -375,7 +379,7 @@ export const FaceRecognitionCard: React.FC<FaceRecognitionCardProps> = ({ eventI
|
||||
|
||||
{data.enabled && (
|
||||
<>
|
||||
<div className="mt-4 pt-4 border-t border-neutral-100 text-sm text-neutral-600">
|
||||
<div className="mt-4 pt-4 border-t border-neutral-100 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{/* 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<FaceRecognitionCardProps> = ({ eventI
|
||||
</span>
|
||||
)}
|
||||
{status.failed > 0 && (
|
||||
<span className="text-amber-600">
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
{' · '}
|
||||
{t('admin.faces.failed', {
|
||||
count: status.failed,
|
||||
@@ -435,7 +439,7 @@ export const FaceRecognitionCard: React.FC<FaceRecognitionCardProps> = ({ eventI
|
||||
rows — worth doing, but a bigger change than this. */}
|
||||
{!status.in_progress && sidecarNotice && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-neutral-500 mb-1">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-1">
|
||||
{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<FaceRecognitionCardProps> = ({ eventI
|
||||
disabled={saving}
|
||||
onClick={purge}
|
||||
leftIcon={<Trash2 size={14} />}
|
||||
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' })}
|
||||
</Button>
|
||||
|
||||
@@ -98,7 +98,7 @@ const FaceThumb: React.FC<{
|
||||
|
||||
return (
|
||||
<span
|
||||
className="relative block rounded-full overflow-hidden bg-neutral-100 flex-shrink-0"
|
||||
className="relative block rounded-full overflow-hidden bg-neutral-100 dark:bg-neutral-800 flex-shrink-0"
|
||||
style={{ width: size, height: size, opacity: dim ? 0.4 : 1 }}
|
||||
>
|
||||
<img
|
||||
@@ -159,7 +159,7 @@ const FaceInContext: React.FC<{
|
||||
)}
|
||||
</div>
|
||||
{!canBox && (
|
||||
<p className="text-xs text-neutral-500">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{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<PeopleManagerModalProps> = ({
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="relative bg-white text-neutral-900 rounded-xl shadow-xl w-full max-w-4xl max-h-[88vh] flex flex-col"
|
||||
className="relative bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-xl shadow-xl w-full max-w-4xl max-h-[88vh] flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-100">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-100 dark:border-neutral-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-neutral-900">
|
||||
<h2 className="text-lg font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t('admin.people.title', { defaultValue: 'People in this gallery' })}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-500">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('admin.people.subtitle', {
|
||||
defaultValue: 'Rename, merge people who were split apart, or hide someone from guests.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="p-2 -m-2 text-neutral-400 hover:text-neutral-600">
|
||||
<button type="button" onClick={onClose} className="p-2 -m-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -344,7 +344,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
const truncated = (viewing.person.total_face_count ?? faces.length) > faces.length;
|
||||
return (
|
||||
<>
|
||||
<div className="px-5 py-3 bg-neutral-50 border-b border-neutral-100 text-sm text-neutral-700">
|
||||
<div className="px-5 py-3 bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-100 dark:border-neutral-700 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{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<PeopleManagerModalProps> = ({
|
||||
// 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.
|
||||
<p className="text-sm text-neutral-500 text-center py-10">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-10">
|
||||
{t('admin.people.contextUnavailable', {
|
||||
defaultValue: 'No photo could be loaded for this person.',
|
||||
})}
|
||||
@@ -363,7 +363,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
<>
|
||||
<FaceInContext eventId={eventId} face={face} />
|
||||
{truncated && (
|
||||
<p className="mt-2 text-center text-xs text-neutral-500">
|
||||
<p className="mt-2 text-center text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{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<PeopleManagerModalProps> = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 px-5 py-4 border-t border-neutral-100">
|
||||
<div className="flex items-center justify-between gap-2 px-5 py-4 border-t border-neutral-100 dark:border-neutral-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline" size="sm" disabled={faces.length < 2}
|
||||
@@ -382,7 +382,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</Button>
|
||||
<span className="text-sm text-neutral-500 tabular-nums">
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400 tabular-nums">
|
||||
{faces.length ? `${index + 1} / ${faces.length}${truncated ? '+' : ''}` : '—'}
|
||||
</span>
|
||||
<Button
|
||||
@@ -401,14 +401,14 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
);
|
||||
})() : coverFor ? (
|
||||
<>
|
||||
<div className="px-5 py-3 bg-neutral-50 border-b border-neutral-100 text-sm text-neutral-700">
|
||||
<div className="px-5 py-3 bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-100 dark:border-neutral-700 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
{t('admin.people.coverHelp', {
|
||||
defaultValue: 'Pick the photo that best shows this person. It becomes their avatar here and in the guest-facing people strip.',
|
||||
})}
|
||||
{(faceData?.faces?.length || 0) >= Math.min(
|
||||
PERSON_FACES_LIMIT, coverFor.total_face_count ?? PERSON_FACES_LIMIT
|
||||
) && (coverFor.total_face_count ?? 0) > PERSON_FACES_LIMIT && (
|
||||
<span className="block mt-1 text-xs text-neutral-500">
|
||||
<span className="block mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('admin.people.coverTruncated', {
|
||||
limit: PERSON_FACES_LIMIT,
|
||||
defaultValue: `Showing the ${PERSON_FACES_LIMIT} highest-confidence faces of this person.`,
|
||||
@@ -429,7 +429,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
title={t('admin.people.coverPick', { defaultValue: 'Use as cover' })}
|
||||
onClick={() => chooseCover(face.id)}
|
||||
className={`block rounded-lg overflow-hidden border-2 transition-colors ${
|
||||
current ? 'border-primary-600' : 'border-transparent hover:border-neutral-300'
|
||||
current ? 'border-primary-600' : 'border-transparent hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<FaceThumb
|
||||
@@ -455,7 +455,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
type="button"
|
||||
title={t('admin.people.contextAction', { defaultValue: 'See this person in their photo' })}
|
||||
onClick={() => setViewing({ person: coverFor, index: idx })}
|
||||
className="absolute bottom-1 right-1 bg-white/90 text-neutral-700 rounded-full p-1 opacity-100 [@media(hover:hover)]:opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
||||
className="absolute bottom-1 right-1 bg-white/90 dark:bg-neutral-900/90 text-neutral-700 dark:text-neutral-200 rounded-full p-1 opacity-100 [@media(hover:hover)]:opacity-0 group-hover:opacity-100 focus:opacity-100 transition-opacity"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
</button>
|
||||
@@ -465,7 +465,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-neutral-100">
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-neutral-100 dark:border-neutral-700">
|
||||
<Button variant="outline" size="sm" onClick={() => setCoverFor(null)}>
|
||||
{t('common.cancel', { defaultValue: 'Cancel' })}
|
||||
</Button>
|
||||
@@ -473,7 +473,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
</>
|
||||
) : splitting ? (
|
||||
<>
|
||||
<div className="px-5 py-3 bg-amber-50 border-b border-amber-100 text-sm text-amber-900">
|
||||
<div className="px-5 py-3 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-100 dark:border-amber-800 text-sm text-amber-900 dark:text-amber-200">
|
||||
{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<PeopleManagerModalProps> = ({
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<FaceThumb
|
||||
@@ -512,8 +512,8 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 px-5 py-4 border-t border-neutral-100">
|
||||
<span className="text-sm text-neutral-500">
|
||||
<div className="flex items-center justify-between gap-2 px-5 py-4 border-t border-neutral-100 dark:border-neutral-700">
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('admin.people.splitSelected', {
|
||||
count: splitFaceIds.length,
|
||||
defaultValue: `${splitFaceIds.length} selected`,
|
||||
@@ -534,7 +534,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
{/* --- people grid --------------------------------------------- */}
|
||||
<div className="flex-1 overflow-y-auto p-5">
|
||||
{isLoading ? <Loading /> : people.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 text-center py-10">
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-10">
|
||||
{t('admin.people.empty', { defaultValue: 'No people detected yet.' })}
|
||||
</p>
|
||||
) : (
|
||||
@@ -545,7 +545,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
<div
|
||||
key={person.id}
|
||||
className={`flex items-center gap-3 p-2 rounded-lg border transition-colors ${
|
||||
isSelected ? 'border-primary-400 bg-primary-50' : 'border-transparent hover:bg-neutral-50'
|
||||
isSelected ? 'border-primary-400 bg-primary-50 dark:bg-primary-900/30' : 'border-transparent hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
@@ -577,13 +577,13 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
if (e.key === 'Escape') setRenaming(null);
|
||||
}}
|
||||
placeholder={t('admin.people.namePlaceholder', { defaultValue: 'Add a name' })}
|
||||
className="w-full max-w-xs px-2 py-1 text-sm border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
className="w-full max-w-xs px-2 py-1 text-sm border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setRenaming(person.id); setDraftLabel(person.label || ''); }}
|
||||
className="text-sm text-left text-neutral-900 hover:underline"
|
||||
className="text-sm text-left text-neutral-900 dark:text-neutral-100 hover:underline"
|
||||
>
|
||||
{person.label || (
|
||||
<span className="text-neutral-400 italic">
|
||||
@@ -592,7 +592,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500">
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{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<PeopleManagerModalProps> = ({
|
||||
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"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
@@ -623,7 +623,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
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"
|
||||
>
|
||||
<ImageIcon size={16} />
|
||||
</button>
|
||||
@@ -632,7 +632,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
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"
|
||||
>
|
||||
<Scissors size={16} />
|
||||
</button>
|
||||
@@ -641,7 +641,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
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'}`}
|
||||
>
|
||||
<EyeOff size={16} />
|
||||
</button>
|
||||
@@ -650,7 +650,7 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
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'}`}
|
||||
>
|
||||
<Ban size={16} />
|
||||
</button>
|
||||
@@ -664,8 +664,8 @@ export const PeopleManagerModal: React.FC<PeopleManagerModalProps> = ({
|
||||
|
||||
{/* Merge only becomes available at two, and the wording names the
|
||||
target explicitly so nobody has to guess which name survives. */}
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4 border-t border-neutral-100">
|
||||
<span className="text-sm text-neutral-500">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4 border-t border-neutral-100 dark:border-neutral-700">
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{selected.length > 0
|
||||
? t('admin.people.selectedCount', {
|
||||
count: selected.length,
|
||||
|
||||
Reference in New Issue
Block a user