Admin Chabad chrome parity + UX review #97
+6
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"],
|
||||
|
||||
@@ -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 |
|
||||
|
||||
## What’s 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`.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user