From 32e5fc19740d2db2bbf86495a78d93253de870ef Mon Sep 17 00:00:00 2001 From: ilia Date: Wed, 5 Aug 2026 14:13:26 -0400 Subject: [PATCH 1/2] Make admin Chabad chrome match viewer (navy + theme). Add light/dark toggle, navy gold-accented sidebar, skip link, and clearer titles so admin reads as the same JRCC brand; document UX review and next priorities in ROADMAP. --- ROADMAP.md | 7 +- admin-frontend/index.html | 11 + admin-frontend/src/App.tsx | 33 +-- admin-frontend/src/components/Layout.tsx | 189 +++++++++--------- admin-frontend/src/components/ThemeToggle.tsx | 48 +++++ admin-frontend/src/context/ThemeContext.tsx | 78 ++++++++ admin-frontend/src/index.css | 47 +++++ admin-frontend/src/pages/Login.tsx | 64 +++--- admin-frontend/tailwind.config.js | 9 + docs/ADMIN_UX_REVIEW.md | 42 ++++ docs/FACE_ACCURACY_STATUS.md | 12 +- 11 files changed, 397 insertions(+), 143 deletions(-) create mode 100644 admin-frontend/src/components/ThemeToggle.tsx create mode 100644 admin-frontend/src/context/ThemeContext.tsx create mode 100644 docs/ADMIN_UX_REVIEW.md diff --git a/ROADMAP.md b/ROADMAP.md index f20d9ce..d93a4cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -102,6 +102,7 @@ Living plan for product quality, auth/email reliability, and automation. - [x] **Favorite sign-in copy** — logged-out heart opens favorites messaging (not report) via `SignInRequiredDialog` - [x] **Text contrast** — replace misplaced `text-secondary` (pale wash token) with `text-foreground` / `text-primary` on labels, auth dialogs, menus +- [x] **Admin Chabad chrome** — navy sidebar, gold accent, theme toggle matching viewer tokens (`docs/ADMIN_UX_REVIEW.md`) ### Face recognition accuracy (2026-08) @@ -110,8 +111,12 @@ Living plan for product quality, auth/email reliability, and automation. - [x] **Phase 3 — recalibrate confidence + re-score quality** — fit distance→confidence knots from identified pairs / `match_decisions`; optional JSON overrides legacy curve; `scripts/fit_confidence_calibration.py` + `scripts/rescore_face_quality.py`; docs in `docs/FACE_CONFIDENCE_CALIBRATION.md` - [x] **Phase 4 — multi-ref matching** — up to 3 trusted refs per person; best (min) distance wins; rolling-mean calibration fitter; status in `docs/FACE_ACCURACY_STATUS.md` - [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 — multi-embedding / cluster-name Identify** (person merge shipped in #95) +- [ ] **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 ## Later diff --git a/admin-frontend/index.html b/admin-frontend/index.html index 792b87c..ee2375f 100644 --- a/admin-frontend/index.html +++ b/admin-frontend/index.html @@ -8,6 +8,17 @@ JRCC Photos Admin +
diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index ab50b32..7cdccc4 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { AuthProvider, useAuth } from './context/AuthContext' import { DeveloperModeProvider } from './context/DeveloperModeContext' +import { ThemeProvider } from './context/ThemeContext' import Login from './pages/Login' import Dashboard from './pages/Dashboard' import Search from './pages/Search' @@ -171,21 +172,23 @@ function AppRoutes() { function App() { return ( - - - - - - - - - - - + + + + + + + + + + + + + ) } diff --git a/admin-frontend/src/components/Layout.tsx b/admin-frontend/src/components/Layout.tsx index 37af124..2c434c2 100644 --- a/admin-frontend/src/components/Layout.tsx +++ b/admin-frontend/src/components/Layout.tsx @@ -2,22 +2,43 @@ import { useCallback, useState } from 'react' import { Outlet, Link, useLocation } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import { useInactivityTimeout } from '../hooks/useInactivityTimeout' +import ThemeToggle from './ThemeToggle' const INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000 -// Check if running on iOS const isIOS = (): boolean => { - return /iPad|iPhone|iPod/.test(navigator.userAgent) || + return ( + /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) + ) } type NavItem = { path: string label: string - icon: string featureKey?: string } +const PAGE_TITLES: Record = { + '/': 'Home', + '/scan': 'Scan photos', + '/process': 'Process faces', + '/search': 'Search photos', + '/identify': 'Identify people', + '/auto-match': 'Auto-Match', + '/modify': 'Modify people', + '/tags': 'Tag photos', + '/manage-photos': 'Manage photos', + '/faces-maintenance': 'Faces maintenance', + '/approve-identified': 'User-identified faces', + '/manage-users': 'Users', + '/reported-photos': 'Reported photos', + '/pending-linkages': 'User-tagged photos', + '/pending-photos': 'User uploads', + '/settings': 'Settings', + '/help': 'Help', +} + export default function Layout() { const location = useLocation() const { username, logout, isAuthenticated, hasPermission } = useAuth() @@ -36,51 +57,44 @@ export default function Layout() { }) const primaryNavItems: NavItem[] = [ - { path: '/scan', label: 'Scan', icon: '🗂️', featureKey: 'scan' }, - { path: '/process', label: 'Process', icon: '⚙️', featureKey: 'process' }, - { path: '/search', label: 'Search Photos', icon: '🔍', featureKey: 'search_photos' }, - { path: '/identify', label: 'Identify People', icon: '👤', featureKey: 'identify_people' }, - { path: '/auto-match', label: 'Auto-Match', icon: '🤖', featureKey: 'auto_match' }, - { path: '/modify', label: 'Modify People', icon: '✏️', featureKey: 'modify_people' }, - { path: '/tags', label: 'Tag Photos', icon: '🏷️', featureKey: 'tag_photos' }, + { 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: '/tags', label: 'Tag photos', featureKey: 'tag_photos' }, ] const maintenanceNavItems: NavItem[] = [ - { path: '/faces-maintenance', label: 'Faces', icon: '🔧', featureKey: 'faces_maintenance' }, - { path: '/approve-identified', label: 'User Identified Faces', icon: '✅', featureKey: 'user_identified' }, - { path: '/reported-photos', label: 'User Reported Photos', icon: '🚩', featureKey: 'user_reported' }, - { path: '/pending-linkages', label: 'User Tagged Photos', icon: '🔖', featureKey: 'user_tagged' }, - { path: '/pending-photos', label: 'User Uploaded Photos', icon: '📤', featureKey: 'user_uploaded' }, - { path: '/manage-users', label: 'Users', icon: '👥', featureKey: 'manage_users' }, + { path: '/faces-maintenance', label: 'Faces', featureKey: 'faces_maintenance' }, + { path: '/approve-identified', label: 'User-identified faces', featureKey: 'user_identified' }, + { path: '/reported-photos', label: 'Reported photos', featureKey: 'user_reported' }, + { path: '/pending-linkages', label: 'User-tagged photos', featureKey: 'user_tagged' }, + { path: '/pending-photos', label: 'User uploads', featureKey: 'user_uploaded' }, + { path: '/manage-users', label: 'Users', featureKey: 'manage_users' }, ] - const footerNavItems: NavItem[] = [{ path: '/help', label: 'Help', icon: '📚' }] + const footerNavItems: NavItem[] = [{ path: '/help', label: 'Help' }] const filterNavItems = (items: NavItem[]) => items.filter((item) => !item.featureKey || hasPermission(item.featureKey)) - const renderNavLink = ( - item: { path: string; label: string; icon: string }, - extraClasses = '' - ) => { + const renderNavLink = (item: NavItem, extraClasses = '') => { const isActive = location.pathname === item.path return ( { - // Close sidebar on iOS when navigating - if (isIOSDevice) { - setSidebarOpen(false) - } + if (isIOSDevice) setSidebarOpen(false) }} - className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${ + className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${ isActive - ? 'bg-primary/10 text-primary' - : 'text-foreground/80 hover:bg-muted' + ? 'bg-sidebar-active text-sidebar-accent-foreground ring-1 ring-gold/50' + : 'text-sidebar-foreground/85 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground' } ${extraClasses}`} > - {item.icon} {item.label} ) @@ -89,44 +103,32 @@ export default function Layout() { const visiblePrimary = filterNavItems(primaryNavItems) const visibleMaintenance = filterNavItems(maintenanceNavItems) const visibleFooter = filterNavItems(footerNavItems) - - // Get page title based on route - const getPageTitle = () => { - const route = location.pathname - if (route === '/') return '🏠 Home Page' - if (route === '/scan') return '🗂️ Scan Photos' - if (route === '/process') return '⚙️ Process Faces' - if (route === '/search') return '🔍 Search Photos' - if (route === '/identify') return '👤 Identify' - if (route === '/auto-match') return '🤖 Auto-Match Faces' - if (route === '/modify') return '✏️ Modify Identified' - if (route === '/tags') return '🏷️ Photos tagging interface' - if (route === '/manage-photos') return 'Manage Photos' - if (route === '/faces-maintenance') return '🔧 Faces Maintenance' - if (route === '/approve-identified') return '✅ Approve Identified' - if (route === '/manage-users') return '👥 Manage Users' - if (route === '/reported-photos') return '🚩 Reported Photos' - if (route === '/pending-linkages') return '🔖 User Tagged Photos' - if (route === '/pending-photos') return '📤 Manage User Uploaded Photos' - if (route === '/settings') return 'Settings' - if (route === '/help') return '📚 Help' - return 'PunimTag' - } + const pageTitle = PAGE_TITLES[location.pathname] || 'JRCC Photos Admin' + const railWidth = isIOSDevice ? 'w-16' : 'w-64' + const contentOffset = isIOSDevice ? 'ml-16' : 'ml-64' return (
+ + Skip to main content + + {/* Top bar */} -
+
- {/* Left sidebar - fixed position with logo */} -
+
{isIOSDevice ? ( ) : ( - - JRCC - JRCC + + {/* Dark-bg wordmark on navy rail */} + )}
- {/* Header content - aligned with main content */} -
-
-
-

{getPageTitle()}

-
-
- {username} + +
+
+

{pageTitle}

+
+ + {username}
-
+
-
- {/* Overlay for mobile when sidebar is open */} +
{isIOSDevice && sidebarOpen && (
setSidebarOpen(false)} + aria-hidden="true" /> )} - {/* Left sidebar - fixed position */} -
-
)} {visibleFooter.length > 0 && ( -
+
{visibleFooter.map((item) => renderNavLink(item))}
)} -
+ - {/* Main content - with left margin to account for fixed sidebar */} -
+
-
+
) } - diff --git a/admin-frontend/src/components/ThemeToggle.tsx b/admin-frontend/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..0d897ea --- /dev/null +++ b/admin-frontend/src/components/ThemeToggle.tsx @@ -0,0 +1,48 @@ +import { useTheme } from '../context/ThemeContext' + +type ThemeToggleProps = { + className?: string + /** Use on navy chrome where muted colors need higher contrast */ + onNavy?: boolean +} + +export default function ThemeToggle({ className = '', onNavy = false }: ThemeToggleProps) { + const { theme, toggleTheme } = useTheme() + const isDark = theme === 'dark' + const label = isDark ? 'Switch to light mode' : 'Switch to dark mode' + + return ( + + ) +} diff --git a/admin-frontend/src/context/ThemeContext.tsx b/admin-frontend/src/context/ThemeContext.tsx new file mode 100644 index 0000000..20b881a --- /dev/null +++ b/admin-frontend/src/context/ThemeContext.tsx @@ -0,0 +1,78 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react' + +export type ThemeMode = 'light' | 'dark' + +const STORAGE_KEY = 'punimtag-admin-theme' + +type ThemeContextValue = { + theme: ThemeMode + setTheme: (mode: ThemeMode) => void + toggleTheme: () => void +} + +const ThemeContext = createContext(null) + +function readStoredTheme(): ThemeMode { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored === 'light' || stored === 'dark') return stored + } catch { + /* ignore */ + } + if (typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark' + } + return 'light' +} + +function applyThemeClass(mode: ThemeMode) { + document.documentElement.classList.toggle('dark', mode === 'dark') +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setThemeState] = useState(() => + typeof document !== 'undefined' && document.documentElement.classList.contains('dark') + ? 'dark' + : readStoredTheme(), + ) + + useEffect(() => { + applyThemeClass(theme) + try { + localStorage.setItem(STORAGE_KEY, theme) + } catch { + /* ignore */ + } + }, [theme]) + + const setTheme = useCallback((mode: ThemeMode) => { + setThemeState(mode) + }, []) + + const toggleTheme = useCallback(() => { + setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark')) + }, []) + + const value = useMemo( + () => ({ theme, setTheme, toggleTheme }), + [theme, setTheme, toggleTheme], + ) + + return {children} +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext) + if (!ctx) { + throw new Error('useTheme must be used within ThemeProvider') + } + return ctx +} diff --git a/admin-frontend/src/index.css b/admin-frontend/src/index.css index 075ca66..6430231 100644 --- a/admin-frontend/src/index.css +++ b/admin-frontend/src/index.css @@ -5,6 +5,9 @@ /* Chabad Blue — shared with viewer-frontend/app/globals.css Navy #0038A8 / deep text #0B1F4B / soft bg #F5F7FB / quiet gold #C4A35A + + Admin chrome uses a deep-navy sidebar in both modes so the brand reads + immediately (viewer keeps a lighter header; admin is a denser tool UI). */ :root { --radius: 0.625rem; @@ -28,6 +31,13 @@ --border: rgba(11, 31, 75, 0.12); --input: rgba(11, 31, 75, 0.14); --ring: #0038a8; + --sidebar: #0b1f4b; + --sidebar-foreground: #f5f7fb; + --sidebar-muted: #93a4c4; + --sidebar-accent: rgba(245, 247, 251, 0.1); + --sidebar-accent-foreground: #ffffff; + --sidebar-border: rgba(245, 247, 251, 0.12); + --sidebar-active: rgba(196, 163, 90, 0.22); --font-sans: "Rubik", "Heebo", ui-sans-serif, system-ui, sans-serif; --font-display: "Heebo", "Rubik", ui-sans-serif, system-ui, sans-serif; } @@ -53,6 +63,13 @@ --border: rgba(245, 247, 251, 0.12); --input: rgba(245, 247, 251, 0.16); --ring: #5b8def; + --sidebar: #06102a; + --sidebar-foreground: #f5f7fb; + --sidebar-muted: #b6c4df; + --sidebar-accent: rgba(245, 247, 251, 0.08); + --sidebar-accent-foreground: #ffffff; + --sidebar-border: rgba(245, 247, 251, 0.1); + --sidebar-active: rgba(91, 141, 239, 0.28); } html { @@ -70,6 +87,36 @@ body { color: var(--foreground); } +:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} + +.skip-link { + position: absolute; + left: -9999px; + top: 0; + z-index: 100; + padding: 0.5rem 1rem; + background: var(--primary); + color: var(--primary-foreground); + border-radius: 0 0 var(--radius) 0; +} + +.skip-link:focus { + left: 0; +} + /* Custom scrollbar styling for similar faces container */ .similar-faces-scrollable { scrollbar-width: auto; diff --git a/admin-frontend/src/pages/Login.tsx b/admin-frontend/src/pages/Login.tsx index e92273a..df04f0c 100644 --- a/admin-frontend/src/pages/Login.tsx +++ b/admin-frontend/src/pages/Login.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' +import ThemeToggle from '../components/ThemeToggle' export default function Login() { const [username, setUsername] = useState('') @@ -39,74 +40,83 @@ export default function Login() { if (isLoading && !loading) { return ( -
- Loading... +
+ Loading…
) } return ( -
-
-
-
-
+
+