Merge pull request 'Restyle admin Home (ops, not marketing)' (#99) from feature/admin-home-ops-restyle into master
CI / skip-ci-check (push) Successful in 35s
CI / docker-ci (push) Successful in 32s
CI / secret-scan (push) Successful in 37s
CI / python-lint (push) Successful in 34s
CI / admin-unit (push) Successful in 1m2s
CI / viewer-unit (push) Successful in 1m48s
CI / e2e (push) Successful in 3m1s

This commit was merged in pull request #99.
This commit is contained in:
2026-08-05 13:55:28 -05:00
3 changed files with 158 additions and 245 deletions
+155 -243
View File
@@ -1,265 +1,177 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { photosApi, PhotoSearchResult } from '../api/photos'
import apiClient from '../api/client'
type Shortcut = {
to: string
title: string
blurb: string
featureKey?: string
}
const SHORTCUTS: Shortcut[] = [
{
to: '/scan',
title: 'Scan',
blurb: 'Find new photos on disk',
featureKey: 'scan',
},
{
to: '/process',
title: 'Process',
blurb: 'Detect faces in scanned photos',
featureKey: 'process',
},
{
to: '/people/identify',
title: 'Identify',
blurb: 'Name unknown faces',
featureKey: 'identify_people',
},
{
to: '/people/auto-match',
title: 'Auto-Match',
blurb: 'Review suggested matches',
featureKey: 'auto_match',
},
{
to: '/search',
title: 'Search',
blurb: 'Browse by person, date, or folder',
featureKey: 'search_photos',
},
{
to: '/tags',
title: 'Tag photos',
blurb: 'Organize with tags',
featureKey: 'tag_photos',
},
]
export default function Dashboard() {
const { username: _username } = useAuth()
const { username, hasPermission } = useAuth()
const [samplePhotos, setSamplePhotos] = useState<PhotoSearchResult[]>([])
const [loadingPhotos, setLoadingPhotos] = useState(true)
const shortcuts = SHORTCUTS.filter(
(s) => !s.featureKey || hasPermission(s.featureKey)
)
useEffect(() => {
loadSamplePhotos()
let cancelled = false
;(async () => {
try {
setLoadingPhotos(true)
const result = await photosApi.searchPhotos({
search_type: 'processed',
page: 1,
page_size: 8,
})
if (!cancelled) setSamplePhotos(result.items || [])
} catch {
if (!cancelled) setSamplePhotos([])
} finally {
if (!cancelled) setLoadingPhotos(false)
}
})()
return () => {
cancelled = true
}
}, [])
const loadSamplePhotos = async () => {
try {
setLoadingPhotos(true)
// Try to get some recent photos to display
const result = await photosApi.searchPhotos({
search_type: 'processed',
page: 1,
page_size: 6,
})
setSamplePhotos(result.items || [])
} catch (error) {
console.error('Failed to load sample photos:', error)
setSamplePhotos([])
} finally {
setLoadingPhotos(false)
}
}
const getPhotoImageUrl = (photoId: number): string => {
return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
}
const getPhotoImageUrl = (photoId: number): string =>
`${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
return (
<div className="min-h-screen">
{/* Hero Section */}
<section className="relative py-12 px-4 overflow-hidden" style={{ background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%)' }}>
<div className="max-w-6xl mx-auto relative z-10">
<div className="text-center mb-8">
<h1 className="text-4xl md:text-5xl font-bold mb-4 leading-tight" style={{ color: '#F97316' }}>
Welcome to PunimTag
</h1>
<p className="text-xl md:text-2xl mb-3" style={{ color: '#2563EB' }}>
Your Intelligent Photo Management System
</p>
<p className="text-lg max-w-2xl mx-auto text-muted-foreground">
Organize, identify, and search through your photo collection like never before.
</p>
</div>
</div>
<div className="mx-auto max-w-5xl space-y-10 px-1 py-2">
<header className="space-y-2 border-b border-border pb-6">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-[color:var(--gold)]">
JRCC photo admin
</p>
<h1 className="font-display text-3xl font-semibold tracking-tight text-foreground md:text-4xl">
{username ? `Welcome back, ${username}` : 'Home'}
</h1>
<p className="max-w-xl text-sm text-muted-foreground md:text-base">
Scan, process, and name faces for the community library. Pick a workflow
below same destinations as the sidebar.
</p>
</header>
<section aria-labelledby="home-workflows-heading">
<h2
id="home-workflows-heading"
className="mb-3 text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground"
>
Workflows
</h2>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{shortcuts.map((item) => (
<li key={item.to}>
<Link
to={item.to}
className="group flex h-full flex-col gap-1 rounded-lg border border-border bg-card px-4 py-3 transition-colors hover:border-primary/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="font-medium text-foreground group-hover:text-primary">
{item.title}
</span>
<span className="text-sm text-muted-foreground">{item.blurb}</span>
</Link>
</li>
))}
</ul>
</section>
{/* Feature Showcase 1 - AI Recognition */}
<section className="py-16 px-4 bg-card">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<div className="text-5xl mb-4">🤖</div>
<h2 className="text-4xl font-bold text-foreground mb-4">
Recognize Faces Automatically
</h2>
<p className="text-lg text-muted-foreground mb-6">
Never lose track of who's in your photos again. Our smart system
automatically finds and recognizes faces in all your pictures. Just
tell it who someone is once, and it will find them in thousands of
photos—even from years ago.
</p>
<ul className="space-y-3 text-foreground/80">
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
<span>Automatically finds faces in all your photos</span>
</li>
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
<span>Recognizes the same person across different photos</span>
</li>
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
<span>Works even with photos taken years apart</span>
</li>
</ul>
</div>
<div className="relative">
<div className="bg-muted/40 rounded-2xl p-8 shadow-xl">
<div className="aspect-video bg-card rounded-lg shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">👥</div>
<p className="text-muted-foreground text-sm">
Face recognition in action
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Feature Showcase 2 - Smart Search */}
<section className="py-16 px-4 bg-muted/40">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div className="order-2 md:order-1 relative">
<div className="bg-muted/40 rounded-2xl p-8 shadow-xl">
<div className="aspect-video bg-card rounded-lg shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">🔍</div>
<p className="text-muted-foreground text-sm">
Powerful search interface
</p>
</div>
</div>
</div>
</div>
<div className="order-1 md:order-2">
<div className="text-5xl mb-4">🔍</div>
<h2 className="text-4xl font-bold text-foreground mb-4">
Find Anything, Instantly
</h2>
<p className="text-lg text-muted-foreground mb-6">
Search your entire photo collection by people, dates, tags, or
folders. Our advanced filtering system makes it easy to find
exactly what you're looking for, no matter how large your
collection grows.
</p>
<ul className="space-y-3 text-foreground/80">
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}></span>
<span>Search by person name across all photos</span>
</li>
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}></span>
<span>Filter by date ranges and folders</span>
</li>
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}></span>
<span>Tag-based organization and filtering</span>
</li>
</ul>
</div>
</div>
</div>
</section>
{/* Feature Showcase 3 - Batch Processing */}
<section className="py-16 px-4 bg-card">
<div className="max-w-6xl mx-auto">
<div className="grid md:grid-cols-2 gap-12 items-center">
<div>
<div className="text-5xl mb-4"></div>
<h2 className="text-4xl font-bold text-foreground mb-4">
Process Thousands at Once
</h2>
<p className="text-lg text-muted-foreground mb-6">
Don't let a large photo collection overwhelm you. Our batch
processing system efficiently handles thousands of photos with
real-time progress tracking. Watch as your photos are organized
automatically.
</p>
<ul className="space-y-3 text-foreground/80">
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
<span>Batch face detection and recognition</span>
</li>
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
<span>Real-time progress updates</span>
</li>
<li className="flex items-start gap-3">
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
<span>Background job processing</span>
</li>
</ul>
</div>
<div className="relative">
<div className="bg-muted/40 rounded-2xl p-8 shadow-xl">
<div className="aspect-video bg-card rounded-lg shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">📊</div>
<p className="text-muted-foreground text-sm">
Batch processing dashboard
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Visual Gallery Section */}
<section className="py-16 px-4 bg-card">
<div className="max-w-6xl mx-auto">
<h2 className="text-4xl font-bold text-center text-foreground mb-4">
Organize Your Memories
<section aria-labelledby="home-recent-heading">
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2
id="home-recent-heading"
className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground"
>
Recent processed
</h2>
<p className="text-center text-lg text-muted-foreground mb-12 max-w-2xl mx-auto">
Transform your photo collection into an organized, searchable library
of memories. Find any moment, any person, any time.
</p>
{loadingPhotos ? (
<div className="grid md:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-muted/40 rounded-xl p-6 shadow-md aspect-square flex items-center justify-center animate-pulse"
>
<div className="text-center">
<div className="text-6xl mb-4">📸</div>
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
</div>
))}
</div>
) : samplePhotos.length > 0 ? (
<div className="grid md:grid-cols-3 gap-6">
{samplePhotos.slice(0, 6).map((photo) => (
<div
key={photo.id}
className="bg-muted/40 rounded-xl p-2 shadow-md aspect-square overflow-hidden group hover:shadow-lg transition-shadow"
>
<img
src={getPhotoImageUrl(photo.id)}
alt={photo.filename}
className="w-full h-full object-cover rounded-lg"
loading="lazy"
onError={(e) => {
const target = e.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.error-fallback')) {
const fallback = document.createElement('div')
fallback.className =
'w-full h-full flex items-center justify-center error-fallback'
fallback.innerHTML =
'<div class="text-center"><div class="text-6xl mb-4">📸</div><p class="text-muted-foreground text-sm">Photo</p></div>'
parent.appendChild(fallback)
}
}}
/>
</div>
))}
</div>
) : (
<div className="grid md:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div
key={i}
className="bg-muted/40 rounded-xl p-6 shadow-md aspect-square flex items-center justify-center"
>
<div className="text-center">
<div className="text-6xl mb-4">📸</div>
<p className="text-muted-foreground text-sm">Your photos</p>
</div>
</div>
))}
</div>
{hasPermission('search_photos') && (
<Link
to="/search"
className="text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Open search
</Link>
)}
</div>
{loadingPhotos ? (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="aspect-square animate-pulse rounded-md bg-muted"
aria-hidden
/>
))}
</div>
) : samplePhotos.length > 0 ? (
<ul className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{samplePhotos.slice(0, 8).map((photo) => (
<li
key={photo.id}
className="overflow-hidden rounded-md border border-border bg-muted/30"
>
<img
src={getPhotoImageUrl(photo.id)}
alt={photo.filename || `Photo ${photo.id}`}
className="aspect-square w-full object-cover"
loading="lazy"
/>
</li>
))}
</ul>
) : (
<p className="rounded-lg border border-dashed border-border bg-muted/20 px-4 py-8 text-center text-sm text-muted-foreground">
No processed photos yet. Start with Scan, then Process.
</p>
)}
</section>
</div>
)
+1
View File
@@ -20,6 +20,7 @@ Snapshot after Chabad Blue chrome parity (navy sidebar, theme toggle, shared tok
| 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 | Viewer filter comboboxes lack accessible names (axe `button-name`) | Label each Select trigger; then restore full-page axe on gallery home |
| Done | Admin Home was a SaaS marketing splash (orange/blue hero, emoji placeholders) | Quiet ops home: welcome + workflow links + recent processed strip |
| P3 | Emoji leftovers in Help / some toasts | Plain language + lucide/SVG icons over time |
## Accessibility status
+2 -2
View File
@@ -11,8 +11,8 @@ plus Immich-inspired precision gates.
| **Same vs different embedding distance** | **Yes — working** | Same-person pairs mean **0.44** (p50 0.45); different-person mean **0.84** (p50 0.92). Clear ArcFace separation. |
| **Quality score spread** | **Slightly better** | After Phase 1 fix + Phase 3 re-score: max **0.78** (was ~0.72), stddev still **~0.05**, mean ~**0.62**. Sharpness now real, but size/brightness/contrast still dominate — scores stay clustered. |
| **Quality gate (≥0.5 refs)** | **Useful** | **38** faces below 0.5; all **29** identified faces remain eligible. |
| **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. |
| **Fitted confidence knots** | **Not applied** | Dry-run cliff (~31% @ 0.49 → ~1%) reflects sharp same/diff separation on a tiny identified set — **not** a live bug. Legacy empirical curve still in use. |
| **`match_decisions` log** | **Seeded only** | Seed script filled rows from identified pairs; real Auto-Match Saves still needed before `--apply`. |
**Bottom line:** Matching geometry is healthy (same ≪ different). Precision gates from Immich cut false accepts; person-merge is live; cluster-first naming still open.