Admin Chabad chrome parity + UX review #97

Merged
ilia merged 2 commits from feature/admin-chabad-chrome-ux into master 2026-08-05 13:20:46 -05:00
13 changed files with 400 additions and 146 deletions
+6 -1
View File
@@ -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
+11
View File
@@ -8,6 +8,17 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Heebo:wght@400;500;600;700&family=Rubik:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>JRCC Photos Admin</title>
<script>
(function () {
try {
var t = localStorage.getItem('punimtag-admin-theme');
var dark =
t === 'dark' ||
(t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark');
} catch (e) {}
})();
</script>
</head>
<body>
<div id="root"></div>
+18 -15
View File
@@ -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 (
<AuthProvider>
<DeveloperModeProvider>
<ToastProvider>
<ConfirmProvider>
<BrowserRouter
basename={
import.meta.env.BASE_URL.replace(/\/$/, '') || undefined
}
>
<AppRoutes />
</BrowserRouter>
</ConfirmProvider>
</ToastProvider>
</DeveloperModeProvider>
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<DeveloperModeProvider>
<ToastProvider>
<ConfirmProvider>
<BrowserRouter
basename={
import.meta.env.BASE_URL.replace(/\/$/, '') || undefined
}
>
<AppRoutes />
</BrowserRouter>
</ConfirmProvider>
</ToastProvider>
</DeveloperModeProvider>
</AuthProvider>
</ThemeProvider>
)
}
+90 -99
View File
@@ -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<string, string> = {
'/': '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 (
<Link
key={item.path}
to={item.path}
onClick={() => {
// 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}`}
>
<span>{item.icon}</span>
<span>{item.label}</span>
</Link>
)
@@ -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 (
<div className="min-h-screen bg-background">
<a href="#main-content" className="skip-link">
Skip to main content
</a>
{/* Top bar */}
<div className="bg-card border-b border-border shadow-sm">
<header className="bg-card border-b border-border shadow-sm">
<div className="flex">
{/* Left sidebar - fixed position with logo */}
<div className={`${isIOSDevice ? 'w-20' : 'w-64'} fixed left-0 top-0 bg-card border-r border-border h-20 flex items-center justify-center px-4 z-10`}>
<div
className={`${railWidth} fixed left-0 top-0 z-10 flex h-16 items-center justify-center border-b border-sidebar-border bg-sidebar px-3`}
style={{ boxShadow: 'inset 0 -3px 0 var(--gold)' }}
>
{isIOSDevice ? (
<button
type="button"
onClick={() => setSidebarOpen(!sidebarOpen)}
className="flex items-center justify-center p-2 hover:bg-muted rounded-lg transition-colors"
aria-label="Toggle menu"
className="flex items-center justify-center rounded-lg p-2 text-sidebar-foreground hover:bg-sidebar-accent"
aria-label={sidebarOpen ? 'Close menu' : 'Open menu'}
aria-expanded={sidebarOpen}
>
<svg className="w-6 h-6 text-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
{sidebarOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
) : (
@@ -135,58 +137,48 @@ export default function Layout() {
</svg>
</button>
) : (
<Link to="/" className="flex items-center justify-center hover:opacity-80 transition-opacity">
<img
src="/brand/jrcc-logo-light.png"
alt="JRCC"
className="h-12 w-auto dark:hidden"
/>
<img
src="/brand/jrcc-logo-dark.png"
alt="JRCC"
className="h-12 w-auto hidden dark:block"
/>
<Link to="/" className="flex items-center justify-center transition-opacity hover:opacity-90" aria-label="JRCC admin home">
{/* Dark-bg wordmark on navy rail */}
<img src="/brand/jrcc-logo-dark.png" alt="" className="h-10 w-auto" />
</Link>
)}
</div>
{/* Header content - aligned with main content */}
<div className={`${isIOSDevice ? 'ml-20' : 'ml-64'} flex-1 px-4`}>
<div className="flex justify-between items-center h-20">
<div className="flex items-center">
<h1 className="text-lg font-bold font-display text-foreground">{getPageTitle()}</h1>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">{username}</span>
<div className={`${contentOffset} flex-1 px-4`}>
<div className="flex h-16 items-center justify-between">
<h1 className="font-display text-lg font-semibold tracking-tight text-foreground">{pageTitle}</h1>
<div className="flex items-center gap-2">
<ThemeToggle />
<span className="hidden text-sm text-muted-foreground sm:inline">{username}</span>
<button
type="button"
onClick={logout}
className="px-3 py-1 text-sm text-muted-foreground hover:text-foreground"
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Logout
Log out
</button>
</div>
</div>
</div>
</div>
</div>
</header>
<div className="flex relative">
{/* Overlay for mobile when sidebar is open */}
<div className="relative flex">
{isIOSDevice && sidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-20"
className="fixed inset-0 z-20 bg-black/50"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}
{/* Left sidebar - fixed position */}
<div
className={`fixed left-0 top-20 bg-card border-r border-border h-[calc(100vh-5rem)] overflow-y-auto transition-transform duration-300 z-30 ${
isIOSDevice
? `w-64 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`
: 'w-64'
<aside
className={`fixed left-0 top-16 z-30 h-[calc(100vh-4rem)] overflow-y-auto border-r border-sidebar-border bg-sidebar transition-transform duration-300 ${
isIOSDevice ? `w-64 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'w-64'
}`}
aria-label="Primary"
>
<nav className="p-4 space-y-1">
<nav className="space-y-1 p-3">
{visiblePrimary.map((item) => renderNavLink(item))}
{visibleMaintenance.length > 0 && (
@@ -194,33 +186,32 @@ export default function Layout() {
<button
type="button"
onClick={() => setMaintenanceExpanded((prev) => !prev)}
className="w-full px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground flex items-center justify-between hover:text-foreground"
className="flex w-full items-center justify-between px-3 py-2 text-xs font-semibold uppercase tracking-wide text-sidebar-muted hover:text-sidebar-foreground"
aria-expanded={maintenanceExpanded}
>
<span>Maintenance</span>
<span>{maintenanceExpanded ? '' : ''}</span>
<span aria-hidden="true">{maintenanceExpanded ? '' : ''}</span>
</button>
{maintenanceExpanded && (
<div className="mt-1 space-y-1">
{visibleMaintenance.map((item) => renderNavLink(item, 'ml-4'))}
{visibleMaintenance.map((item) => renderNavLink(item, 'ml-2'))}
</div>
)}
</div>
)}
{visibleFooter.length > 0 && (
<div className="mt-4 space-y-1">
<div className="mt-4 space-y-1 border-t border-sidebar-border pt-3">
{visibleFooter.map((item) => renderNavLink(item))}
</div>
)}
</nav>
</div>
</aside>
{/* Main content - with left margin to account for fixed sidebar */}
<div className={`flex-1 ${isIOSDevice ? 'ml-20' : 'ml-64'} p-4`}>
<main id="main-content" className={`flex-1 ${contentOffset} p-4 sm:p-6`} tabIndex={-1}>
<Outlet />
</div>
</main>
</div>
</div>
)
}
@@ -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 (
<button
type="button"
onClick={toggleTheme}
aria-label={label}
aria-pressed={isDark}
title={label}
className={`inline-flex h-9 w-9 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gold ${
onNavy
? 'text-white/85 hover:bg-white/10 hover:text-white'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
} ${className}`}
>
{isDark ? (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<circle cx="12" cy="12" r="4" strokeWidth="2" />
<path
strokeWidth="2"
strokeLinecap="round"
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"
/>
</svg>
) : (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
d="M21 14.5A8.5 8.5 0 1 1 12.5 3 7 7 0 0 0 21 14.5z"
/>
</svg>
)}
</button>
)
}
@@ -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<ThemeContextValue | null>(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<ThemeMode>(() =>
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 <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (!ctx) {
throw new Error('useTheme must be used within ThemeProvider')
}
return ctx
}
+47
View File
@@ -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;
+37 -27
View File
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-background text-muted-foreground">
Loading...
<div className="flex min-h-screen items-center justify-center bg-background text-muted-foreground">
Loading
</div>
)
}
return (
<div className="min-h-screen bg-background flex items-center justify-center px-4">
<div className="max-w-md w-full">
<div className="bg-card text-card-foreground rounded-lg border border-border shadow-md p-8">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="relative flex min-h-screen items-center justify-center bg-background px-4">
<div
className="pointer-events-none absolute inset-0 opacity-90"
style={{
background:
'radial-gradient(ellipse 80% 50% at 50% -10%, rgba(0, 56, 168, 0.18), transparent), radial-gradient(ellipse 40% 30% at 90% 80%, rgba(196, 163, 90, 0.12), transparent)',
}}
aria-hidden="true"
/>
<div className="absolute right-4 top-4 z-10">
<ThemeToggle />
</div>
<div className="relative z-10 w-full max-w-md">
<div
className="rounded-xl border border-border bg-card p-8 text-card-foreground shadow-md"
style={{ boxShadow: '0 1px 0 var(--gold), 0 12px 40px rgba(11, 31, 75, 0.08)' }}
>
<div className="mb-8 text-center">
<div className="mb-4 flex justify-center">
<img
src="/brand/jrcc-logo-light.png"
alt="Jewish Russian Community Centre"
className="h-16 w-auto dark:hidden"
className="h-14 w-auto dark:hidden"
/>
<img
src="/brand/jrcc-logo-dark.png"
alt="Jewish Russian Community Centre"
className="h-16 w-auto hidden dark:block"
className="hidden h-14 w-auto dark:block"
/>
</div>
<h1 className="font-display text-xl font-semibold text-foreground">
Photos Admin
</h1>
<p className="text-muted-foreground mt-1">JRCC photo management</p>
<h1 className="font-display text-xl font-semibold text-foreground">Photos Admin</h1>
<p className="mt-1 text-sm text-muted-foreground">JRCC photo management</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-destructive/10 border border-destructive/30 text-destructive px-4 py-3 rounded">
<div className="rounded border border-destructive/30 bg-destructive/10 px-4 py-3 text-destructive" role="alert">
{error}
</div>
)}
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-foreground mb-1"
>
<label htmlFor="username" className="mb-1 block text-sm font-medium text-foreground">
Username
</label>
<input
id="username"
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full px-3 py-2 border border-input rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
className="w-full rounded-md border border-input bg-card px-3 py-2 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-foreground mb-1"
>
<label htmlFor="password" className="mb-1 block text-sm font-medium text-foreground">
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 pr-10 border border-input rounded-md shadow-sm bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
className="w-full rounded-md border border-input bg-card px-3 py-2 pr-10 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
type="button"
@@ -114,7 +124,7 @@ export default function Login() {
className="absolute inset-y-0 right-2 flex items-center text-muted-foreground hover:text-foreground focus:outline-none"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? '🙈' : '👁️'}
{showPassword ? 'Hide' : 'Show'}
</button>
</div>
</div>
@@ -122,9 +132,9 @@ export default function Login() {
<button
type="submit"
disabled={loading}
className="w-full bg-primary text-primary-foreground py-2 px-4 rounded-md hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full rounded-md bg-primary px-4 py-2.5 font-medium text-primary-foreground hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? 'Logging in...' : 'Login'}
{loading ? 'Signing in' : 'Sign in'}
</button>
</form>
</div>
+9
View File
@@ -42,6 +42,15 @@ export default {
DEFAULT: "var(--gold)",
foreground: "var(--gold-foreground)",
},
sidebar: {
DEFAULT: "var(--sidebar)",
foreground: "var(--sidebar-foreground)",
muted: "var(--sidebar-muted)",
accent: "var(--sidebar-accent)",
"accent-foreground": "var(--sidebar-accent-foreground)",
border: "var(--sidebar-border)",
active: "var(--sidebar-active)",
},
},
fontFamily: {
sans: ["var(--font-sans)"],
+42
View File
@@ -0,0 +1,42 @@
# Admin UI/UX review (2026-08)
Snapshot after Chabad Blue chrome parity (navy sidebar, theme toggle, shared tokens).
## What works
- Same brand tokens as viewer (`#0038A8`, gold `#C4A35A`, Rubik/Heebo).
- Light/dark toggle (persisted `punimtag-admin-theme`) + FOUC-safe boot script.
- Navy rail makes admin *read* as JRCC immediately (buttons alone were too weak).
- Skip link, `:focus-visible`, `prefers-reduced-motion`, clearer page titles (no emoji soup).
- Merge people, Auto-Match progress, Identify keyboard nav already shipped.
## Friction (prioritized)
| Priority | Issue | Suggested fix |
|----------|--------|----------------|
| P1 | Nav is long; Maintenance always competing with daily Identify / Auto-Match | Default collapse Maintenance; optional “Favorites” / last-used pins |
| P1 | Identify / Auto-Match / Modify are separate mental models for “faces → people” | Light IA: one **People** hub with tabs (Identify · Auto-Match · Modify) |
| P2 | Dense tables (Manage Users, Approve) still feel like legacy admin | Sticky column headers, denser row actions, bulk bar always visible when selection ≠ 0 |
| P2 | Empty / error states often plain text | Consistent empty cards + primary CTA (“Scan a folder”, “Run Auto-Match”) |
| P2 | iOS hamburger only — no desktop collapse | Optional collapsed icon rail for large monitors |
| P3 | Emoji leftovers in Help / some toasts | Plain language + lucide/SVG icons over time |
| P3 | No axe / WCAG CI | Add `@axe-core/playwright` smoke on login + one workflow page |
| P3 | Viewer still has more `gray-*`/`blue-*` leftovers than admin shell | Viewer token pass (separate PR) |
## Accessibility status
| Area | Admin | Viewer |
|------|-------|--------|
| Light/dark | Yes (this work) | Yes |
| Skip link | Yes | No (gap) |
| Theme control a11y | `aria-label` / `aria-pressed` | Same |
| Systematic axe CI | No | No |
| Form labels / live regions | Partial (login, toasts, some dialogs) | Stronger on gallery controls |
## Whats next (product)
1. **People hub IA** (Identify / Auto-Match / Modify tabs) — biggest daily UX win.
2. **Phase 5 junk reject** (blur/pose) + soak Immich gates with real `match_decisions`.
3. **Cluster-name Identify** (Phase 6 remainder).
4. **axe smoke** in e2e; skip link on viewer.
5. Rotate DEV `ADMIN_PASSWORD` off default `admin`.
+11 -1
View File
@@ -14,7 +14,7 @@ plus Immich-inspired precision gates.
| **Fitted confidence knots** | **Not applied** | Auto-fit still noisy on small DEV set. Legacy empirical curve still in use. |
| **`match_decisions` log** | **Empty** | Need Auto-Match Saves on DEV. |
**Bottom line:** Matching geometry is healthy (same ≪ different). Precision gates from Immich cut false accepts; clustering-first UX and person-merge are still open.
**Bottom line:** Matching geometry is healthy (same ≪ different). Precision gates from Immich cut false accepts; person-merge is live; cluster-first naming still open.
## Phases
@@ -23,6 +23,15 @@ plus Immich-inspired precision gates.
3. **Phase 3** — Quality re-score + calibration fit tooling (don't `--apply` yet).
4. **Phase 4** — Up to 3 trusted refs; best (min) distance.
5. **Immich steals** — max distance 0.50; next-best margin 0.08; detection floor 0.55; auto-accept max distance 0.35.
6. **Person merge** — done (#95).
## What's next
1. Run Auto-Match Saves on DEV so `match_decisions` fills; then revisit calibration `--apply`.
2. Phase 5 blur/pose junk reject.
3. Cluster-name Identify (Phase 6 remainder).
4. Admin People hub IA + a11y axe smoke — `docs/ADMIN_UX_REVIEW.md`.
5. Rotate DEV `ADMIN_PASSWORD` if still the default.
## Immich review — steal vs skip
@@ -33,6 +42,7 @@ plus Immich-inspired precision gates.
| Min detection score | **Done** (new Process jobs) |
| Cluster-first / name cluster | Later |
| Person merge UI | Done (`POST /people/{id}/merge` + Modify People checkboxes) |
| Admin Chabad chrome | Done (navy sidebar + theme toggle; see `docs/ADMIN_UX_REVIEW.md`) |
| InsightFace buffalo | Skip — ArcFace OK |
Immich source is public (`github.com/immich-app/immich`); no local clone required. You already run Immich at `photos.levkin.ca`.
+1 -1
View File
@@ -13,7 +13,7 @@ export class AdminLoginPage {
async signIn(username: string, password: string): Promise<void> {
await this.page.locator('#username').fill(username);
await this.page.locator('#password').fill(password);
await this.page.getByRole('button', { name: /^Login$/i }).click();
await this.page.getByRole('button', { name: /^(Login|Sign in)$/i }).click();
await this.page.waitForURL((url) => !url.pathname.endsWith('/login'), { timeout: 30_000 });
}
}
+2 -2
View File
@@ -45,7 +45,7 @@ test.describe('admin review pages @smoke', () => {
},
);
await page.goto(`${adminBaseUrl}/`);
await expect(page.getByRole('button', { name: 'Logout' })).toBeVisible({
await expect(page.getByRole('button', { name: /Log\s*out/i })).toBeVisible({
timeout: 30_000,
});
});
@@ -75,7 +75,7 @@ test.describe('admin review pages @smoke', () => {
await timings.measure('admin_approve', async () => {
await page.goto(`${adminBaseUrl}/approve-identified`);
await expect(page.getByRole('heading', { name: /Approve Identified/i })).toBeVisible({
await expect(page.getByRole('heading', { name: /Approve Identified|User-identified faces/i })).toBeVisible({
timeout: 20_000,
});
});