diff --git a/.gitignore b/.gitignore index 9f4e4fa..789074d 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,4 @@ viewer-frontend/.data/ !admin-frontend/public/brand/ !admin-frontend/public/brand/**/*.png backend/data/confidence_calibration.json +.dev-admin-password.tmp diff --git a/ROADMAP.md b/ROADMAP.md index d93a4cd..15615c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,10 +113,11 @@ Living plan for product quality, auth/email reliability, and automation. - [x] **Immich precision gates** — max recognition distance, next-best person margin, detection floor 0.55, auto-accept distance cap; admin Chabad Blue theme (shell + login) - [x] **Person merge** — `POST /people/{id}/merge` + Modify People UI (#95) - [x] **Admin brand parity** — JRCC logos tracked; page tokens; navy sidebar + light/dark toggle (#96 + chrome follow-up) -- [ ] **Phase 5 — harder reject of junk detections (tiny/blur/pose)** — partial via detection floor; blur/pose still open -- [ ] **Phase 6 — cluster-name Identify** (merge done; naming unnamed clusters still open) -- [ ] **Admin People hub IA** — tabs for Identify / Auto-Match / Modify (see `docs/ADMIN_UX_REVIEW.md`) -- [ ] **a11y smoke** — axe on admin login + one workflow; viewer skip-link +- [x] **Admin People hub IA** — `/people/{identify,auto-match,modify}` tabs + redirects from old paths +- [x] **a11y smoke** — axe on admin login + viewer home; viewer skip-link already present +- [x] **Phase 5 — harder reject of junk detections** — blur (laplacian) + extreme yaw/pitch at Process time +- [x] **Cluster-name Identify** — “Name cluster” selects similarity group into Identify payload +- [ ] Soak `match_decisions` on DEV then revisit calibration `--apply` (needs ≥20 same + ≥20 diff pairs) ## Later diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index 7cdccc4..13bde9f 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -11,6 +11,7 @@ import Process from './pages/Process' import Identify from './pages/Identify' import AutoMatch from './pages/AutoMatch' import Modify from './pages/Modify' +import PeopleHub from './pages/PeopleHub' import Tags from './pages/Tags' import FacesMaintenance from './pages/FacesMaintenance' import ApproveIdentified from './pages/ApproveIdentified' @@ -117,9 +118,15 @@ function AppRoutes() { } /> } /> } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> } /> } /> } /> diff --git a/admin-frontend/src/components/Layout.tsx b/admin-frontend/src/components/Layout.tsx index 2c434c2..f7ca9a2 100644 --- a/admin-frontend/src/components/Layout.tsx +++ b/admin-frontend/src/components/Layout.tsx @@ -24,6 +24,10 @@ const PAGE_TITLES: Record = { '/scan': 'Scan photos', '/process': 'Process faces', '/search': 'Search photos', + '/people': 'People', + '/people/identify': 'Identify people', + '/people/auto-match': 'Auto-Match', + '/people/modify': 'Modify people', '/identify': 'Identify people', '/auto-match': 'Auto-Match', '/modify': 'Modify people', @@ -60,9 +64,7 @@ export default function Layout() { { path: '/scan', label: 'Scan', featureKey: 'scan' }, { path: '/process', label: 'Process', featureKey: 'process' }, { path: '/search', label: 'Search photos', featureKey: 'search_photos' }, - { path: '/identify', label: 'Identify people', featureKey: 'identify_people' }, - { path: '/auto-match', label: 'Auto-Match', featureKey: 'auto_match' }, - { path: '/modify', label: 'Modify people', featureKey: 'modify_people' }, + { path: '/people', label: 'People', featureKey: 'identify_people' }, { path: '/tags', label: 'Tag photos', featureKey: 'tag_photos' }, ] @@ -81,11 +83,14 @@ export default function Layout() { items.filter((item) => !item.featureKey || hasPermission(item.featureKey)) const renderNavLink = (item: NavItem, extraClasses = '') => { - const isActive = location.pathname === item.path + const isActive = + item.path === '/people' + ? location.pathname.startsWith('/people') + : location.pathname === item.path return ( { if (isIOSDevice) setSidebarOpen(false) }} @@ -103,7 +108,9 @@ export default function Layout() { const visiblePrimary = filterNavItems(primaryNavItems) const visibleMaintenance = filterNavItems(maintenanceNavItems) const visibleFooter = filterNavItems(footerNavItems) - const pageTitle = PAGE_TITLES[location.pathname] || 'JRCC Photos Admin' + const pageTitle = + PAGE_TITLES[location.pathname] || + (location.pathname.startsWith('/people') ? 'People' : 'JRCC Photos Admin') const railWidth = isIOSDevice ? 'w-16' : 'w-64' const contentOffset = isIOSDevice ? 'ml-16' : 'ml-64' diff --git a/admin-frontend/src/pages/Identify.tsx b/admin-frontend/src/pages/Identify.tsx index 62c3bbd..43abe7b 100644 --- a/admin-frontend/src/pages/Identify.tsx +++ b/admin-frontend/src/pages/Identify.tsx @@ -91,6 +91,8 @@ export default function Identify() { const [compareEnabled, setCompareEnabled] = useState(true) const [selectedSimilar, setSelectedSimilar] = useState>({}) const [uniqueFacesOnly, setUniqueFacesOnly] = useState(true) + /** face id → other face ids in the same similarity cluster (excludes self) */ + const [clusterMembersByRep, setClusterMembersByRep] = useState>({}) // Excluded faces filter const [includeExcludedFaces, setIncludeExcludedFaces] = useState(false) @@ -222,6 +224,8 @@ export default function Identify() { let filtered = res.items if (uniqueFacesOnly) { filtered = await filterUniqueFaces(filtered) + } else { + setClusterMembersByRep({}) } setFaces(filtered) @@ -329,23 +333,28 @@ export default function Identify() { // Track which faces should be excluded (duplicates in groups) const excludeSet = new Set() - + const clusterMap: Record = {} + // For each group, keep only the first face and mark others for exclusion for (const group of groups) { - let firstFaceFound = false + let representative: number | null = null + const members: number[] = [] for (const face of faces) { if (group.has(face.id)) { - if (!firstFaceFound) { - // Keep this face (first representative) - firstFaceFound = true + if (representative === null) { + representative = face.id } else { - // Exclude this face (duplicate) excludeSet.add(face.id) + members.push(face.id) } } } + if (representative !== null && members.length > 0) { + clusterMap[representative] = members + } } - + + setClusterMembersByRep(clusterMap) // Return faces that are not excluded return faces.filter(face => !excludeSet.has(face.id)) } @@ -1557,7 +1566,27 @@ export default function Identify() { readOnly={!!personId} disabled={!!personId} /> -
+
+ {currentFace && (clusterMembersByRep[currentFace.id]?.length ?? 0) > 0 && ( + + )}