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
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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user