- {/* 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 (
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
- Photos Admin
-
-
JRCC photo management
+
Photos Admin
+
JRCC photo management