feat: Add new analysis documents and update installation scripts for backend integration

This commit introduces several new analysis documents, including Auto-Match Load Performance Analysis, Folder Picker Analysis, Monorepo Migration Summary, and various performance analysis documents. Additionally, the installation scripts are updated to reflect changes in backend service paths, ensuring proper integration with the new backend structure. These enhancements provide better documentation and streamline the setup process for users.
This commit is contained in:
Tanya
2025-12-30 15:04:32 -05:00
parent 12c62f1deb
commit 68d280e8f5
140 changed files with 5101 additions and 933 deletions
+55
View File
@@ -0,0 +1,55 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json'],
},
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react-hooks/recommended',
'plugin:@typescript-eslint/recommended',
],
settings: {
react: {
version: 'detect',
},
},
rules: {
'max-len': [
'error',
{
code: 100,
tabWidth: 2,
ignoreUrls: true,
ignoreStrings: true,
ignoreTemplateLiterals: true,
},
],
'react/react-in-jsx-scope': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
}
+57
View File
@@ -0,0 +1,57 @@
# PunimTag Frontend
React + Vite + TypeScript frontend for PunimTag.
## Setup
```bash
cd frontend
npm install
```
## Development
Start the dev server:
```bash
npm run dev
```
The frontend will run on http://localhost:3000
Make sure the backend API is running on http://127.0.0.1:8000
## Default Login
- Username: `admin`
- Password: `admin`
## Features (Phase 1)
- ✅ Login page with JWT authentication
- ✅ Protected routes with auth check
- ✅ Navigation layout (left sidebar + top bar)
- ✅ Dashboard page (placeholder)
- ✅ Search page (placeholder)
- ✅ Identify page (placeholder)
- ✅ Auto-Match page (placeholder)
- ✅ Tags page (placeholder)
- ✅ Settings page (placeholder)
## Project Structure
```
frontend/
├── src/
│ ├── api/ # API client and endpoints
│ ├── components/ # React components
│ ├── hooks/ # Custom React hooks
│ ├── pages/ # Page components
│ ├── App.tsx # Main app component
│ ├── main.tsx # Entry point
│ └── index.css # Tailwind CSS
├── index.html
├── package.json
├── vite.config.ts
└── tailwind.config.js
```
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PunimTag</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6182
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "punimtag-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@tanstack/react-query": "^5.8.4",
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.4.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+141
View File
@@ -0,0 +1,141 @@
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 Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Search from './pages/Search'
import Scan from './pages/Scan'
import Process from './pages/Process'
import Identify from './pages/Identify'
import AutoMatch from './pages/AutoMatch'
import Modify from './pages/Modify'
import Tags from './pages/Tags'
import FacesMaintenance from './pages/FacesMaintenance'
import ApproveIdentified from './pages/ApproveIdentified'
import ManageUsers from './pages/ManageUsers'
import ReportedPhotos from './pages/ReportedPhotos'
import PendingPhotos from './pages/PendingPhotos'
import UserTaggedPhotos from './pages/UserTaggedPhotos'
import ManagePhotos from './pages/ManagePhotos'
import Settings from './pages/Settings'
import Help from './pages/Help'
import Layout from './components/Layout'
import PasswordChangeModal from './components/PasswordChangeModal'
import AdminRoute from './components/AdminRoute'
function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading, passwordChangeRequired } = useAuth()
const [showPasswordModal, setShowPasswordModal] = useState(false)
useEffect(() => {
if (isAuthenticated && passwordChangeRequired) {
setShowPasswordModal(true)
}
}, [isAuthenticated, passwordChangeRequired])
if (isLoading) {
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
return (
<>
{showPasswordModal && (
<PasswordChangeModal
onSuccess={() => {
setShowPasswordModal(false)
}}
/>
)}
{children}
</>
)
}
function AppRoutes() {
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<PrivateRoute>
<Layout />
</PrivateRoute>
}
>
<Route index element={<Dashboard />} />
<Route path="scan" element={<Scan />} />
<Route path="process" element={<Process />} />
<Route path="search" element={<Search />} />
<Route path="identify" element={<Identify />} />
<Route path="auto-match" element={<AutoMatch />} />
<Route path="modify" element={<Modify />} />
<Route path="tags" element={<Tags />} />
<Route path="manage-photos" element={<ManagePhotos />} />
<Route path="faces-maintenance" element={<FacesMaintenance />} />
<Route
path="approve-identified"
element={
<AdminRoute featureKey="user_identified">
<ApproveIdentified />
</AdminRoute>
}
/>
<Route
path="manage-users"
element={
<AdminRoute featureKey="manage_users">
<ManageUsers />
</AdminRoute>
}
/>
<Route
path="reported-photos"
element={
<AdminRoute featureKey="user_reported">
<ReportedPhotos />
</AdminRoute>
}
/>
<Route
path="pending-linkages"
element={
<AdminRoute featureKey="user_tagged">
<UserTaggedPhotos />
</AdminRoute>
}
/>
<Route
path="pending-photos"
element={
<AdminRoute featureKey="user_uploaded">
<PendingPhotos />
</AdminRoute>
}
/>
<Route path="settings" element={<Settings />} />
<Route path="help" element={<Help />} />
</Route>
</Routes>
)
}
function App() {
return (
<AuthProvider>
<DeveloperModeProvider>
<BrowserRouter>
<AppRoutes />
</BrowserRouter>
</DeveloperModeProvider>
</AuthProvider>
)
}
export default App
+65
View File
@@ -0,0 +1,65 @@
import apiClient from './client'
import { UserRoleValue } from './users'
export interface LoginRequest {
username: string
password: string
}
export interface TokenResponse {
access_token: string
refresh_token: string
token_type?: string
password_change_required?: boolean
}
export interface PasswordChangeRequest {
current_password: string
new_password: string
}
export interface PasswordChangeResponse {
success: boolean
message: string
}
export interface UserResponse {
username: string
is_admin?: boolean
role?: UserRoleValue
permissions?: Record<string, boolean>
}
export const authApi = {
login: async (credentials: LoginRequest): Promise<TokenResponse> => {
const { data } = await apiClient.post<TokenResponse>(
'/api/v1/auth/login',
credentials
)
return data
},
refresh: async (refreshToken: string): Promise<TokenResponse> => {
const { data } = await apiClient.post<TokenResponse>(
'/api/v1/auth/refresh',
{ refresh_token: refreshToken }
)
return data
},
me: async (): Promise<UserResponse> => {
const { data } = await apiClient.get<UserResponse>('/api/v1/auth/me')
return data
},
changePassword: async (
request: PasswordChangeRequest
): Promise<PasswordChangeResponse> => {
const { data } = await apiClient.post<PasswordChangeResponse>(
'/api/v1/auth/change-password',
request
)
return data
},
}
+72
View File
@@ -0,0 +1,72 @@
import apiClient from './client'
export interface AuthUserResponse {
id: number
name: string | null
email: string
is_admin: boolean | null
has_write_access: boolean | null
is_active: boolean | null
role: string | null
created_at: string | null
updated_at: string | null
}
export interface AuthUserCreateRequest {
email: string
name: string
password: string
is_admin: boolean
has_write_access: boolean
}
export interface AuthUserUpdateRequest {
email: string
name: string
is_admin: boolean
has_write_access: boolean
is_active?: boolean
role?: string
password?: string
}
export interface AuthUsersListResponse {
items: AuthUserResponse[]
total: number
}
export const authUsersApi = {
listUsers: async (): Promise<AuthUsersListResponse> => {
const { data } = await apiClient.get<AuthUsersListResponse>('/api/v1/auth-users')
return data
},
getUser: async (userId: number): Promise<AuthUserResponse> => {
const { data } = await apiClient.get<AuthUserResponse>(`/api/v1/auth-users/${userId}`)
return data
},
createUser: async (request: AuthUserCreateRequest): Promise<AuthUserResponse> => {
const { data } = await apiClient.post<AuthUserResponse>('/api/v1/auth-users', request)
return data
},
updateUser: async (
userId: number,
request: AuthUserUpdateRequest
): Promise<AuthUserResponse> => {
const { data } = await apiClient.put<AuthUserResponse>(
`/api/v1/auth-users/${userId}`,
request
)
return data
},
deleteUser: async (userId: number): Promise<{ message?: string; deactivated?: boolean }> => {
const response = await apiClient.delete(`/api/v1/auth-users/${userId}`)
// Return data if present (200 OK with deactivation message), otherwise empty object (204 No Content)
return response.data || {}
},
}
+66
View File
@@ -0,0 +1,66 @@
import axios from 'axios'
// Get API base URL from environment variable or use default
// The .env file should contain: VITE_API_URL=http://127.0.0.1:8000
// Alternatively, Vite proxy can be used (configured in vite.config.ts) by setting VITE_API_URL to empty string
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
// Add token to requests
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Handle 401 errors and network errors
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// Handle network errors (no response from server)
if (!error.response && (error.message === 'Network Error' || error.code === 'ERR_NETWORK')) {
// Check if user is logged in
const token = localStorage.getItem('access_token')
if (!token) {
// Not logged in - redirect to login
const isLoginPage = window.location.pathname === '/login'
if (!isLoginPage) {
window.location.href = '/login'
return Promise.reject(error)
}
}
// If logged in but network error, it's a connection issue
console.error('Network Error:', error)
}
// Handle 401 Unauthorized
if (error.response?.status === 401) {
// Don't redirect if we're already on the login page (prevents clearing error messages)
const isLoginPage = window.location.pathname === '/login'
// Always clear tokens on 401, but only redirect if not already on login page
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
// Clear sessionStorage settings on authentication failure
sessionStorage.removeItem('identify_settings')
// Only redirect if not already on login page
if (!isLoginPage) {
window.location.href = '/login'
}
// If on login page, just reject the error so the login component can handle it
}
return Promise.reject(error)
}
)
export default apiClient
+293
View File
@@ -0,0 +1,293 @@
import apiClient from './client'
export interface ProcessFacesRequest {
batch_size?: number
detector_backend: string
model_name: string
}
export interface ProcessFacesResponse {
job_id: string
message: string
batch_size?: number
detector_backend: string
model_name: string
}
export interface FaceItem {
id: number
photo_id: number
quality_score: number
face_confidence: number
location: string
pose_mode?: string
excluded?: boolean
}
export interface UnidentifiedFacesResponse {
items: FaceItem[]
page: number
page_size: number
total: number
}
export interface SimilarFaceItem {
id: number
photo_id: number
similarity: number
location: string
quality_score: number
filename: string
pose_mode?: string
}
export interface SimilarFacesResponse {
base_face_id: number
items: SimilarFaceItem[]
}
export interface FaceSimilarityPair {
face_id_1: number
face_id_2: number
similarity: number // 0-1 range
confidence_pct: number // 0-100 range
}
export interface BatchSimilarityRequest {
face_ids: number[]
min_confidence?: number // 0-100, default 60
}
export interface BatchSimilarityResponse {
pairs: FaceSimilarityPair[]
}
export interface IdentifyFaceRequest {
person_id?: number
first_name?: string
last_name?: string
middle_name?: string
maiden_name?: string
date_of_birth?: string
additional_face_ids?: number[]
}
export interface IdentifyFaceResponse {
identified_face_ids: number[]
person_id: number
created_person: boolean
}
export interface FaceUnmatchResponse {
face_id: number
message: string
}
export interface BatchUnmatchRequest {
face_ids: number[]
}
export interface BatchUnmatchResponse {
unmatched_face_ids: number[]
count: number
message: string
}
export interface AutoMatchRequest {
tolerance: number
auto_accept?: boolean
auto_accept_threshold?: number
}
export interface AutoMatchFaceItem {
id: number
photo_id: number
photo_filename: string
location: string
quality_score: number
similarity: number // Confidence percentage (0-100)
distance: number
pose_mode?: string
}
export interface AutoMatchPersonItem {
person_id: number
person_name: string
reference_face_id: number
reference_photo_id: number
reference_photo_filename: string
reference_location: string
reference_pose_mode?: string
face_count: number
matches: AutoMatchFaceItem[]
total_matches: number
}
export interface AutoMatchPersonSummary {
person_id: number
person_name: string
reference_face_id: number
reference_photo_id: number
reference_photo_filename: string
reference_location: string
reference_pose_mode?: string
face_count: number
total_matches: number
}
export interface AutoMatchPeopleResponse {
people: AutoMatchPersonSummary[]
total_people: number
}
export interface AutoMatchPersonMatchesResponse {
person_id: number
matches: AutoMatchFaceItem[]
total_matches: number
}
export interface AutoMatchResponse {
people: AutoMatchPersonItem[]
total_people: number
total_matches: number
auto_accepted?: boolean
auto_accepted_faces?: number
skipped_persons?: number
skipped_matches?: number
}
export interface AcceptMatchesRequest {
face_ids: number[]
}
export interface MaintenanceFaceItem {
id: number
photo_id: number
photo_path: string
photo_filename: string
quality_score: number
person_id: number | null
person_name: string | null
excluded: boolean
}
export interface MaintenanceFacesResponse {
items: MaintenanceFaceItem[]
total: number
}
export interface DeleteFacesRequest {
face_ids: number[]
}
export interface DeleteFacesResponse {
deleted_face_ids: number[]
count: number
message: string
}
export const facesApi = {
/**
* Start face processing job
*/
processFaces: async (request: ProcessFacesRequest): Promise<ProcessFacesResponse> => {
const response = await apiClient.post<ProcessFacesResponse>('/api/v1/faces/process', request)
return response.data
},
getUnidentified: async (params: {
page?: number
page_size?: number
min_quality?: number
date_from?: string
date_to?: string
date_taken_from?: string
date_taken_to?: string
date_processed?: string
date_processed_from?: string
date_processed_to?: string
sort_by?: 'quality' | 'date_taken' | 'date_added'
sort_dir?: 'asc' | 'desc'
tag_names?: string
match_all?: boolean
photo_ids?: string
include_excluded?: boolean
}): Promise<UnidentifiedFacesResponse> => {
const response = await apiClient.get<UnidentifiedFacesResponse>('/api/v1/faces/unidentified', {
params,
})
return response.data
},
getSimilar: async (faceId: number, includeExcluded?: boolean): Promise<SimilarFacesResponse> => {
const response = await apiClient.get<SimilarFacesResponse>(`/api/v1/faces/${faceId}/similar`, {
params: { include_excluded: includeExcluded || false },
})
return response.data
},
batchSimilarity: async (request: BatchSimilarityRequest): Promise<BatchSimilarityResponse> => {
const response = await apiClient.post<BatchSimilarityResponse>('/api/v1/faces/batch-similarity', request)
return response.data
},
identify: async (faceId: number, payload: IdentifyFaceRequest): Promise<IdentifyFaceResponse> => {
const response = await apiClient.post<IdentifyFaceResponse>(`/api/v1/faces/${faceId}/identify`, payload)
return response.data
},
setExcluded: async (faceId: number, excluded: boolean): Promise<{ face_id: number; excluded: boolean; message: string }> => {
const response = await apiClient.put<{ face_id: number; excluded: boolean; message: string }>(
`/api/v1/faces/${faceId}/excluded?excluded=${excluded}`
)
return response.data
},
unmatch: async (faceId: number): Promise<FaceUnmatchResponse> => {
const response = await apiClient.post<FaceUnmatchResponse>(`/api/v1/faces/${faceId}/unmatch`)
return response.data
},
batchUnmatch: async (payload: BatchUnmatchRequest): Promise<BatchUnmatchResponse> => {
const response = await apiClient.post<BatchUnmatchResponse>('/api/v1/faces/batch-unmatch', payload)
return response.data
},
autoMatch: async (request: AutoMatchRequest): Promise<AutoMatchResponse> => {
const response = await apiClient.post<AutoMatchResponse>('/api/v1/faces/auto-match', request)
return response.data
},
getAutoMatchPeople: async (params?: {
filter_frontal_only?: boolean
}): Promise<AutoMatchPeopleResponse> => {
const response = await apiClient.get<AutoMatchPeopleResponse>('/api/v1/faces/auto-match/people', {
params,
})
return response.data
},
getAutoMatchPersonMatches: async (
personId: number,
params?: {
tolerance?: number
filter_frontal_only?: boolean
}
): Promise<AutoMatchPersonMatchesResponse> => {
const response = await apiClient.get<AutoMatchPersonMatchesResponse>(
`/api/v1/faces/auto-match/people/${personId}/matches`,
{ params }
)
return response.data
},
getMaintenanceFaces: async (params: {
page?: number
page_size?: number
min_quality?: number
max_quality?: number
excluded_filter?: 'all' | 'excluded' | 'included'
identified_filter?: 'all' | 'identified' | 'unidentified'
}): Promise<MaintenanceFacesResponse> => {
const response = await apiClient.get<MaintenanceFacesResponse>('/api/v1/faces/maintenance', {
params,
})
return response.data
},
deleteFaces: async (request: DeleteFacesRequest): Promise<DeleteFacesResponse> => {
const response = await apiClient.post<DeleteFacesResponse>('/api/v1/faces/delete', request)
return response.data
},
}
export default facesApi
+41
View File
@@ -0,0 +1,41 @@
import apiClient from './client'
export enum JobStatus {
PENDING = 'pending',
STARTED = 'started',
PROGRESS = 'progress',
SUCCESS = 'success',
FAILURE = 'failure',
}
export interface JobResponse {
id: string
status: JobStatus
progress: number
message: string
created_at: string
updated_at: string
}
export const jobsApi = {
getJob: async (jobId: string): Promise<JobResponse> => {
const { data } = await apiClient.get<JobResponse>(
`/api/v1/jobs/${jobId}`
)
return data
},
streamJobProgress: (jobId: string): EventSource => {
// EventSource needs absolute URL - use VITE_API_URL or fallback to direct backend URL
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}`)
},
cancelJob: async (jobId: string): Promise<{ message: string; status: string }> => {
const { data } = await apiClient.delete<{ message: string; status: string }>(
`/api/v1/jobs/${jobId}`
)
return data
},
}
@@ -0,0 +1,95 @@
import apiClient from './client'
export interface PendingIdentification {
id: number
face_id: number
photo_id?: number | null
user_id: number
user_name?: string | null
user_email: string
first_name: string
last_name: string
middle_name?: string | null
maiden_name?: string | null
date_of_birth?: string | null
status: string
created_at: string
updated_at: string
}
export interface PendingIdentificationsListResponse {
items: PendingIdentification[]
total: number
}
export interface ApproveDenyDecision {
id: number
decision: 'approve' | 'deny'
}
export interface ApproveDenyRequest {
decisions: ApproveDenyDecision[]
}
export interface ApproveDenyResponse {
approved: number
denied: number
errors: string[]
}
export interface UserIdentificationStats {
user_id: number
username: string
full_name: string
email: string
face_count: number
first_identification_date: string | null
last_identification_date: string | null
}
export interface IdentificationReportResponse {
items: UserIdentificationStats[]
total_faces: number
total_users: number
}
export interface ClearDatabaseResponse {
deleted_records: number
errors: string[]
}
export const pendingIdentificationsApi = {
list: async (includeDenied: boolean = false): Promise<PendingIdentificationsListResponse> => {
const res = await apiClient.get<PendingIdentificationsListResponse>(
'/api/v1/pending-identifications',
{ params: { include_denied: includeDenied } }
)
return res.data
},
approveDeny: async (request: ApproveDenyRequest): Promise<ApproveDenyResponse> => {
const res = await apiClient.post<ApproveDenyResponse>(
'/api/v1/pending-identifications/approve-deny',
request
)
return res.data
},
getReport: async (dateFrom?: string, dateTo?: string): Promise<IdentificationReportResponse> => {
const params: Record<string, string> = {}
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
const res = await apiClient.get<IdentificationReportResponse>(
'/api/v1/pending-identifications/report',
{ params }
)
return res.data
},
clearDenied: async (): Promise<ClearDatabaseResponse> => {
const res = await apiClient.post<ClearDatabaseResponse>(
'/api/v1/pending-identifications/clear-denied'
)
return res.data
},
}
export default pendingIdentificationsApi
+71
View File
@@ -0,0 +1,71 @@
import apiClient from './client'
export interface PendingLinkageResponse {
id: number
photo_id: number
tag_id: number | null
proposed_tag_name: string | null
resolved_tag_name: string | null
user_id: number
user_name: string | null
user_email: string | null
status: string
notes: string | null
created_at: string
updated_at: string | null
photo_filename: string | null
photo_path: string | null
photo_media_type: string | null
photo_tags: string[]
}
export interface PendingLinkagesListResponse {
items: PendingLinkageResponse[]
total: number
}
export interface ReviewDecision {
id: number
decision: 'approve' | 'deny'
}
export interface ReviewRequest {
decisions: ReviewDecision[]
}
export interface ReviewResponse {
approved: number
denied: number
tags_created: number
linkages_created: number
errors: string[]
}
export interface CleanupResponse {
deleted_records: number
errors: string[]
warnings?: string[]
}
export const pendingLinkagesApi = {
async listPendingLinkages(statusFilter?: string): Promise<PendingLinkagesListResponse> {
const { data } = await apiClient.get<PendingLinkagesListResponse>('/api/v1/pending-linkages', {
params: statusFilter ? { status_filter: statusFilter } : undefined,
})
return data
},
async reviewPendingLinkages(request: ReviewRequest): Promise<ReviewResponse> {
const { data } = await apiClient.post<ReviewResponse>('/api/v1/pending-linkages/review', request)
return data
},
async cleanupPendingLinkages(): Promise<CleanupResponse> {
const { data } = await apiClient.post<CleanupResponse>('/api/v1/pending-linkages/cleanup', {})
return data
},
}
export default pendingLinkagesApi
+106
View File
@@ -0,0 +1,106 @@
import apiClient from './client'
export interface PendingPhotoResponse {
id: number
user_id: number
user_name: string | null
user_email: string | null
filename: string
original_filename: string
file_path: string
file_size: number
mime_type: string
status: string
submitted_at: string
reviewed_at: string | null
reviewed_by: number | null
rejection_reason: string | null
}
export interface PendingPhotosListResponse {
items: PendingPhotoResponse[]
total: number
}
export interface ReviewDecision {
id: number
decision: 'approve' | 'reject'
rejection_reason?: string | null
}
export interface ReviewRequest {
decisions: ReviewDecision[]
}
export interface ReviewResponse {
approved: number
rejected: number
errors: string[]
warnings?: string[] // Informational messages (e.g., duplicates)
}
export interface CleanupResponse {
deleted_files: number
deleted_records: number
errors: string[]
warnings?: string[] // Informational messages (e.g., files already deleted)
}
export const pendingPhotosApi = {
listPendingPhotos: async (statusFilter?: string): Promise<PendingPhotosListResponse> => {
const { data } = await apiClient.get<PendingPhotosListResponse>(
'/api/v1/pending-photos',
{
params: statusFilter ? { status_filter: statusFilter } : undefined,
}
)
return data
},
getPendingPhotoImage: (photoId: number): string => {
return `${apiClient.defaults.baseURL}/api/v1/pending-photos/${photoId}/image`
},
getPendingPhotoImageBlob: async (photoId: number): Promise<string> => {
// Fetch image as blob with authentication
const response = await apiClient.get(
`/api/v1/pending-photos/${photoId}/image`,
{
responseType: 'blob',
}
)
// Create object URL from blob
return URL.createObjectURL(response.data)
},
reviewPendingPhotos: async (request: ReviewRequest): Promise<ReviewResponse> => {
const { data } = await apiClient.post<ReviewResponse>(
'/api/v1/pending-photos/review',
request
)
return data
},
cleanupFiles: async (statusFilter?: string): Promise<CleanupResponse> => {
const { data } = await apiClient.post<CleanupResponse>(
'/api/v1/pending-photos/cleanup-files',
{},
{
params: statusFilter ? { status_filter: statusFilter } : undefined,
}
)
return data
},
cleanupDatabase: async (statusFilter?: string): Promise<CleanupResponse> => {
const { data } = await apiClient.post<CleanupResponse>(
'/api/v1/pending-photos/cleanup-database',
{},
{
params: statusFilter ? { status_filter: statusFilter } : undefined,
}
)
return data
},
}
+121
View File
@@ -0,0 +1,121 @@
import apiClient from './client'
export interface Person {
id: number
first_name: string
last_name: string
middle_name?: string | null
maiden_name?: string | null
date_of_birth?: string | null
}
export interface PeopleListResponse {
items: Person[]
total: number
}
export interface PersonWithFaces extends Person {
face_count: number
video_count: number
}
export interface PeopleWithFacesListResponse {
items: PersonWithFaces[]
total: number
}
export interface PersonCreateRequest {
first_name: string
last_name: string
middle_name?: string
maiden_name?: string
date_of_birth?: string | null
}
export interface PersonUpdateRequest {
first_name: string
last_name: string
middle_name?: string
maiden_name?: string
date_of_birth?: string | null
}
export const peopleApi = {
list: async (lastName?: string): Promise<PeopleListResponse> => {
const params = lastName ? { last_name: lastName } : {}
const res = await apiClient.get<PeopleListResponse>('/api/v1/people', { params })
return res.data
},
listWithFaces: async (lastName?: string): Promise<PeopleWithFacesListResponse> => {
const params = lastName ? { last_name: lastName } : {}
const res = await apiClient.get<PeopleWithFacesListResponse>('/api/v1/people/with-faces', { params })
return res.data
},
create: async (payload: PersonCreateRequest): Promise<Person> => {
const res = await apiClient.post<Person>('/api/v1/people', payload)
return res.data
},
update: async (personId: number, payload: PersonUpdateRequest): Promise<Person> => {
const res = await apiClient.put<Person>(`/api/v1/people/${personId}`, payload)
return res.data
},
getFaces: async (personId: number): Promise<PersonFacesResponse> => {
const res = await apiClient.get<PersonFacesResponse>(`/api/v1/people/${personId}/faces`)
return res.data
},
getVideos: async (personId: number): Promise<PersonVideosResponse> => {
const res = await apiClient.get<PersonVideosResponse>(`/api/v1/people/${personId}/videos`)
return res.data
},
acceptMatches: async (personId: number, faceIds: number[]): Promise<IdentifyFaceResponse> => {
const res = await apiClient.post<IdentifyFaceResponse>(`/api/v1/people/${personId}/accept-matches`, { face_ids: faceIds })
return res.data
},
delete: async (personId: number): Promise<void> => {
await apiClient.delete(`/api/v1/people/${personId}`)
},
}
export interface IdentifyFaceResponse {
identified_face_ids: number[]
person_id: number
created_person: boolean
}
export interface PersonFaceItem {
id: number
photo_id: number
photo_path: string
photo_filename: string
location: string
face_confidence: number
quality_score: number
detector_backend: string
model_name: string
}
export interface PersonFacesResponse {
person_id: number
items: PersonFaceItem[]
total: number
}
export interface PersonVideoItem {
id: number
filename: string
path: string
date_taken: string | null
date_added: string
linkage_id: number
}
export interface PersonVideosResponse {
person_id: number
items: PersonVideoItem[]
total: number
}
export default peopleApi
+172
View File
@@ -0,0 +1,172 @@
import apiClient from './client'
export interface PhotoImportRequest {
folder_path: string
recursive?: boolean
}
export interface PhotoImportResponse {
job_id: string
message: string
folder_path?: string
estimated_photos?: number
}
export interface PhotoResponse {
id: number
path: string
filename: string
checksum?: string
date_added: string
date_taken?: string
width?: number
height?: number
mime_type?: string
}
export interface UploadResponse {
message: string
added: number
existing: number
errors: string[]
}
export interface BulkDeletePhotosResponse {
message: string
deleted_count: number
missing_photo_ids: number[]
}
export const photosApi = {
importPhotos: async (
request: PhotoImportRequest
): Promise<PhotoImportResponse> => {
const { data } = await apiClient.post<PhotoImportResponse>(
'/api/v1/photos/import',
request
)
return data
},
uploadPhotos: async (files: File[]): Promise<UploadResponse> => {
const formData = new FormData()
files.forEach((file) => {
formData.append('files', file)
})
const { data } = await apiClient.post<UploadResponse>(
'/api/v1/photos/import/upload',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
}
)
return data
},
getPhoto: async (photoId: number): Promise<PhotoResponse> => {
const { data } = await apiClient.get<PhotoResponse>(
`/api/v1/photos/${photoId}`
)
return data
},
streamJobProgress: (jobId: string): EventSource => {
// EventSource needs absolute URL - use VITE_API_URL or fallback to direct backend URL
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}`)
},
searchPhotos: async (params: {
search_type: 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' | 'favorites'
person_name?: string
tag_names?: string
match_all?: boolean
date_from?: string
date_to?: string
folder_path?: string
page?: number
page_size?: number
}): Promise<SearchPhotosResponse> => {
const { data } = await apiClient.get<SearchPhotosResponse>('/api/v1/photos', {
params,
})
return data
},
toggleFavorite: async (photoId: number): Promise<{ photo_id: number; is_favorite: boolean; message: string }> => {
const { data } = await apiClient.post(
`/api/v1/photos/${photoId}/toggle-favorite`
)
return data
},
checkFavorite: async (photoId: number): Promise<{ photo_id: number; is_favorite: boolean }> => {
const { data } = await apiClient.get(
`/api/v1/photos/${photoId}/is-favorite`
)
return data
},
bulkAddFavorites: async (photoIds: number[]): Promise<{ message: string; added_count: number; already_favorite_count: number; total_requested: number }> => {
const { data } = await apiClient.post(
'/api/v1/photos/bulk-add-favorites',
{ photo_ids: photoIds }
)
return data
},
bulkRemoveFavorites: async (photoIds: number[]): Promise<{ message: string; removed_count: number; not_favorite_count: number; total_requested: number }> => {
const { data } = await apiClient.post(
'/api/v1/photos/bulk-remove-favorites',
{ photo_ids: photoIds }
)
return data
},
bulkDeletePhotos: async (photoIds: number[]): Promise<BulkDeletePhotosResponse> => {
const { data } = await apiClient.post<BulkDeletePhotosResponse>(
'/api/v1/photos/bulk-delete',
{ photo_ids: photoIds }
)
return data
},
openFolder: async (photoId: number): Promise<{ message: string; folder: string }> => {
const { data } = await apiClient.post<{ message: string; folder: string }>(
`/api/v1/photos/${photoId}/open-folder`
)
return data
},
browseFolder: async (): Promise<{ path: string; success: boolean; message?: string }> => {
const { data } = await apiClient.post<{ path: string; success: boolean; message?: string }>(
'/api/v1/photos/browse-folder'
)
return data
},
}
export interface PhotoSearchResult {
id: number
path: string
filename: string
date_taken?: string
date_added: string
processed: boolean
person_name?: string
tags: string[]
has_faces: boolean
face_count: number
is_favorite?: boolean
}
export interface SearchPhotosResponse {
items: PhotoSearchResult[]
page: number
page_size: number
total: number
}
+74
View File
@@ -0,0 +1,74 @@
import apiClient from './client'
export interface ReportedPhotoResponse {
id: number
photo_id: number
user_id: number
user_name: string | null
user_email: string | null
status: string
reported_at: string
reviewed_at: string | null
reviewed_by: number | null
review_notes: string | null
report_comment: string | null
photo_path: string | null
photo_filename: string | null
photo_media_type: string | null
}
export interface ReportedPhotosListResponse {
items: ReportedPhotoResponse[]
total: number
}
export interface ReviewDecision {
id: number
decision: 'keep' | 'remove'
review_notes?: string | null
}
export interface ReviewRequest {
decisions: ReviewDecision[]
}
export interface ReviewResponse {
kept: number
removed: number
errors: string[]
}
export interface ReportedCleanupResponse {
deleted_records: number
errors: string[]
warnings?: string[]
}
export const reportedPhotosApi = {
listReportedPhotos: async (statusFilter?: string): Promise<ReportedPhotosListResponse> => {
const { data } = await apiClient.get<ReportedPhotosListResponse>(
'/api/v1/reported-photos',
{
params: statusFilter ? { status_filter: statusFilter } : undefined,
}
)
return data
},
reviewReportedPhotos: async (request: ReviewRequest): Promise<ReviewResponse> => {
const { data } = await apiClient.post<ReviewResponse>(
'/api/v1/reported-photos/review',
request
)
return data
},
cleanupReportedPhotos: async (): Promise<ReportedCleanupResponse> => {
const { data } = await apiClient.post<ReportedCleanupResponse>(
'/api/v1/reported-photos/cleanup',
{},
)
return data
},
}
+42
View File
@@ -0,0 +1,42 @@
import apiClient from './client'
import { UserRoleValue } from './users'
export interface RoleFeature {
key: string
label: string
}
export type RolePermissionsMap = Record<UserRoleValue, Record<string, boolean>>
export interface RolePermissionsResponse {
features: RoleFeature[]
permissions: RolePermissionsMap
}
export interface RolePermissionsUpdateRequest {
permissions: RolePermissionsMap
}
export const rolePermissionsApi = {
async listPermissions(): Promise<RolePermissionsResponse> {
const { data } = await apiClient.get<RolePermissionsResponse>('/api/v1/role-permissions')
return data
},
async updatePermissions(
request: RolePermissionsUpdateRequest
): Promise<RolePermissionsResponse> {
const { data } = await apiClient.put<RolePermissionsResponse>(
'/api/v1/role-permissions',
request
)
return data
},
}
+118
View File
@@ -0,0 +1,118 @@
import apiClient from './client'
export interface TagResponse {
id: number
tag_name: string
created_date: string
}
export interface TagsResponse {
items: TagResponse[]
total: number
}
export interface PhotoTagsRequest {
photo_ids: number[]
tag_names: string[]
}
export interface PhotoTagsResponse {
message: string
photos_updated: number
tags_added: number
tags_removed: number
}
export interface PhotoTagItem {
tag_id: number
tag_name: string
}
export interface PhotoTagsListResponse {
photo_id: number
tags: PhotoTagItem[]
total: number
}
export interface TagUpdateRequest {
tag_name: string
}
export interface TagDeleteRequest {
tag_ids: number[]
}
export interface PhotoWithTagsItem {
id: number
filename: string
path: string
processed: boolean
date_taken?: string | null
date_added?: string | null
face_count: number
unidentified_face_count: number // Count of faces with person_id IS NULL
tags: string // Comma-separated tags string
people_names: string // Comma-separated people names string
media_type?: string | null // 'image' or 'video'
}
export interface PhotosWithTagsResponse {
items: PhotoWithTagsItem[]
total: number
}
export const tagsApi = {
list: async (): Promise<TagsResponse> => {
const { data } = await apiClient.get<TagsResponse>('/api/v1/tags')
return data
},
create: async (tagName: string): Promise<TagResponse> => {
const { data } = await apiClient.post<TagResponse>('/api/v1/tags', {
tag_name: tagName,
})
return data
},
addToPhotos: async (request: PhotoTagsRequest): Promise<PhotoTagsResponse> => {
const { data } = await apiClient.post<PhotoTagsResponse>(
'/api/v1/tags/photos/add',
request
)
return data
},
removeFromPhotos: async (request: PhotoTagsRequest): Promise<PhotoTagsResponse> => {
const { data } = await apiClient.post<PhotoTagsResponse>(
'/api/v1/tags/photos/remove',
request
)
return data
},
getPhotoTags: async (photoId: number): Promise<PhotoTagsListResponse> => {
const { data } = await apiClient.get<PhotoTagsListResponse>(
`/api/v1/tags/photos/${photoId}`
)
return data
},
update: async (tagId: number, tagName: string): Promise<TagResponse> => {
const { data } = await apiClient.put<TagResponse>(`/api/v1/tags/${tagId}`, {
tag_name: tagName,
})
return data
},
delete: async (tagIds: number[]): Promise<{ message: string; deleted_count: number }> => {
const { data } = await apiClient.post<{ message: string; deleted_count: number }>(
'/api/v1/tags/delete',
{ tag_ids: tagIds }
)
return data
},
getPhotosWithTags: async (): Promise<PhotosWithTagsResponse> => {
const { data } = await apiClient.get<PhotosWithTagsResponse>('/api/v1/tags/photos')
return data
},
}
export default tagsApi
+88
View File
@@ -0,0 +1,88 @@
import apiClient from './client'
export type UserRoleValue =
| 'admin'
| 'manager'
| 'moderator'
| 'reviewer'
| 'editor'
| 'importer'
| 'viewer'
export interface UserResponse {
id: number
username: string
email: string | null
full_name: string | null
is_active: boolean
is_admin: boolean
role?: UserRoleValue | null
created_date: string
last_login: string | null
}
export interface UserCreateRequest {
username: string
password: string
email: string
full_name: string
is_active?: boolean
is_admin?: boolean
role: UserRoleValue
give_frontend_permission?: boolean
}
export interface UserUpdateRequest {
password?: string | null
email: string
full_name: string
is_active?: boolean
is_admin?: boolean
role?: UserRoleValue
give_frontend_permission?: boolean
}
export interface UsersListResponse {
items: UserResponse[]
total: number
}
export const usersApi = {
listUsers: async (params?: {
is_active?: boolean
is_admin?: boolean
}): Promise<UsersListResponse> => {
const { data } = await apiClient.get<UsersListResponse>('/api/v1/users', {
params,
})
return data
},
getUser: async (userId: number): Promise<UserResponse> => {
const { data } = await apiClient.get<UserResponse>(`/api/v1/users/${userId}`)
return data
},
createUser: async (request: UserCreateRequest): Promise<UserResponse> => {
const { data } = await apiClient.post<UserResponse>('/api/v1/users', request)
return data
},
updateUser: async (
userId: number,
request: UserUpdateRequest
): Promise<UserResponse> => {
const { data } = await apiClient.put<UserResponse>(
`/api/v1/users/${userId}`,
request
)
return data
},
deleteUser: async (userId: number): Promise<{ message?: string; deactivated?: boolean }> => {
const response = await apiClient.delete(`/api/v1/users/${userId}`)
// Return data if present (200 OK with deactivation message), otherwise empty object (204 No Content)
return response.data || {}
},
}
+128
View File
@@ -0,0 +1,128 @@
import apiClient from './client'
export interface PersonInfo {
id: number
first_name: string
last_name: string
middle_name?: string | null
maiden_name?: string | null
date_of_birth?: string | null
}
export interface VideoListItem {
id: number
filename: string
path: string
date_taken: string | null
date_added: string
identified_people: PersonInfo[]
identified_people_count: number
}
export interface ListVideosResponse {
items: VideoListItem[]
page: number
page_size: number
total: number
}
export interface VideoPersonInfo {
person_id: number
first_name: string
last_name: string
middle_name?: string | null
maiden_name?: string | null
date_of_birth?: string | null
identified_by: string | null
identified_date: string
}
export interface VideoPeopleResponse {
video_id: number
people: VideoPersonInfo[]
}
export interface IdentifyVideoRequest {
person_id?: number
first_name?: string
last_name?: string
middle_name?: string
maiden_name?: string
date_of_birth?: string | null
}
export interface IdentifyVideoResponse {
video_id: number
person_id: number
created_person: boolean
message: string
}
export interface RemoveVideoPersonResponse {
video_id: number
person_id: number
removed: boolean
message: string
}
export const videosApi = {
listVideos: async (params: {
page?: number
page_size?: number
folder_path?: string
date_from?: string
date_to?: string
has_people?: boolean
person_name?: string
sort_by?: string
sort_dir?: string
}): Promise<ListVideosResponse> => {
const res = await apiClient.get<ListVideosResponse>('/api/v1/videos', { params })
return res.data
},
getVideoPeople: async (videoId: number): Promise<VideoPeopleResponse> => {
const res = await apiClient.get<VideoPeopleResponse>(`/api/v1/videos/${videoId}/people`)
return res.data
},
identifyPerson: async (
videoId: number,
request: IdentifyVideoRequest
): Promise<IdentifyVideoResponse> => {
const res = await apiClient.post<IdentifyVideoResponse>(
`/api/v1/videos/${videoId}/identify`,
request
)
return res.data
},
removePerson: async (
videoId: number,
personId: number
): Promise<RemoveVideoPersonResponse> => {
const res = await apiClient.delete<RemoveVideoPersonResponse>(
`/api/v1/videos/${videoId}/people/${personId}`
)
return res.data
},
getThumbnailUrl: (videoId: number): string => {
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return `${baseURL}/api/v1/videos/${videoId}/thumbnail`
},
getVideoUrl: (videoId: number): string => {
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
return `${baseURL}/api/v1/videos/${videoId}/video`
},
}
export default videosApi
@@ -0,0 +1,30 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
interface AdminRouteProps {
children: React.ReactNode
featureKey?: string
}
export default function AdminRoute({ children, featureKey }: AdminRouteProps) {
const { isAuthenticated, isLoading, isAdmin, hasPermission } = useAuth()
if (isLoading) {
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
if (featureKey) {
if (!hasPermission(featureKey)) {
return <Navigate to="/" replace />
}
} else if (!isAdmin) {
return <Navigate to="/" replace />
}
return <>{children}</>
}
+182
View File
@@ -0,0 +1,182 @@
import { useCallback, useState } from 'react'
import { Outlet, Link, useLocation } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { useInactivityTimeout } from '../hooks/useInactivityTimeout'
const INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000
type NavItem = {
path: string
label: string
icon: string
featureKey?: string
}
export default function Layout() {
const location = useLocation()
const { username, logout, isAuthenticated, hasPermission } = useAuth()
const [maintenanceExpanded, setMaintenanceExpanded] = useState(true)
const handleInactivityLogout = useCallback(() => {
logout()
}, [logout])
useInactivityTimeout({
timeoutMs: INACTIVITY_TIMEOUT_MS,
onTimeout: handleInactivityLogout,
isEnabled: isAuthenticated,
})
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' },
]
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' },
]
const footerNavItems: NavItem[] = [{ path: '/help', label: 'Help', icon: '📚' }]
const filterNavItems = (items: NavItem[]) =>
items.filter((item) => !item.featureKey || hasPermission(item.featureKey))
const renderNavLink = (
item: { path: string; label: string; icon: string },
extraClasses = ''
) => {
const isActive = location.pathname === item.path
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
} ${extraClasses}`}
>
<span>{item.icon}</span>
<span>{item.label}</span>
</Link>
)
}
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'
}
return (
<div className="min-h-screen bg-gray-50">
{/* Top bar */}
<div className="bg-white border-b border-gray-200 shadow-sm">
<div className="flex">
{/* Left sidebar - fixed position with logo */}
<div className="fixed left-0 top-0 w-64 bg-white border-r border-gray-200 h-20 flex items-center justify-center px-4 z-10">
<Link to="/" className="flex items-center justify-center hover:opacity-80 transition-opacity">
<img
src="/logo.png"
alt="PunimTag"
className="h-12 w-auto"
onError={(e) => {
// Fallback if logo.png doesn't exist, try logo.svg
const target = e.target as HTMLImageElement
if (target.src.endsWith('logo.png')) {
target.src = '/logo.svg'
}
}}
/>
</Link>
</div>
{/* Header content - aligned with main content */}
<div className="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 text-gray-900">{getPageTitle()}</h1>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{username}</span>
<button
onClick={logout}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900"
>
Logout
</button>
</div>
</div>
</div>
</div>
</div>
<div className="flex relative">
{/* Left sidebar - fixed position */}
<div className="fixed left-0 top-20 w-64 bg-white border-r border-gray-200 h-[calc(100vh-5rem)] overflow-y-auto">
<nav className="p-4 space-y-1">
{visiblePrimary.map((item) => renderNavLink(item))}
{visibleMaintenance.length > 0 && (
<div className="mt-4">
<button
type="button"
onClick={() => setMaintenanceExpanded((prev) => !prev)}
className="w-full px-3 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 flex items-center justify-between hover:text-gray-700"
>
<span>Maintenance</span>
<span>{maintenanceExpanded ? '▼' : '▶'}</span>
</button>
{maintenanceExpanded && (
<div className="mt-1 space-y-1">
{visibleMaintenance.map((item) => renderNavLink(item, 'ml-4'))}
</div>
)}
</div>
)}
{visibleFooter.length > 0 && (
<div className="mt-4 space-y-1">
{visibleFooter.map((item) => renderNavLink(item))}
</div>
)}
</nav>
</div>
{/* Main content - with left margin to account for fixed sidebar */}
<div className="flex-1 ml-64 p-4">
<Outlet />
</div>
</div>
</div>
)
}
@@ -0,0 +1,129 @@
import { useState } from 'react'
import { authApi } from '../api/auth'
import { useAuth } from '../context/AuthContext'
interface PasswordChangeModalProps {
onSuccess: () => void
}
export default function PasswordChangeModal({ onSuccess }: PasswordChangeModalProps) {
const { clearPasswordChangeRequired } = useAuth()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
// Validation
if (!currentPassword || !newPassword || !confirmPassword) {
setError('All fields are required')
return
}
if (newPassword.length < 6) {
setError('New password must be at least 6 characters')
return
}
if (newPassword !== confirmPassword) {
setError('New passwords do not match')
return
}
if (currentPassword === newPassword) {
setError('New password must be different from current password')
return
}
try {
setLoading(true)
await authApi.changePassword({
current_password: currentPassword,
new_password: newPassword,
})
clearPasswordChangeRequired()
onSuccess()
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to change password')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Change Password Required</h2>
<p className="text-sm text-gray-600 mb-4">
You must change your password before continuing. Please enter your current password
(provided by your administrator) and choose a new password.
</p>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Current Password *
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
New Password * (min 6 characters)
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
minLength={6}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm New Password *
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
required
minLength={6}
/>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,430 @@
import { useEffect, useState, useRef } from 'react'
import { PhotoSearchResult, photosApi } from '../api/photos'
import { apiClient } from '../api/client'
interface PhotoViewerProps {
photos: PhotoSearchResult[]
initialIndex: number
onClose: () => void
}
const ZOOM_MIN = 0.5
const ZOOM_MAX = 5
const ZOOM_STEP = 0.25
const SLIDESHOW_INTERVALS = [
{ value: 1, label: '1s' },
{ value: 2, label: '2s' },
{ value: 3, label: '3s' },
{ value: 5, label: '5s' },
{ value: 10, label: '10s' },
]
export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoViewerProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex)
const [imageLoading, setImageLoading] = useState(true)
const [imageError, setImageError] = useState(false)
const preloadedImages = useRef<Set<number>>(new Set())
// Zoom state
const [zoom, setZoom] = useState(1)
const [panX, setPanX] = useState(0)
const [panY, setPanY] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
const imageContainerRef = useRef<HTMLDivElement>(null)
// Slideshow state
const [isPlaying, setIsPlaying] = useState(false)
const [slideshowInterval, setSlideshowInterval] = useState(3) // seconds
const slideshowTimerRef = useRef<NodeJS.Timeout | null>(null)
// Favorite state
const [isFavorite, setIsFavorite] = useState(false)
const [loadingFavorite, setLoadingFavorite] = useState(false)
const currentPhoto = photos[currentIndex]
const canGoPrev = currentIndex > 0
const canGoNext = currentIndex < photos.length - 1
// Get photo URL
const getPhotoUrl = (photoId: number) => {
return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
}
// Preload adjacent images
const preloadAdjacent = (index: number) => {
// Preload next photo
if (index + 1 < photos.length) {
const nextPhotoId = photos[index + 1].id
if (!preloadedImages.current.has(nextPhotoId)) {
const img = new Image()
img.src = getPhotoUrl(nextPhotoId)
preloadedImages.current.add(nextPhotoId)
}
}
// Preload previous photo
if (index - 1 >= 0) {
const prevPhotoId = photos[index - 1].id
if (!preloadedImages.current.has(prevPhotoId)) {
const img = new Image()
img.src = getPhotoUrl(prevPhotoId)
preloadedImages.current.add(prevPhotoId)
}
}
}
// Handle navigation
const goPrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
// Reset zoom when navigating
setZoom(1)
setPanX(0)
setPanY(0)
}
}
const goNext = () => {
if (currentIndex < photos.length - 1) {
setCurrentIndex(currentIndex + 1)
// Reset zoom when navigating
setZoom(1)
setPanX(0)
setPanY(0)
}
}
// Zoom functions
const zoomIn = () => {
setZoom(prev => Math.min(prev + ZOOM_STEP, ZOOM_MAX))
}
const zoomOut = () => {
setZoom(prev => Math.max(prev - ZOOM_STEP, ZOOM_MIN))
}
const resetZoom = () => {
setZoom(1)
setPanX(0)
setPanY(0)
}
const handleWheel = (e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
// Zoom with Ctrl/Cmd + wheel
e.preventDefault()
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP
setZoom(prev => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, prev + delta)))
}
}
// Pan (drag) functionality
const handleMouseDown = (e: React.MouseEvent) => {
if (zoom > 1) {
setIsDragging(true)
setDragStart({ x: e.clientX - panX, y: e.clientY - panY })
}
}
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging && zoom > 1) {
setPanX(e.clientX - dragStart.x)
setPanY(e.clientY - dragStart.y)
}
}
const handleMouseUp = () => {
setIsDragging(false)
}
// Slideshow functions
const toggleSlideshow = () => {
setIsPlaying(prev => !prev)
}
useEffect(() => {
if (isPlaying) {
slideshowTimerRef.current = setInterval(() => {
setCurrentIndex(prev => {
if (prev < photos.length - 1) {
return prev + 1
} else {
// Loop back to start or stop
setIsPlaying(false)
return prev
}
})
// Reset zoom when slideshow advances
setZoom(1)
setPanX(0)
setPanY(0)
}, slideshowInterval * 1000)
} else {
if (slideshowTimerRef.current) {
clearInterval(slideshowTimerRef.current)
slideshowTimerRef.current = null
}
}
return () => {
if (slideshowTimerRef.current) {
clearInterval(slideshowTimerRef.current)
}
}
}, [isPlaying, slideshowInterval, photos.length])
// Handle image load
useEffect(() => {
if (!currentPhoto) return
setImageLoading(true)
setImageError(false)
// Reset zoom when photo changes
setZoom(1)
setPanX(0)
setPanY(0)
// Load favorite status when photo changes
photosApi.checkFavorite(currentPhoto.id)
.then(result => setIsFavorite(result.is_favorite))
.catch(err => {
console.error('Error checking favorite:', err)
setIsFavorite(false)
})
// Preload adjacent images when current photo changes
preloadAdjacent(currentIndex)
}, [currentIndex, currentPhoto, photos.length])
// Toggle favorite
const toggleFavorite = async () => {
if (loadingFavorite || !currentPhoto) return
setLoadingFavorite(true)
try {
const result = await photosApi.toggleFavorite(currentPhoto.id)
setIsFavorite(result.is_favorite)
} catch (error) {
console.error('Error toggling favorite:', error)
alert('Error updating favorite status')
} finally {
setLoadingFavorite(false)
}
}
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'ArrowLeft' && !isPlaying) {
e.preventDefault()
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
setZoom(1)
setPanX(0)
setPanY(0)
}
} else if (e.key === 'ArrowRight' && !isPlaying) {
e.preventDefault()
if (currentIndex < photos.length - 1) {
setCurrentIndex(currentIndex + 1)
setZoom(1)
setPanX(0)
setPanY(0)
}
} else if (e.key === '+' || e.key === '=') {
e.preventDefault()
setZoom(prev => Math.min(prev + ZOOM_STEP, ZOOM_MAX))
} else if (e.key === '-' || e.key === '_') {
e.preventDefault()
setZoom(prev => Math.max(prev - ZOOM_STEP, ZOOM_MIN))
} else if (e.key === '0') {
e.preventDefault()
setZoom(1)
setPanX(0)
setPanY(0)
} else if (e.key === ' ') {
e.preventDefault()
setIsPlaying(prev => !prev)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentIndex, photos.length, onClose, isPlaying])
if (!currentPhoto) {
return null
}
const photoUrl = getPhotoUrl(currentPhoto.id)
return (
<div className="fixed inset-0 bg-black z-[100] flex flex-col">
{/* Top Left Info Corner */}
<div className="absolute top-0 left-0 z-10 bg-black bg-opacity-70 text-white p-2 rounded-br-lg">
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs"
title="Close (Esc)"
>
</button>
<div className="text-xs">
{currentIndex + 1} / {photos.length}
</div>
{currentPhoto.filename && (
<div className="text-xs text-gray-300 truncate max-w-xs">
{currentPhoto.filename}
</div>
)}
</div>
</div>
{/* Top Right Controls */}
<div className="absolute top-0 right-0 z-10 bg-black bg-opacity-70 text-white p-2 rounded-bl-lg">
<div className="flex items-center gap-2">
{/* Favorite button */}
<button
onClick={toggleFavorite}
disabled={loadingFavorite}
className={`px-3 py-1 rounded text-xs ${
isFavorite
? 'bg-yellow-600 hover:bg-yellow-700'
: 'bg-gray-700 hover:bg-gray-600'
} disabled:opacity-50`}
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
>
{isFavorite ? '⭐' : '☆'}
</button>
{/* Slideshow controls */}
{isPlaying && (
<select
value={slideshowInterval}
onChange={(e) => setSlideshowInterval(Number(e.target.value))}
onClick={(e) => e.stopPropagation()}
className="px-2 py-1 bg-gray-700 rounded text-xs"
title="Slideshow speed"
>
{SLIDESHOW_INTERVALS.map(interval => (
<option key={interval.value} value={interval.value}>
{interval.label}
</option>
))}
</select>
)}
<button
onClick={toggleSlideshow}
className={`px-3 py-1 rounded text-xs ${
isPlaying
? 'bg-red-600 hover:bg-red-700'
: 'bg-green-600 hover:bg-green-700'
}`}
title={isPlaying ? 'Pause slideshow (Space)' : 'Start slideshow (Space)'}
>
{isPlaying ? '⏸' : '▶'}
</button>
</div>
</div>
{/* Main Image Area */}
<div
ref={imageContainerRef}
className="flex-1 flex items-center justify-center relative overflow-hidden"
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
>
{imageLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black z-20">
<div className="text-white text-lg">Loading...</div>
</div>
)}
{imageError ? (
<div className="text-white text-center">
<div className="text-lg mb-2">Failed to load image</div>
<div className="text-sm text-gray-400">{currentPhoto.path}</div>
</div>
) : (
<div
style={{
transform: `translate(${panX}px, ${panY}px) scale(${zoom})`,
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
}}
>
<img
src={photoUrl}
alt={currentPhoto.filename || `Photo ${currentIndex + 1}`}
className="max-w-full max-h-full object-contain"
style={{ userSelect: 'none', pointerEvents: 'none' }}
onLoad={() => setImageLoading(false)}
onError={() => {
setImageLoading(false)
setImageError(true)
}}
draggable={false}
/>
</div>
)}
{/* Zoom Controls */}
<div className="absolute top-12 right-4 z-30 flex flex-col gap-2">
<button
onClick={zoomIn}
disabled={zoom >= ZOOM_MAX}
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
title="Zoom in (Ctrl/Cmd + Wheel)"
>
+
</button>
<div className="px-3 py-1 bg-black bg-opacity-70 text-white rounded text-center text-xs">
{Math.round(zoom * 100)}%
</div>
<button
onClick={zoomOut}
disabled={zoom <= ZOOM_MIN}
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
title="Zoom out (Ctrl/Cmd + Wheel)"
>
</button>
{zoom !== 1 && (
<button
onClick={resetZoom}
className="px-3 py-1 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded text-xs"
title="Reset zoom"
>
Reset
</button>
)}
</div>
{/* Navigation Buttons */}
<button
onClick={goPrev}
disabled={!canGoPrev || isPlaying}
className="absolute left-4 top-1/2 -translate-y-1/2 px-4 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed z-30"
title="Previous (←)"
>
Prev
</button>
<button
onClick={goNext}
disabled={!canGoNext || isPlaying}
className="absolute right-4 top-1/2 -translate-y-1/2 px-4 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed z-30"
title="Next (→)"
>
Next
</button>
</div>
</div>
)
}
+152
View File
@@ -0,0 +1,152 @@
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'
import { authApi, TokenResponse } from '../api/auth'
import { UserRoleValue } from '../api/users'
interface AuthState {
isAuthenticated: boolean
username: string | null
isLoading: boolean
passwordChangeRequired: boolean
isAdmin: boolean
role: UserRoleValue | null
permissions: Record<string, boolean>
}
interface AuthContextType extends AuthState {
login: (username: string, password: string) => Promise<{ success: boolean; error?: string; passwordChangeRequired?: boolean }>
logout: () => void
clearPasswordChangeRequired: () => void
hasPermission: (featureKey: string) => boolean
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const [authState, setAuthState] = useState<AuthState>({
isAuthenticated: false,
username: null,
isLoading: true,
passwordChangeRequired: false,
isAdmin: false,
role: null,
permissions: {},
})
useEffect(() => {
const token = localStorage.getItem('access_token')
if (token) {
authApi
.me()
.then((user) => {
setAuthState({
isAuthenticated: true,
username: user.username,
isLoading: false,
passwordChangeRequired: false,
isAdmin: user.is_admin || false,
role: (user.role as UserRoleValue) || null,
permissions: user.permissions || {},
})
})
.catch(() => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
setAuthState({
isAuthenticated: false,
username: null,
isLoading: false,
passwordChangeRequired: false,
isAdmin: false,
role: null,
permissions: {},
})
})
} else {
setAuthState({
isAuthenticated: false,
username: null,
isLoading: false,
passwordChangeRequired: false,
isAdmin: false,
role: null,
permissions: {},
})
}
}, [])
const login = async (username: string, password: string) => {
try {
const tokens: TokenResponse = await authApi.login({ username, password })
localStorage.setItem('access_token', tokens.access_token)
localStorage.setItem('refresh_token', tokens.refresh_token)
const user = await authApi.me()
const passwordChangeRequired = tokens.password_change_required || false
setAuthState({
isAuthenticated: true,
username: user.username,
isLoading: false,
passwordChangeRequired,
isAdmin: user.is_admin || false,
role: (user.role as UserRoleValue) || null,
permissions: user.permissions || {},
})
return { success: true, passwordChangeRequired }
} catch (error: any) {
return {
success: false,
error: error.response?.data?.detail || 'Login failed',
}
}
}
const clearPasswordChangeRequired = () => {
setAuthState((prev) => ({
...prev,
passwordChangeRequired: false,
}))
}
const logout = () => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
// Clear sessionStorage settings on logout
sessionStorage.removeItem('identify_settings')
setAuthState({
isAuthenticated: false,
username: null,
isLoading: false,
passwordChangeRequired: false,
isAdmin: false,
role: null,
permissions: {},
})
}
const hasPermission = useCallback(
(featureKey: string): boolean => {
if (!featureKey) {
return authState.isAdmin
}
if (authState.isAdmin) {
return true
}
return Boolean(authState.permissions[featureKey])
},
[authState.isAdmin, authState.permissions]
)
return (
<AuthContext.Provider value={{ ...authState, login, logout, clearPasswordChangeRequired, hasPermission }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
@@ -0,0 +1,42 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
interface DeveloperModeContextType {
isDeveloperMode: boolean
setDeveloperMode: (enabled: boolean) => void
}
const DeveloperModeContext = createContext<DeveloperModeContextType | undefined>(undefined)
const STORAGE_KEY = 'punimtag_developer_mode'
export function DeveloperModeProvider({ children }: { children: ReactNode }) {
const [isDeveloperMode, setIsDeveloperMode] = useState<boolean>(false)
// Load from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored !== null) {
setIsDeveloperMode(stored === 'true')
}
}, [])
const setDeveloperMode = (enabled: boolean) => {
setIsDeveloperMode(enabled)
localStorage.setItem(STORAGE_KEY, enabled.toString())
}
return (
<DeveloperModeContext.Provider value={{ isDeveloperMode, setDeveloperMode }}>
{children}
</DeveloperModeContext.Provider>
)
}
export function useDeveloperMode() {
const context = useContext(DeveloperModeContext)
if (context === undefined) {
throw new Error('useDeveloperMode must be used within a DeveloperModeProvider')
}
return context
}
+84
View File
@@ -0,0 +1,84 @@
import { useState, useEffect } from 'react'
import { authApi, TokenResponse } from '../api/auth'
interface AuthState {
isAuthenticated: boolean
username: string | null
isLoading: boolean
}
export function useAuth() {
const [authState, setAuthState] = useState<AuthState>({
isAuthenticated: false,
username: null,
isLoading: true,
})
useEffect(() => {
const token = localStorage.getItem('access_token')
if (token) {
// Verify token by fetching user info
authApi
.me()
.then((user) => {
setAuthState({
isAuthenticated: true,
username: user.username,
isLoading: false,
})
})
.catch(() => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
setAuthState({
isAuthenticated: false,
username: null,
isLoading: false,
})
})
} else {
setAuthState({
isAuthenticated: false,
username: null,
isLoading: false,
})
}
}, [])
const login = async (username: string, password: string) => {
try {
const tokens: TokenResponse = await authApi.login({ username, password })
localStorage.setItem('access_token', tokens.access_token)
localStorage.setItem('refresh_token', tokens.refresh_token)
const user = await authApi.me()
setAuthState({
isAuthenticated: true,
username: user.username,
isLoading: false,
})
return { success: true }
} catch (error: any) {
return {
success: false,
error: error.response?.data?.detail || 'Login failed',
}
}
}
const logout = () => {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
setAuthState({
isAuthenticated: false,
username: null,
isLoading: false,
})
}
return {
...authState,
login,
logout,
}
}
@@ -0,0 +1,68 @@
import { useEffect, useRef } from 'react'
interface UseInactivityTimeoutOptions {
timeoutMs: number
onTimeout: () => void
isEnabled?: boolean
}
const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
'mousemove',
'mousedown',
'keydown',
'scroll',
'touchstart',
'focus',
]
export function useInactivityTimeout({
timeoutMs,
onTimeout,
isEnabled = true,
}: UseInactivityTimeoutOptions) {
const timeoutRef = useRef<number | null>(null)
const callbackRef = useRef(onTimeout)
useEffect(() => {
callbackRef.current = onTimeout
}, [onTimeout])
useEffect(() => {
if (!isEnabled) {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
return
}
const resetTimer = () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current)
}
timeoutRef.current = window.setTimeout(() => {
callbackRef.current()
}, timeoutMs)
}
const handleVisibilityChange = () => {
if (!document.hidden) {
resetTimer()
}
}
resetTimer()
ACTIVITY_EVENTS.forEach((event) => window.addEventListener(event, resetTimer))
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
if (timeoutRef.current) {
window.clearTimeout(timeoutRef.current)
}
ACTIVITY_EVENTS.forEach((event) => window.removeEventListener(event, resetTimer))
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [timeoutMs, isEnabled])
}
+65
View File
@@ -0,0 +1,65 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
}
body {
margin: 0;
min-height: 100vh;
}
/* Custom scrollbar styling for similar faces container */
.similar-faces-scrollable {
/* Firefox */
scrollbar-width: auto;
scrollbar-color: #4B5563 #F3F4F6;
}
.similar-faces-scrollable::-webkit-scrollbar {
/* Chrome, Safari, Edge */
width: 12px;
}
.similar-faces-scrollable::-webkit-scrollbar-track {
background: #F3F4F6;
border-radius: 6px;
}
.similar-faces-scrollable::-webkit-scrollbar-thumb {
background: #4B5563;
border-radius: 6px;
border: 2px solid #F3F4F6;
}
.similar-faces-scrollable::-webkit-scrollbar-thumb:hover {
background: #374151;
}
.role-permissions-scroll {
scrollbar-width: auto;
scrollbar-color: #1d4ed8 #e5e7eb;
}
.role-permissions-scroll::-webkit-scrollbar {
width: 16px;
height: 16px;
background-color: #bfdbfe;
}
.role-permissions-scroll::-webkit-scrollbar-track {
background: #bfdbfe;
border-radius: 8px;
}
.role-permissions-scroll::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%);
border-radius: 8px;
border: 3px solid #bfdbfe;
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App.tsx'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>,
)
@@ -0,0 +1,606 @@
import { useEffect, useState, useCallback } from 'react'
import pendingIdentificationsApi, {
PendingIdentification,
IdentificationReportResponse,
UserIdentificationStats
} from '../api/pendingIdentifications'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
export default function ApproveIdentified() {
const { isAdmin } = useAuth()
const [pendingIdentifications, setPendingIdentifications] = useState<PendingIdentification[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'deny' | null>>({})
const [submitting, setSubmitting] = useState(false)
const [includeDenied, setIncludeDenied] = useState(false)
const [showReport, setShowReport] = useState(false)
const [reportData, setReportData] = useState<IdentificationReportResponse | null>(null)
const [reportLoading, setReportLoading] = useState(false)
const [reportError, setReportError] = useState<string | null>(null)
const [dateFrom, setDateFrom] = useState<string>('')
const [dateTo, setDateTo] = useState<string>('')
const [clearing, setClearing] = useState(false)
const loadPendingIdentifications = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await pendingIdentificationsApi.list(includeDenied)
setPendingIdentifications(response.items)
} catch (err: any) {
let errorMessage = 'Failed to load pending identifications'
if (err.response?.data?.detail) {
errorMessage = err.response.data.detail
} else if (err.message) {
errorMessage = err.message
// Provide more context for network errors
if (err.message === 'Network Error' || err.code === 'ERR_NETWORK') {
errorMessage = `Network Error: Cannot connect to backend API (${apiClient.defaults.baseURL}). Please check:\n1. Backend is running\n2. You are logged in\n3. CORS is configured correctly`
}
}
setError(errorMessage)
console.error('Error loading pending identifications:', err)
} finally {
setLoading(false)
}
}, [includeDenied])
useEffect(() => {
loadPendingIdentifications()
}, [loadPendingIdentifications])
const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return date.toLocaleDateString()
} catch {
return dateString
}
}
const formatName = (pending: PendingIdentification): string => {
const parts = [
pending.first_name,
pending.middle_name,
pending.last_name,
].filter(Boolean)
if (pending.maiden_name) {
parts.push(`(${pending.maiden_name})`)
}
return parts.join(' ')
}
const handleDecisionChange = (id: number, decision: 'approve' | 'deny') => {
setDecisions(prev => {
const currentDecision = prev[id]
// If clicking the same checkbox, deselect it
if (currentDecision === decision) {
const updated = { ...prev }
delete updated[id]
return updated
}
// Otherwise, set the new decision (this will automatically deselect the other)
return {
...prev,
[id]: decision
}
})
}
const handleSubmit = async () => {
// Get all decisions that have been made, for pending or denied items (not approved)
const decisionsList = Object.entries(decisions)
.filter(([id, decision]) => {
const pending = pendingIdentifications.find(p => p.id === parseInt(id))
return decision !== null && pending && pending.status !== 'approved'
})
.map(([id, decision]) => ({
id: parseInt(id),
decision: decision!
}))
if (decisionsList.length === 0) {
alert('Please select Approve or Deny for at least one identification.')
return
}
if (!confirm(`Submit ${decisionsList.length} decision(s)?`)) {
return
}
setSubmitting(true)
try {
const response = await pendingIdentificationsApi.approveDeny({
decisions: decisionsList
})
const message = [
`✅ Approved: ${response.approved}`,
`❌ Denied: ${response.denied}`,
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : ''
].filter(Boolean).join('\n')
alert(message)
if (response.errors.length > 0) {
console.error('Errors:', response.errors)
}
// Reload the list to show updated status
await loadPendingIdentifications()
// Clear decisions
setDecisions({})
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to submit decisions'
alert(`Error: ${errorMessage}`)
console.error('Error submitting decisions:', err)
} finally {
setSubmitting(false)
}
}
const loadReport = useCallback(async () => {
setReportLoading(true)
setReportError(null)
try {
const response = await pendingIdentificationsApi.getReport(
dateFrom || undefined,
dateTo || undefined
)
setReportData(response)
} catch (err: any) {
setReportError(err.response?.data?.detail || err.message || 'Failed to load report')
console.error('Error loading report:', err)
} finally {
setReportLoading(false)
}
}, [dateFrom, dateTo])
const handleOpenReport = () => {
setShowReport(true)
loadReport()
}
const handleCloseReport = () => {
setShowReport(false)
setReportData(null)
setReportError(null)
setDateFrom('')
setDateTo('')
}
const formatDateTime = (dateString: string | null | undefined): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return date.toLocaleString()
} catch {
return dateString
}
}
const handleClearDenied = async () => {
if (!confirm('Are you sure you want to delete all denied records? This action cannot be undone.')) {
return
}
setClearing(true)
try {
const response = await pendingIdentificationsApi.clearDenied()
const message = [
`✅ Deleted ${response.deleted_records} denied record(s)`,
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : ''
].filter(Boolean).join('\n')
alert(message)
if (response.errors.length > 0) {
console.error('Errors:', response.errors)
alert('Errors:\n' + response.errors.join('\n'))
}
// Reload the list to reflect changes
await loadPendingIdentifications()
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to clear denied records'
alert(`Error: ${errorMessage}`)
console.error('Error clearing denied records:', err)
} finally {
setClearing(false)
}
}
return (
<div>
<div className="bg-white rounded-lg shadow p-6">
{loading && (
<div className="text-center py-8">
<p className="text-gray-600">Loading identified people...</p>
</div>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
<p className="font-semibold">Error loading data</p>
<p className="text-sm mt-1">{error}</p>
<button
onClick={loadPendingIdentifications}
className="mt-3 px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Retry
</button>
</div>
)}
{!loading && !error && (
<>
<div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-600">
Total pending identifications: <span className="font-semibold">{pendingIdentifications.length}</span>
</div>
<div className="flex items-center gap-4">
<button
onClick={() => {
if (!isAdmin) {
return
}
handleClearDenied()
}}
disabled={clearing || !isAdmin}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
title={
isAdmin
? 'Delete all denied records from the database'
: 'Only admins can clear denied records'
}
>
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
</button>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={includeDenied}
onChange={(e) => setIncludeDenied(e.target.checked)}
className="w-4 h-4 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm text-gray-700">Include denied</span>
</label>
<button
onClick={handleSubmit}
disabled={submitting || Object.values(decisions).filter(d => d !== null).length === 0}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
>
{submitting ? 'Submitting...' : 'Submit Decisions'}
</button>
</div>
</div>
{pendingIdentifications.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No pending identifications found.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date of Birth
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Face
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Approve
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{pendingIdentifications.map((pending) => {
const isDenied = pending.status === 'denied'
const isApproved = pending.status === 'approved'
return (
<tr key={pending.id} className={`hover:bg-gray-50 ${isDenied ? 'opacity-60 bg-gray-50' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{formatName(pending)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDate(pending.date_of_birth)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{pending.photo_id ? (
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${pending.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${pending.face_id}/crop`}
alt={`Face ${pending.face_id}`}
className="w-16 h-16 object-cover rounded border border-gray-300"
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 = 'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${pending.face_id}`
parent.appendChild(fallback)
}
}}
/>
</div>
) : (
<img
src={`/api/v1/faces/${pending.face_id}/crop`}
alt={`Face ${pending.face_id}`}
className="w-16 h-16 object-cover rounded border border-gray-300"
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 = 'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${pending.face_id}`
parent.appendChild(fallback)
}
}}
/>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{pending.user_name || 'Unknown'}
</div>
<div className="text-sm text-gray-500">
{pending.user_email || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDate(pending.created_at)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{isApproved ? (
<div className="text-sm text-green-600 font-medium">Approved</div>
) : (
<div className="flex flex-col gap-2">
{isDenied && (
<span className="text-xs text-red-600 font-medium">(Denied)</span>
)}
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decisions[pending.id] === 'approve'}
onChange={() => {
const currentDecision = decisions[pending.id]
if (currentDecision === 'approve') {
// Deselect if already selected
handleDecisionChange(pending.id, 'approve')
} else {
// Select approve (this will deselect deny if selected)
handleDecisionChange(pending.id, 'approve')
}
}}
className="w-4 h-4 text-green-600 focus:ring-green-500 rounded"
/>
<span className="text-sm text-gray-700">Approve</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decisions[pending.id] === 'deny'}
onChange={() => {
const currentDecision = decisions[pending.id]
if (currentDecision === 'deny') {
// Deselect if already selected
handleDecisionChange(pending.id, 'deny')
} else {
// Select deny (this will deselect approve if selected)
handleDecisionChange(pending.id, 'deny')
}
}}
className="w-4 h-4 text-red-600 focus:ring-red-500 rounded"
/>
<span className="text-sm text-gray-700">Deny</span>
</label>
</div>
</div>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
{/* Report Modal */}
{showReport && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900">Identification Report</h2>
<button
onClick={handleCloseReport}
className="text-gray-400 hover:text-gray-600 text-2xl font-bold"
>
×
</button>
</div>
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Date From
</label>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">
Date To
</label>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-end">
<button
onClick={loadReport}
disabled={reportLoading}
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
>
{reportLoading ? 'Loading...' : 'Apply Filter'}
</button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{reportLoading && (
<div className="text-center py-8">
<p className="text-gray-600">Loading report...</p>
</div>
)}
{reportError && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
<p className="font-semibold">Error loading report</p>
<p className="text-sm mt-1">{reportError}</p>
</div>
)}
{!reportLoading && !reportError && reportData && (
<>
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded">
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="font-semibold text-gray-700">Total Users:</span>{' '}
<span className="text-gray-900">{reportData.total_users}</span>
</div>
<div>
<span className="font-semibold text-gray-700">Total Faces:</span>{' '}
<span className="text-gray-900">{reportData.total_faces}</span>
</div>
<div>
<span className="font-semibold text-gray-700">Average per User:</span>{' '}
<span className="text-gray-900">
{reportData.total_users > 0
? Math.round((reportData.total_faces / reportData.total_users) * 10) / 10
: 0}
</span>
</div>
</div>
</div>
{reportData.items.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No identifications found for the selected date range.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Faces Identified
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
First Identification
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Identification
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reportData.items.map((stat: UserIdentificationStats) => (
<tr key={stat.user_id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{stat.full_name}
</div>
<div className="text-sm text-gray-500">{stat.username}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">{stat.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-semibold text-gray-900">
{stat.face_count}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDateTime(stat.first_identification_date)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDateTime(stat.last_identification_date)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
</div>
)}
</div>
)
}
+936
View File
@@ -0,0 +1,936 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import facesApi, {
AutoMatchPersonSummary,
AutoMatchFaceItem
} from '../api/faces'
import peopleApi, { Person } from '../api/people'
import { apiClient } from '../api/client'
import { useDeveloperMode } from '../context/DeveloperModeContext'
const DEFAULT_TOLERANCE = 0.6
export default function AutoMatch() {
const { isDeveloperMode } = useDeveloperMode()
const [tolerance, setTolerance] = useState(DEFAULT_TOLERANCE)
const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(70)
const [isActive, setIsActive] = useState(false)
const [people, setPeople] = useState<AutoMatchPersonSummary[]>([])
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonSummary[]>([])
// Store matches separately, keyed by person_id
const [matchesCache, setMatchesCache] = useState<Record<number, AutoMatchFaceItem[]>>({})
const [currentIndex, setCurrentIndex] = useState(0)
const [searchQuery, setSearchQuery] = useState('')
const [allPeople, setAllPeople] = useState<Person[]>([])
const [loadingPeople, setLoadingPeople] = useState(false)
const [showPeopleDropdown, setShowPeopleDropdown] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const [selectedFaces, setSelectedFaces] = useState<Record<number, boolean>>({})
const [originalSelectedFaces, setOriginalSelectedFaces] = useState<Record<number, boolean>>({})
const [busy, setBusy] = useState(false)
const [saving, setSaving] = useState(false)
const [hasNoResults, setHasNoResults] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
// SessionStorage keys for persisting state and settings
const STATE_KEY = 'automatch_state'
const SETTINGS_KEY = 'automatch_settings'
// Track if initial load has happened
const initialLoadRef = useRef(false)
// Track if settings have been loaded from sessionStorage
const [settingsLoaded, setSettingsLoaded] = useState(false)
// Track if state has been restored from sessionStorage
const [stateRestored, setStateRestored] = useState(false)
// Track if initial restoration is complete (prevents reload effects from firing during restoration)
const restorationCompleteRef = useRef(false)
const currentPerson = useMemo(() => {
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
return activePeople[currentIndex]
}, [filteredPeople, people, currentIndex])
const currentMatches = useMemo(() => {
if (!currentPerson) return []
return matchesCache[currentPerson.person_id] || []
}, [currentPerson, matchesCache])
// Check if any matches are selected
const hasSelectedMatches = useMemo(() => {
return currentMatches.some(match => selectedFaces[match.id] === true)
}, [currentMatches, selectedFaces])
// Load matches for a specific person (lazy loading)
const loadPersonMatches = async (personId: number) => {
// Skip if already cached
if (matchesCache[personId]) {
return
}
try {
const response = await facesApi.getAutoMatchPersonMatches(personId, {
tolerance,
filter_frontal_only: false
})
setMatchesCache(prev => ({
...prev,
[personId]: response.matches
}))
// Update total_matches in people list
setPeople(prev => prev.map(p =>
p.person_id === personId
? { ...p, total_matches: response.total_matches }
: p
))
// If no matches found, remove person from list (matching original behavior)
// Original endpoint only returns people who have matches
if (response.total_matches === 0) {
setPeople(prev => {
const removedIndex = prev.findIndex(p => p.person_id === personId)
// Adjust current index if needed
if (removedIndex !== -1) {
setCurrentIndex(currentIdx => {
if (currentIdx >= removedIndex) {
return Math.max(0, currentIdx - 1)
}
return currentIdx
})
}
return prev.filter(p => p.person_id !== personId)
})
setFilteredPeople(prev => prev.filter(p => p.person_id !== personId))
}
} catch (error) {
console.error('Failed to load matches for person:', error)
// Set empty matches on error, and remove person from list
setMatchesCache(prev => ({
...prev,
[personId]: []
}))
// Remove person if matches failed to load (assume no matches)
setPeople(prev => prev.filter(p => p.person_id !== personId))
setFilteredPeople(prev => prev.filter(p => p.person_id !== personId))
}
}
// Shared function for auto-load and refresh (loads people list only - fast)
const loadAutoMatch = async (clearState: boolean = false) => {
if (tolerance < 0 || tolerance > 1) {
return
}
setBusy(true)
setIsRefreshing(true)
try {
// Clear saved state if explicitly requested (Refresh button)
if (clearState) {
sessionStorage.removeItem(STATE_KEY)
setMatchesCache({}) // Clear matches cache
}
// Load people list only (fast - no match calculations)
const response = await facesApi.getAutoMatchPeople({
filter_frontal_only: false
})
if (response.people.length === 0) {
setHasNoResults(true)
setPeople([])
setFilteredPeople([])
setIsActive(false)
setBusy(false)
setIsRefreshing(false)
return
}
setHasNoResults(false)
setPeople(response.people)
setFilteredPeople([])
setCurrentIndex(0)
setSelectedFaces({})
setOriginalSelectedFaces({})
setIsActive(true)
// Load matches for first person immediately
if (response.people.length > 0) {
await loadPersonMatches(response.people[0].person_id)
}
} catch (error) {
console.error('Auto-match failed:', error)
} finally {
setBusy(false)
setIsRefreshing(false)
}
}
// Load settings from sessionStorage on mount
useEffect(() => {
try {
const saved = sessionStorage.getItem(SETTINGS_KEY)
if (saved) {
const settings = JSON.parse(saved)
if (settings.tolerance !== undefined) setTolerance(settings.tolerance)
if (settings.autoAcceptThreshold !== undefined) setAutoAcceptThreshold(settings.autoAcceptThreshold)
}
} catch (error) {
console.error('Error loading settings from sessionStorage:', error)
} finally {
setSettingsLoaded(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Load state from sessionStorage on mount (people, current index, selected faces)
// Note: This effect runs after settings are loaded, so tolerance is already set
useEffect(() => {
if (!settingsLoaded) return // Wait for settings to load first
try {
const saved = sessionStorage.getItem(STATE_KEY)
if (saved) {
const state = JSON.parse(saved)
// Only restore state if tolerance matches (cached state is for current tolerance)
if (state.people && Array.isArray(state.people) && state.people.length > 0 &&
state.tolerance === tolerance) {
setPeople(state.people)
if (state.currentIndex !== undefined) {
setCurrentIndex(Math.min(state.currentIndex, state.people.length - 1))
}
if (state.selectedFaces && typeof state.selectedFaces === 'object') {
setSelectedFaces(state.selectedFaces)
}
if (state.originalSelectedFaces && typeof state.originalSelectedFaces === 'object') {
setOriginalSelectedFaces(state.originalSelectedFaces)
}
if (state.matchesCache && typeof state.matchesCache === 'object') {
setMatchesCache(state.matchesCache)
}
if (state.isActive !== undefined) {
setIsActive(state.isActive)
}
if (state.hasNoResults !== undefined) {
setHasNoResults(state.hasNoResults)
}
// Mark that we restored state, so we don't reload
initialLoadRef.current = true
// Mark restoration as complete after state is restored
setTimeout(() => {
restorationCompleteRef.current = true
}, 50)
} else if (state.tolerance !== undefined && state.tolerance !== tolerance) {
// Tolerance changed, clear old cache
sessionStorage.removeItem(STATE_KEY)
}
}
} catch (error) {
console.error('Error loading state from sessionStorage:', error)
} finally {
setStateRestored(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsLoaded])
// Save state to sessionStorage whenever it changes (but only after initial restore)
useEffect(() => {
if (!stateRestored) return // Don't save during initial restore
try {
const state = {
people,
currentIndex,
selectedFaces,
originalSelectedFaces,
matchesCache,
isActive,
hasNoResults,
tolerance, // Include tolerance to validate cache on restore
}
sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
} catch (error) {
console.error('Error saving state to sessionStorage:', error)
}
}, [people, currentIndex, selectedFaces, originalSelectedFaces, matchesCache, isActive, hasNoResults, tolerance, stateRestored])
// Save state on unmount (when navigating away) - use refs to capture latest values
const peopleRef = useRef(people)
const currentIndexRef = useRef(currentIndex)
const selectedFacesRef = useRef(selectedFaces)
const originalSelectedFacesRef = useRef(originalSelectedFaces)
const matchesCacheRef = useRef(matchesCache)
const isActiveRef = useRef(isActive)
const hasNoResultsRef = useRef(hasNoResults)
const toleranceRef = useRef(tolerance)
// Update refs whenever state changes
useEffect(() => {
peopleRef.current = people
currentIndexRef.current = currentIndex
selectedFacesRef.current = selectedFaces
originalSelectedFacesRef.current = originalSelectedFaces
matchesCacheRef.current = matchesCache
isActiveRef.current = isActive
hasNoResultsRef.current = hasNoResults
toleranceRef.current = tolerance
}, [people, currentIndex, selectedFaces, originalSelectedFaces, matchesCache, isActive, hasNoResults, tolerance])
// Save state on unmount (when navigating away)
useEffect(() => {
return () => {
try {
const state = {
people: peopleRef.current,
currentIndex: currentIndexRef.current,
selectedFaces: selectedFacesRef.current,
originalSelectedFaces: originalSelectedFacesRef.current,
matchesCache: matchesCacheRef.current,
isActive: isActiveRef.current,
hasNoResults: hasNoResultsRef.current,
tolerance: toleranceRef.current, // Include tolerance to validate cache on restore
}
sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
} catch (error) {
console.error('Error saving state on unmount:', error)
}
}
}, [])
// Save settings to sessionStorage whenever they change (but only after initial load)
useEffect(() => {
if (!settingsLoaded) return // Don't save during initial load
try {
const settings = {
tolerance,
autoAcceptThreshold,
}
sessionStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
} catch (error) {
console.error('Error saving settings to sessionStorage:', error)
}
}, [tolerance, autoAcceptThreshold, settingsLoaded])
// Load all people for dropdown
useEffect(() => {
const loadAllPeople = async () => {
try {
setLoadingPeople(true)
const response = await peopleApi.list()
setAllPeople(response.items || [])
} catch (error) {
console.error('Failed to load people:', error)
setAllPeople([])
} finally {
setLoadingPeople(false)
}
}
loadAllPeople()
}, [])
// Initial load on mount (after settings and state are loaded)
useEffect(() => {
if (!initialLoadRef.current && settingsLoaded && stateRestored) {
initialLoadRef.current = true
// Only load if we didn't restore state (no people means we need to load)
if (people.length === 0) {
loadAutoMatch()
// If we're loading fresh, mark restoration as complete immediately
restorationCompleteRef.current = true
} else {
// If state was restored, restorationCompleteRef is already set in the state restoration effect
// But ensure it's set in case state restoration didn't happen
if (!restorationCompleteRef.current) {
setTimeout(() => {
restorationCompleteRef.current = true
}, 50)
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsLoaded, stateRestored])
// Reload when tolerance changes (immediate reload)
// But only if restoration is complete (prevents reload during initial restoration)
useEffect(() => {
if (initialLoadRef.current && restorationCompleteRef.current) {
// Clear matches cache when tolerance changes (matches depend on tolerance)
setMatchesCache({})
loadAutoMatch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tolerance])
// Apply search filter
useEffect(() => {
if (!searchQuery.trim()) {
setFilteredPeople([])
return
}
const query = searchQuery.trim().toLowerCase()
const filtered = people.filter(person => {
// Extract last name from person name (matching desktop logic)
let lastName = ''
if (person.person_name.includes(',')) {
lastName = person.person_name.split(',')[0].trim().toLowerCase()
} else {
const nameParts = person.person_name.trim().split(' ')
if (nameParts.length > 0) {
lastName = nameParts[nameParts.length - 1].toLowerCase()
}
}
return lastName.includes(query)
})
setFilteredPeople(filtered)
setCurrentIndex(0)
}, [searchQuery, people])
const startAutoMatch = async () => {
if (tolerance < 0 || tolerance > 1) {
alert('Please enter a valid tolerance value between 0.0 and 1.0.')
return
}
if (autoAcceptThreshold < 0 || autoAcceptThreshold > 100) {
alert('Please enter a valid auto-accept threshold between 0 and 100.')
return
}
setBusy(true)
try {
const response = await facesApi.autoMatch({
tolerance,
auto_accept: true,
auto_accept_threshold: autoAcceptThreshold
})
// Show summary if auto-accept was performed
if (response.auto_accepted) {
const summary = [
`✅ Auto-matched ${response.auto_accepted_faces || 0} faces`,
response.skipped_persons ? `⚠️ Skipped ${response.skipped_persons} persons (non-frontal reference)` : '',
response.skipped_matches ? `️ Skipped ${response.skipped_matches} matches (didn't meet criteria)` : ''
].filter(Boolean).join('\n')
if (summary) {
alert(summary)
}
// Reload faces after auto-accept to remove auto-accepted faces from the list
// Clear cache to get fresh data after auto-accept
await loadAutoMatch(true)
return
}
if (response.people.length === 0) {
alert('🔍 No similar faces found for auto-identification')
setHasNoResults(true)
setPeople([])
setFilteredPeople([])
setIsActive(false)
setBusy(false)
return
}
setHasNoResults(false)
setPeople(response.people)
setFilteredPeople([])
setCurrentIndex(0)
setSelectedFaces({})
setOriginalSelectedFaces({})
setIsActive(true)
} catch (error) {
console.error('Auto-match failed:', error)
alert('Failed to start auto-match. Please try again.')
} finally {
setBusy(false)
}
}
const handleFaceToggle = (faceId: number) => {
setSelectedFaces(prev => ({
...prev,
[faceId]: !prev[faceId],
}))
}
const selectAll = () => {
const newSelected: Record<number, boolean> = {}
currentMatches.forEach(match => {
newSelected[match.id] = true
})
setSelectedFaces(newSelected)
}
const clearAll = () => {
const newSelected: Record<number, boolean> = {}
currentMatches.forEach(match => {
newSelected[match.id] = false
})
setSelectedFaces(newSelected)
}
const saveChanges = async () => {
if (!currentPerson) return
setSaving(true)
try {
const faceIds = currentMatches
.filter(match => selectedFaces[match.id] === true)
.map(match => match.id)
await peopleApi.acceptMatches(currentPerson.person_id, faceIds)
// Update original selected faces to current state
const newOriginal: Record<number, boolean> = {}
currentMatches.forEach(match => {
newOriginal[match.id] = selectedFaces[match.id] || false
})
setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal }))
alert(`✅ Saved ${faceIds.length} match(es)`)
} catch (error) {
console.error('Save failed:', error)
alert('Failed to save matches. Please try again.')
} finally {
setSaving(false)
}
}
// Load matches when current person changes (lazy loading)
useEffect(() => {
if (currentPerson && restorationCompleteRef.current) {
loadPersonMatches(currentPerson.person_id)
// Preload matches for next person in background
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
if (currentIndex + 1 < activePeople.length) {
const nextPerson = activePeople[currentIndex + 1]
loadPersonMatches(nextPerson.person_id)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentPerson?.person_id, currentIndex])
// Restore selected faces when navigating to a different person
useEffect(() => {
if (currentPerson) {
const matches = matchesCache[currentPerson.person_id] || []
const restored: Record<number, boolean> = {}
matches.forEach(match => {
restored[match.id] = originalSelectedFaces[match.id] || false
})
setSelectedFaces(restored)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache])
const goBack = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
}
}
const goNext = () => {
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
if (currentIndex < activePeople.length - 1) {
setCurrentIndex(currentIndex + 1)
}
}
const clearSearch = () => {
setSearchQuery('')
setFilteredPeople([])
setCurrentIndex(0)
setShowPeopleDropdown(false)
}
const formatPersonName = (person: Person): string => {
const parts: string[] = []
// Last name with comma
if (person.last_name) {
parts.push(`${person.last_name},`)
}
// Middle name between last and first
if (person.middle_name) {
parts.push(person.middle_name)
}
// First name
if (person.first_name) {
parts.push(person.first_name)
}
// Maiden name in parentheses
if (person.maiden_name) {
parts.push(`(${person.maiden_name})`)
}
if (parts.length === 0) {
return person.first_name || person.last_name || 'Unknown'
}
// Format as "Last, Middle First (Maiden)"
return parts.join(' ')
}
const formatFullPersonName = (personId: number): string => {
const person = allPeople.find(p => p.id === personId)
if (!person) {
// Fallback to person_name if person not found in allPeople
const currentPersonData = people.find(p => p.person_id === personId) ||
filteredPeople.find(p => p.person_id === personId)
return currentPersonData?.person_name || 'Unknown'
}
return formatPersonName(person)
}
const handlePersonSelect = (personId: number) => {
const person = allPeople.find(p => p.id === personId)
if (person) {
// Extract last name and set as search query
const lastName = person.last_name || ''
setSearchQuery(lastName)
setShowPeopleDropdown(false)
}
}
// Filter people based on search query for dropdown
const filteredPeopleForDropdown = useMemo(() => {
if (!searchQuery.trim()) {
return allPeople
}
const query = searchQuery.trim().toLowerCase()
return allPeople.filter(person => {
const lastName = (person.last_name || '').toLowerCase()
const firstName = (person.first_name || '').toLowerCase()
const middleName = (person.middle_name || '').toLowerCase()
const fullName = `${lastName}, ${firstName}${middleName ? ` ${middleName}` : ''}`.toLowerCase()
return fullName.includes(query) || lastName.includes(query) || firstName.includes(query)
})
}, [searchQuery, allPeople])
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
searchInputRef.current &&
!searchInputRef.current.contains(event.target as Node)
) {
setShowPeopleDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
const canGoBack = currentIndex > 0
const canGoNext = currentIndex < activePeople.length - 1
return (
<div className="flex flex-col h-full">
{/* Configuration */}
<div className="bg-white rounded-lg shadow p-4 mb-4">
<div className="flex items-center gap-4">
<button
onClick={() => loadAutoMatch(true)}
disabled={busy}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
title="Refresh and start from beginning"
>
{isRefreshing ? 'Refreshing...' : '🔄 Refresh'}
</button>
{isDeveloperMode && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Tolerance:</label>
<input
type="number"
min="0"
max="1"
step="0.1"
value={tolerance}
onChange={(e) => setTolerance(parseFloat(e.target.value) || 0)}
disabled={busy}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<span className="text-xs text-gray-500">(lower = stricter matching)</span>
</div>
)}
<div className="flex items-center gap-2">
<button
onClick={startAutoMatch}
disabled={busy || hasNoResults}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
title={hasNoResults ? 'No matches found. Adjust tolerance or process more photos.' : ''}
>
{busy ? 'Processing...' : hasNoResults ? 'No Matches Available' : '🚀 Run Auto-Match'}
</button>
</div>
{isDeveloperMode && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700">Auto-Accept Threshold:</label>
<input
type="number"
min="0"
max="100"
step="5"
value={autoAcceptThreshold}
onChange={(e) => setAutoAcceptThreshold(parseInt(e.target.value) || 70)}
disabled={busy || hasNoResults}
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
/>
<span className="text-xs text-gray-500">% (min similarity)</span>
</div>
)}
</div>
<div className="mt-2 text-xs text-gray-600 bg-blue-50 border border-blue-200 rounded p-2">
<span className="font-medium"> Auto-Match Criteria:</span> Only faces with similarity higher than 70% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
</div>
</div>
{isActive && (
<>
{/* Main panels */}
<div className="flex-1 grid grid-cols-2 gap-4 mb-4">
{/* Left panel - Identified Person */}
<div className="bg-white rounded-lg shadow p-4 flex flex-col">
<h2 className="text-lg font-semibold mb-4">Identified Person</h2>
{/* Search controls */}
<div className="mb-4">
<div className="flex gap-2 mb-2 relative">
<div className="flex-1 relative">
<input
ref={searchInputRef}
type="text"
placeholder="Type Last Name or Select Person"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setShowPeopleDropdown(true)
}}
onFocus={() => setShowPeopleDropdown(true)}
disabled={people.length === 1 || loadingPeople}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
/>
{showPeopleDropdown && filteredPeopleForDropdown.length > 0 && !loadingPeople && (
<div
ref={dropdownRef}
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded shadow-lg max-h-60 overflow-auto"
>
{filteredPeopleForDropdown.map((person) => (
<div
key={person.id}
onClick={() => handlePersonSelect(person.id)}
className="px-3 py-2 hover:bg-blue-50 cursor-pointer text-sm"
>
{formatPersonName(person)}
</div>
))}
</div>
)}
</div>
<button
onClick={clearSearch}
disabled={people.length === 1}
className="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
>
Clear
</button>
</div>
{people.length === 1 && (
<p className="text-xs text-gray-500">(Search disabled - only one person found)</p>
)}
</div>
{/* Person info */}
{currentPerson && (
<>
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
{isDeveloperMode && (
<div className="text-sm text-gray-600">
Person {currentIndex + 1}
</div>
)}
<div className="space-x-2">
<button
onClick={goBack}
disabled={!canGoBack}
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-400"
>
Prev
</button>
<button
onClick={goNext}
disabled={!canGoNext}
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-400"
>
Next
</button>
</div>
</div>
<p className="font-semibold">👤 Person: {formatFullPersonName(currentPerson.person_id)}</p>
<p className="text-sm text-gray-600">
📁 Photo: {currentPerson.reference_photo_filename}
</p>
{isDeveloperMode && (
<p className="text-sm text-gray-600">
📍 Face location: {currentPerson.reference_location}
</p>
)}
<p className="text-sm text-gray-600">
📊 {currentPerson.face_count} faces already identified
</p>
</div>
{/* Person face image */}
<div className="mb-4 flex justify-center">
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${currentPerson.reference_photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${currentPerson.reference_face_id}/crop`}
alt="Reference face"
className="max-w-[300px] max-h-[300px] rounded border border-gray-300"
/>
</div>
</div>
{/* Save button */}
<div className="flex justify-center">
<button
onClick={saveChanges}
disabled={saving || !hasSelectedMatches}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{saving ? '💾 Saving...' : `💾 Save matches for ${currentPerson.person_name}`}
</button>
</div>
</>
)}
</div>
{/* Right panel - Unidentified Faces */}
<div className="bg-white rounded-lg shadow p-4 flex flex-col">
<h2 className="text-lg font-semibold mb-4">Unidentified Faces to Match</h2>
{/* Select All / Clear All buttons */}
<div className="flex gap-2 mb-4">
<button
onClick={selectAll}
disabled={currentMatches.length === 0}
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
>
Select All
</button>
<button
onClick={clearAll}
disabled={currentMatches.length === 0}
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
>
Clear All
</button>
</div>
{/* Matches grid */}
<div className="flex-1 overflow-y-auto">
{currentMatches.length === 0 ? (
<p className="text-gray-500 text-center py-8">No matches found</p>
) : (
<div className="space-y-2">
{currentMatches.map((match) => (
<div
key={match.id}
className="flex items-center gap-3 p-2 border border-gray-200 rounded hover:bg-gray-50"
>
<input
type="checkbox"
checked={selectedFaces[match.id] || false}
onChange={() => handleFaceToggle(match.id)}
className="w-4 h-4"
/>
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${match.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${match.id}/crop`}
alt="Match face"
className="w-20 h-20 object-cover rounded border border-gray-300"
/>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span
className={`px-2 py-1 rounded text-xs font-semibold ${
match.similarity >= 70
? 'bg-green-100 text-green-800'
: match.similarity >= 60
? 'bg-yellow-100 text-yellow-800'
: 'bg-orange-100 text-orange-800'
}`}
>
{Math.round(match.similarity)}% Match
</span>
</div>
<p className="text-xs text-gray-600">📁 {match.photo_filename}</p>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Navigation controls */}
<div className="flex items-center justify-between bg-white rounded-lg shadow p-4">
<div className="flex gap-2">
<button
onClick={goBack}
disabled={!canGoBack}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
Back
</button>
<button
onClick={goNext}
disabled={!canGoNext}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
Next
</button>
</div>
<div className="text-sm text-gray-600">
{isDeveloperMode && `Person ${currentIndex + 1} `}
{currentPerson && `${currentPerson.total_matches} matches`}
</div>
</div>
</>
)}
</div>
)
}
+296
View File
@@ -0,0 +1,296 @@
import { useEffect, useState } from 'react'
import { useAuth } from '../context/AuthContext'
import { photosApi, PhotoSearchResult } from '../api/photos'
import apiClient from '../api/client'
export default function Dashboard() {
const { username } = useAuth()
const [samplePhotos, setSamplePhotos] = useState<PhotoSearchResult[]>([])
const [loadingPhotos, setLoadingPhotos] = useState(true)
useEffect(() => {
loadSamplePhotos()
}, [])
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`
}
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-gray-600">
Organize, identify, and search through your photo collection like never before.
</p>
</div>
</div>
</section>
{/* Feature Showcase 1 - AI Recognition */}
<section className="py-16 px-4 bg-white">
<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-gray-900 mb-4">
Recognize Faces Automatically
</h2>
<p className="text-lg text-gray-600 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-gray-700">
<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-gray-50 rounded-2xl p-8 shadow-xl">
<div className="aspect-video bg-white rounded-lg shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">👥</div>
<p className="text-gray-500 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-gray-50">
<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-gray-50 rounded-2xl p-8 shadow-xl">
<div className="aspect-video bg-white rounded-lg shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">🔍</div>
<p className="text-gray-500 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-gray-900 mb-4">
Find Anything, Instantly
</h2>
<p className="text-lg text-gray-600 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-gray-700">
<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-white">
<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-gray-900 mb-4">
Process Thousands at Once
</h2>
<p className="text-lg text-gray-600 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-gray-700">
<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-gray-50 rounded-2xl p-8 shadow-xl">
<div className="aspect-video bg-white rounded-lg shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-6xl mb-4">📊</div>
<p className="text-gray-500 text-sm">
Batch processing dashboard
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Visual Gallery Section */}
<section className="py-16 px-4 bg-white">
<div className="max-w-6xl mx-auto">
<h2 className="text-4xl font-bold text-center text-gray-900 mb-4">
Organize Your Memories
</h2>
<p className="text-center text-lg text-gray-600 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-gray-50 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-gray-500 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-gray-50 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-gray-500 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-gray-50 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-gray-500 text-sm">Your photos</p>
</div>
</div>
))}
</div>
)}
</div>
</section>
{/* CTA Section */}
<section className="py-20 px-4 bg-white">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-4xl md:text-5xl font-bold mb-6" style={{ color: '#F97316' }}>
Ready to Get Started?
</h2>
<p className="text-xl mb-8 max-w-2xl mx-auto text-gray-600">
Begin organizing your photo collection today. Use the navigation menu
to explore all the powerful features PunimTag has to offer.
</p>
<div className="flex flex-wrap justify-center gap-4">
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">🗂️</span> Scan Photos
</div>
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">⚙️</span> Process Faces
</div>
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">👤</span> Identify People
</div>
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">🤖</span> Auto-Match
</div>
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
<span className="font-semibold">🔍</span> Search Photos
</div>
</div>
</div>
</section>
</div>
)
}
@@ -0,0 +1,435 @@
import { useEffect, useState, useMemo } from 'react'
import facesApi, { MaintenanceFaceItem } from '../api/faces'
import { apiClient } from '../api/client'
type SortColumn = 'person_name' | 'quality' | 'photo_path' | 'excluded'
type SortDir = 'asc' | 'desc'
type ExcludedFilter = 'all' | 'excluded' | 'included'
type IdentifiedFilter = 'all' | 'identified' | 'unidentified'
export default function FacesMaintenance() {
const [faces, setFaces] = useState<MaintenanceFaceItem[]>([])
const [total, setTotal] = useState(0)
const [pageSize, setPageSize] = useState(50)
const [minQuality, setMinQuality] = useState(0.0)
const [maxQuality, setMaxQuality] = useState(1.0)
const [excludedFilter, setExcludedFilter] = useState<ExcludedFilter>('all')
const [identifiedFilter, setIdentifiedFilter] = useState<IdentifiedFilter>('all')
const [selectedFaces, setSelectedFaces] = useState<Set<number>>(new Set())
const [loading, setLoading] = useState(false)
const [deleting, setDeleting] = useState(false)
const [excluding, setExcluding] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
const [sortDir, setSortDir] = useState<SortDir>('asc')
const loadFaces = async () => {
setLoading(true)
try {
const res = await facesApi.getMaintenanceFaces({
page: 1,
page_size: pageSize,
min_quality: minQuality,
max_quality: maxQuality,
excluded_filter: excludedFilter,
identified_filter: identifiedFilter,
})
setFaces(res.items)
setTotal(res.total)
setSelectedFaces(new Set()) // Clear selection when reloading
} catch (error) {
console.error('Error loading faces:', error)
alert('Error loading faces. Please try again.')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadFaces()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, minQuality, maxQuality, excludedFilter, identifiedFilter])
const toggleSelection = (faceId: number) => {
setSelectedFaces(prev => {
const newSet = new Set(prev)
if (newSet.has(faceId)) {
newSet.delete(faceId)
} else {
newSet.add(faceId)
}
return newSet
})
}
const selectAll = () => {
setSelectedFaces(new Set(sortedFaces.map(f => f.id)))
}
const unselectAll = () => {
setSelectedFaces(new Set())
}
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
} else {
setSortColumn(column)
setSortDir('asc')
}
}
const sortedFaces = useMemo(() => {
if (!sortColumn) return faces
return [...faces].sort((a, b) => {
let aVal: any
let bVal: any
switch (sortColumn) {
case 'person_name':
aVal = a.person_name || 'Unidentified'
bVal = b.person_name || 'Unidentified'
break
case 'quality':
aVal = a.quality_score
bVal = b.quality_score
break
case 'photo_path':
aVal = a.photo_path
bVal = b.photo_path
break
case 'excluded':
aVal = a.excluded ? 1 : 0
bVal = b.excluded ? 1 : 0
break
}
if (typeof aVal === 'string') {
aVal = aVal.toLowerCase()
bVal = bVal.toLowerCase()
}
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1
return 0
})
}, [faces, sortColumn, sortDir])
const handleDelete = async () => {
if (selectedFaces.size === 0) {
alert('Please select at least one face to delete.')
return
}
setShowDeleteConfirm(true)
}
const confirmDelete = async () => {
setShowDeleteConfirm(false)
setDeleting(true)
try {
await facesApi.deleteFaces({
face_ids: Array.from(selectedFaces),
})
// Reload faces after deletion
await loadFaces()
alert(`Successfully deleted ${selectedFaces.size} face(s)`)
} catch (error) {
console.error('Error deleting faces:', error)
alert('Error deleting faces. Please try again.')
} finally {
setDeleting(false)
}
}
const handleExclude = async () => {
if (selectedFaces.size === 0) {
alert('Please select at least one face to exclude.')
return
}
setExcluding(true)
try {
const faceIds = Array.from(selectedFaces)
// Exclude each selected face
await Promise.all(faceIds.map(faceId => facesApi.setExcluded(faceId, true)))
// Reload faces after exclusion
await loadFaces()
alert(`Successfully excluded ${selectedFaces.size} face(s)`)
} catch (error) {
console.error('Error excluding faces:', error)
alert('Error excluding faces. Please try again.')
} finally {
setExcluding(false)
}
}
return (
<div>
{/* Controls */}
<div className="bg-white rounded-lg shadow mb-4 p-4">
<div className="grid grid-cols-5 gap-4">
{/* Quality Range Selector */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Quality Range
</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={1}
step={0.01}
value={minQuality}
onChange={(e) => setMinQuality(parseFloat(e.target.value))}
className="flex-1"
/>
<input
type="range"
min={0}
max={1}
step={0.01}
value={maxQuality}
onChange={(e) => setMaxQuality(parseFloat(e.target.value))}
className="flex-1"
/>
</div>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Min: {(minQuality * 100).toFixed(0)}%</span>
<span>Max: {(maxQuality * 100).toFixed(0)}%</span>
</div>
</div>
{/* Excluded Faces Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Excluded Faces
</label>
<select
value={excludedFilter}
onChange={(e) => setExcludedFilter(e.target.value as ExcludedFilter)}
className="block w-auto border rounded px-2 py-1 text-sm"
>
<option value="all">All</option>
<option value="excluded">Excluded only</option>
<option value="included">Included only</option>
</select>
</div>
{/* Identified Filter */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Identified
</label>
<select
value={identifiedFilter}
onChange={(e) => setIdentifiedFilter(e.target.value as IdentifiedFilter)}
className="block w-auto border rounded px-2 py-1 text-sm"
>
<option value="all">All</option>
<option value="identified">Identified only</option>
<option value="unidentified">Unidentified only</option>
</select>
</div>
{/* Batch Size */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Batch Size
</label>
<select
value={pageSize}
onChange={(e) => setPageSize(parseInt(e.target.value))}
className="block w-auto border rounded px-2 py-1 text-sm"
>
{[25, 50, 100, 200, 500, 1000, 1500, 2000].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</div>
{/* Action Buttons */}
<div className="flex items-end gap-2">
<button
onClick={selectAll}
disabled={faces.length === 0}
className="px-3 py-2 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
>
Select All
</button>
<button
onClick={unselectAll}
disabled={selectedFaces.size === 0}
className="px-3 py-2 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
>
Unselect All
</button>
<button
onClick={handleExclude}
disabled={selectedFaces.size === 0 || excluding}
className="px-3 py-2 text-sm bg-orange-600 text-white rounded hover:bg-orange-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{excluding ? 'Excluding...' : 'Exclude Selected'}
</button>
<button
onClick={handleDelete}
disabled={selectedFaces.size === 0 || deleting}
className="px-3 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{deleting ? 'Deleting...' : 'Delete Selected'}
</button>
</div>
</div>
</div>
{/* Results */}
<div className="bg-white rounded-lg shadow p-4">
<div className="mb-4">
<span className="text-sm font-medium text-gray-700">
Total: {total} face(s)
</span>
{selectedFaces.size > 0 && (
<span className="ml-4 text-sm text-gray-600">
Selected: {selectedFaces.size} face(s)
</span>
)}
</div>
{loading ? (
<div className="text-center py-8 text-gray-500">Loading faces...</div>
) : sortedFaces.length === 0 ? (
<div className="text-center py-8 text-gray-500">No faces found</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left p-2 w-12"></th>
<th className="text-left p-2 w-24">Thumbnail</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('person_name')}
>
Person Name {sortColumn === 'person_name' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('photo_path')}
>
File Path {sortColumn === 'photo_path' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('quality')}
>
Quality {sortColumn === 'quality' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
<th
className="text-left p-2 cursor-pointer hover:bg-gray-50"
onClick={() => handleSort('excluded')}
>
Excluded {sortColumn === 'excluded' && (sortDir === 'asc' ? '↑' : '↓')}
</th>
</tr>
</thead>
<tbody>
{sortedFaces.map((face) => (
<tr key={face.id} className="border-b hover:bg-gray-50">
<td className="p-2">
<input
type="checkbox"
checked={selectedFaces.has(face.id)}
onChange={() => toggleSelection(face.id)}
className="cursor-pointer"
/>
</td>
<td className="p-2">
<div
className="w-20 h-20 bg-gray-100 rounded overflow-hidden flex items-center justify-center relative group cursor-pointer"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${face.photo_id}/image`
window.open(photoUrl, '_blank')
}}
title="Click to open full photo"
>
<img
src={`${apiClient.defaults.baseURL}/api/v1/faces/${face.id}/crop`}
alt={`Face ${face.id}`}
className="max-w-full max-h-full object-contain pointer-events-none"
crossOrigin="anonymous"
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 = 'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${face.id}`
parent.appendChild(fallback)
}
}}
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-opacity pointer-events-none" />
</div>
</td>
<td className="p-2">
{face.person_name || (
<span className="text-gray-400 italic">Unidentified</span>
)}
</td>
<td className="p-2">
<span className="text-blue-600" title={face.photo_path}>
{face.photo_path}
</span>
</td>
<td className="p-2">
{(face.quality_score * 100).toFixed(1)}%
</td>
<td className="p-2">
{face.excluded ? (
<span className="text-red-600 font-medium">Yes</span>
) : (
<span className="text-gray-500">No</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Delete Confirmation Dialog */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<h3 className="text-lg font-bold mb-4">Confirm Delete</h3>
<p className="text-gray-700 mb-6">
Are you sure you want to delete {selectedFaces.size} face(s) from
the database? This action cannot be undone.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
>
Cancel
</button>
<button
onClick={confirmDelete}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+133
View File
@@ -0,0 +1,133 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login, isAuthenticated, isLoading } = useAuth()
const navigate = useNavigate()
useEffect(() => {
// Only redirect if user is already authenticated (e.g., visiting /login while logged in)
// Don't redirect on isLoading changes during login attempts
if (isAuthenticated && !isLoading) {
navigate('/', { replace: true })
}
}, [isAuthenticated, isLoading, navigate])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
e.stopPropagation()
setError('')
setLoading(true)
try {
const result = await login(username, password)
if (result.success) {
navigate('/', { replace: true })
} else {
setError(result.error || 'Login failed')
setLoading(false)
}
} catch (err) {
setError('Login failed')
setLoading(false)
}
}
// Only show loading screen on initial auth check, not during login attempts
if (isLoading && !loading) {
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
<div className="max-w-md w-full">
<div className="bg-white rounded-lg shadow-md p-8">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<img
src="/logo.png"
alt="PunimTag"
className="h-16 w-auto"
onError={(e) => {
// Fallback if logo.png doesn't exist, try logo.svg
const target = e.target as HTMLImageElement
if (target.src.endsWith('logo.png')) {
target.src = '/logo.svg'
}
}}
/>
</div>
<p className="text-gray-600">Photo Management System</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div>
<label
htmlFor="username"
className="block text-sm font-medium text-gray-700 mb-1"
>
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 mb-1"
>
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700 focus:outline-none"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? '🙈' : '👁️'}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
</div>
</div>
)
}
+11
View File
@@ -0,0 +1,11 @@
export default function ManagePhotos() {
return (
<div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-gray-600">Photo management functionality coming soon...</p>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+808
View File
@@ -0,0 +1,808 @@
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { pendingPhotosApi, PendingPhotoResponse, ReviewDecision, CleanupResponse } from '../api/pendingPhotos'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { videosApi } from '../api/videos'
type SortKey = 'photo' | 'uploaded_by' | 'file_info' | 'submitted_at' | 'status'
export default function PendingPhotos() {
const { hasPermission, isAdmin } = useAuth()
const canManageUploads = hasPermission('user_uploaded')
const canRunCleanup = isAdmin
const [pendingPhotos, setPendingPhotos] = useState<PendingPhotoResponse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'reject' | null>>({})
const [rejectionReasons, setRejectionReasons] = useState<Record<number, string>>({})
const [bulkRejectionReason, setBulkRejectionReason] = useState<string>('')
const [submitting, setSubmitting] = useState(false)
const [statusFilter, setStatusFilter] = useState<string>('pending')
const [imageUrls, setImageUrls] = useState<Record<number, string>>({})
const [notification, setNotification] = useState<{
approved: number
rejected: number
warnings: string[]
errors: string[]
} | null>(null)
const imageUrlsRef = useRef<Record<number, string>>({})
const [sortBy, setSortBy] = useState<SortKey>('submitted_at')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
const loadPendingPhotos = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await pendingPhotosApi.listPendingPhotos(
statusFilter || undefined
)
setPendingPhotos(response.items)
// Clear decisions when loading different status
setDecisions({})
setRejectionReasons({})
// Load images as blobs with authentication
const newImageUrls: Record<number, string> = {}
for (const photo of response.items) {
try {
const blobUrl = await pendingPhotosApi.getPendingPhotoImageBlob(photo.id)
newImageUrls[photo.id] = blobUrl
} catch (err) {
console.error(`Failed to load image for photo ${photo.id}:`, err)
}
}
setImageUrls(newImageUrls)
imageUrlsRef.current = newImageUrls
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load pending photos')
console.error('Error loading pending photos:', err)
} finally {
setLoading(false)
}
}, [statusFilter])
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
Object.values(imageUrlsRef.current).forEach((url) => {
URL.revokeObjectURL(url)
})
}
}, [])
useEffect(() => {
loadPendingPhotos()
}, [loadPendingPhotos])
const sortedPendingPhotos = useMemo(() => {
const items = [...pendingPhotos]
const direction = sortDirection === 'asc' ? 1 : -1
const compareStrings = (a: string | null | undefined, b: string | null | undefined) =>
(a || '').localeCompare(b || '', undefined, { sensitivity: 'base' })
items.sort((a, b) => {
if (sortBy === 'photo') {
return (a.id - b.id) * direction
}
if (sortBy === 'uploaded_by') {
const aName = a.user_name || a.user_email || ''
const bName = b.user_name || b.user_email || ''
return compareStrings(aName, bName) * direction
}
if (sortBy === 'file_info') {
return compareStrings(a.original_filename, b.original_filename) * direction
}
if (sortBy === 'submitted_at') {
const aTime = a.submitted_at || ''
const bTime = b.submitted_at || ''
return (aTime < bTime ? -1 : aTime > bTime ? 1 : 0) * direction
}
if (sortBy === 'status') {
return compareStrings(a.status, b.status) * direction
}
return 0
})
return items
}, [pendingPhotos, sortBy, sortDirection])
const toggleSort = (key: SortKey) => {
setSortBy((currentKey) => {
if (currentKey === key) {
setSortDirection((currentDirection) => (currentDirection === 'asc' ? 'desc' : 'asc'))
return currentKey
}
setSortDirection('asc')
return key
})
}
const renderSortLabel = (label: string, key: SortKey) => {
const isActive = sortBy === key
const directionSymbol = !isActive ? '↕' : sortDirection === 'asc' ? '▲' : '▼'
return (
<button
type="button"
onClick={() => toggleSort(key)}
className="inline-flex items-center gap-1 text-xs font-medium text-gray-500 uppercase tracking-wider hover:text-gray-700"
>
<span>{label}</span>
<span className="text-[10px]">{directionSymbol}</span>
</button>
)
}
const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return date.toLocaleString()
} catch {
return dateString
}
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
}
const handleDecisionChange = (id: number, decision: 'approve' | 'reject') => {
const currentDecision = decisions[id]
const isUnselecting = currentDecision === decision
setDecisions((prev) => {
// If clicking the same option, unselect it
if (prev[id] === decision) {
const updated = { ...prev }
delete updated[id]
return updated
}
// Otherwise, set the new decision (this automatically unchecks the other checkbox)
return {
...prev,
[id]: decision,
}
})
// Handle rejection reasons
if (isUnselecting) {
// Unselecting - clear rejection reason
setRejectionReasons((prev) => {
const updated = { ...prev }
delete updated[id]
return updated
})
} else if (decision === 'approve') {
// Switching to approve - clear rejection reason
setRejectionReasons((prev) => {
const updated = { ...prev }
delete updated[id]
return updated
})
} else if (decision === 'reject' && bulkRejectionReason.trim()) {
// Switching to reject - apply bulk rejection reason if set
setRejectionReasons((prev) => ({
...prev,
[id]: bulkRejectionReason,
}))
}
}
const handleRejectionReasonChange = (id: number, reason: string) => {
setRejectionReasons((prev) => ({
...prev,
[id]: reason,
}))
}
const handleSelectAllApprove = () => {
const pendingPhotoIds = pendingPhotos
.filter((photo) => photo.status === 'pending')
.map((photo) => photo.id)
const newDecisions: Record<number, 'approve'> = {}
pendingPhotoIds.forEach((id) => {
newDecisions[id] = 'approve'
})
setDecisions((prev) => ({
...prev,
...newDecisions,
}))
// Clear all rejection reasons and bulk rejection reason since we're approving
setRejectionReasons({})
setBulkRejectionReason('')
}
const handleSelectAllReject = () => {
const pendingPhotoIds = pendingPhotos
.filter((photo) => photo.status === 'pending')
.map((photo) => photo.id)
const newDecisions: Record<number, 'reject'> = {}
pendingPhotoIds.forEach((id) => {
newDecisions[id] = 'reject'
})
setDecisions((prev) => ({
...prev,
...newDecisions,
}))
// Apply bulk rejection reason if set
if (bulkRejectionReason.trim()) {
const newRejectionReasons: Record<number, string> = {}
pendingPhotoIds.forEach((id) => {
newRejectionReasons[id] = bulkRejectionReason
})
setRejectionReasons((prev) => ({
...prev,
...newRejectionReasons,
}))
}
}
const handleBulkRejectionReasonChange = (reason: string) => {
setBulkRejectionReason(reason)
// Apply to all currently rejected photos
const rejectedPhotoIds = Object.entries(decisions)
.filter(([id, decision]) => decision === 'reject')
.map(([id]) => parseInt(id))
if (rejectedPhotoIds.length > 0) {
const newRejectionReasons: Record<number, string> = {}
rejectedPhotoIds.forEach((id) => {
newRejectionReasons[id] = reason
})
setRejectionReasons((prev) => ({
...prev,
...newRejectionReasons,
}))
}
}
const handleSubmit = async () => {
// Get all decisions that have been made for pending items
const decisionsList: ReviewDecision[] = Object.entries(decisions)
.filter(([id, decision]) => {
const photo = pendingPhotos.find((p) => p.id === parseInt(id))
return decision !== null && photo && photo.status === 'pending'
})
.map(([id, decision]) => ({
id: parseInt(id),
decision: decision!,
rejection_reason: decision === 'reject' ? (rejectionReasons[parseInt(id)] || null) : null,
}))
if (decisionsList.length === 0) {
alert('Please select Approve or Reject for at least one pending photo.')
return
}
// Show confirmation
const approveCount = decisionsList.filter((d) => d.decision === 'approve').length
const rejectCount = decisionsList.filter((d) => d.decision === 'reject').length
const confirmMessage = `Submit ${decisionsList.length} decision(s)?\n\nThis will approve ${approveCount} photo(s) and reject ${rejectCount} photo(s).`
if (!confirm(confirmMessage)) {
return
}
setSubmitting(true)
try {
const response = await pendingPhotosApi.reviewPendingPhotos({
decisions: decisionsList,
})
// Show custom notification instead of alert
setNotification({
approved: response.approved,
rejected: response.rejected,
warnings: response.warnings || [],
errors: response.errors,
})
if (response.errors.length > 0) {
console.error('Errors:', response.errors)
}
if (response.warnings && response.warnings.length > 0) {
console.info('Warnings:', response.warnings)
}
// Reload the list to show updated status
await loadPendingPhotos()
// Clear decisions and reasons
setDecisions({})
setRejectionReasons({})
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to submit decisions'
alert(`Error: ${errorMessage}`)
console.error('Error submitting decisions:', err)
} finally {
setSubmitting(false)
}
}
const handleCleanupFiles = async (statusFilter?: string) => {
const confirmMessage = statusFilter
? `Delete files from shared space for ${statusFilter} photos? This cannot be undone.`
: 'Delete files from shared space for all approved/rejected photos? This cannot be undone.'
if (!confirm(confirmMessage)) {
return
}
try {
const response: CleanupResponse = await pendingPhotosApi.cleanupFiles(statusFilter)
const message = [
`✅ Deleted ${response.deleted_files} file(s) from shared space`,
response.warnings && response.warnings.length > 0
? `${response.warnings.length} file(s) were already deleted`
: '',
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : '',
]
.filter(Boolean)
.join('\n')
alert(message)
if (response.warnings && response.warnings.length > 0) {
console.info('Cleanup warnings:', response.warnings)
}
if (response.errors.length > 0) {
console.error('Cleanup errors:', response.errors)
}
// Reload the list
await loadPendingPhotos()
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to cleanup files'
alert(`Error: ${errorMessage}`)
console.error('Error cleaning up files:', err)
}
}
const handleCleanupDatabase = async (statusFilter?: string) => {
const confirmMessage = statusFilter
? `Delete all ${statusFilter} records from pending_photos table? This cannot be undone.`
: 'Delete all approved and rejected records from pending_photos table? Pending records will be kept. This cannot be undone.'
if (!confirm(confirmMessage)) {
return
}
try {
const response: CleanupResponse = await pendingPhotosApi.cleanupDatabase(statusFilter)
const message = [
`✅ Deleted ${response.deleted_records} record(s) from database`,
response.warnings && response.warnings.length > 0
? `${response.warnings.join(', ')}`
: '',
response.errors.length > 0
? `⚠️ Errors: ${response.errors.join('; ')}`
: '',
]
.filter(Boolean)
.join('\n')
alert(message)
if (response.warnings && response.warnings.length > 0) {
console.info('Cleanup warnings:', response.warnings)
}
if (response.errors.length > 0) {
console.error('Cleanup errors:', response.errors)
}
// Reload the list
await loadPendingPhotos()
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to cleanup database'
alert(`Error: ${errorMessage}`)
console.error('Error cleaning up database:', err)
}
}
return (
<div>
{/* Notification */}
{notification && (
<div className="mb-4 bg-white border border-gray-200 rounded-lg shadow-lg p-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-green-600 text-lg"></span>
<span className="font-medium">Approved: {notification.approved}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-red-600 text-lg"></span>
<span className="font-medium">Rejected: {notification.rejected}</span>
</div>
{notification.warnings.length > 0 && (
<div className="text-xs text-gray-600 ml-7">
{notification.warnings.join(', ')}
</div>
)}
{notification.errors.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-yellow-600 text-lg"></span>
<span className="font-medium">Errors: {notification.errors.length}</span>
</div>
)}
</div>
<button
onClick={() => setNotification(null)}
className="mt-3 px-3 py-1.5 text-sm text-gray-600 bg-gray-50 rounded hover:bg-gray-100 hover:text-gray-700 transition-colors"
>
Dismiss
</button>
</div>
)}
<div className="bg-white rounded-lg shadow p-6">
{loading && (
<div className="text-center py-8">
<p className="text-gray-600">Loading pending photos...</p>
</div>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
<p className="font-semibold">Error loading data</p>
<p className="text-sm mt-1">{error}</p>
<button
onClick={loadPendingPhotos}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Retry
</button>
</div>
)}
{!loading && !error && (
<>
<div className="mb-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-600">
Total photos: <span className="font-semibold">{pendingPhotos.length}</span>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div>
<div className="flex items-center gap-2">
{canManageUploads && (
<>
<button
onClick={() => {
if (!canRunCleanup) {
return
}
handleCleanupFiles()
}}
disabled={!canRunCleanup}
className="px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
title={
canRunCleanup
? 'Delete files from shared space for approved/rejected photos'
: 'Cleanup files is restricted to admins'
}
>
🗑 Cleanup Files
</button>
<button
onClick={() => {
if (!canRunCleanup) {
return
}
handleCleanupDatabase()
}}
disabled={!canRunCleanup}
className="px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
title={
canRunCleanup
? 'Delete approved and rejected records from pending_photos table (pending records will be kept)'
: 'Clear database is restricted to admins'
}
>
🗑 Clear Database
</button>
</>
)}
{pendingPhotos.filter((p) => p.status === 'pending').length > 0 && (
<>
<button
onClick={handleSelectAllApprove}
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Approve
</button>
<button
onClick={handleSelectAllReject}
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Reject
</button>
</>
)}
<button
onClick={handleSubmit}
disabled={
submitting ||
Object.values(decisions).filter((d) => d !== null).length === 0
}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
>
{submitting ? 'Submitting...' : 'Submit Decisions'}
</button>
</div>
</div>
{Object.values(decisions).some((d) => d === 'reject') && (
<div className="flex items-center gap-2">
<label className="text-sm text-gray-700 font-medium whitespace-nowrap">
Bulk Rejection Reason:
</label>
<textarea
value={bulkRejectionReason}
onChange={(e) => handleBulkRejectionReasonChange(e.target.value)}
placeholder="Enter rejection reason to apply to all rejected photos..."
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md resize-none focus:ring-blue-500 focus:border-blue-500"
rows={2}
/>
</div>
)}
</div>
{pendingPhotos.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No pending photos found.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left">
{renderSortLabel('Photo', 'photo')}
</th>
<th className="px-6 py-3 text-left">
{renderSortLabel('Uploaded By', 'uploaded_by')}
</th>
<th className="px-6 py-3 text-left">
{renderSortLabel('File Info', 'file_info')}
</th>
<th className="px-6 py-3 text-left">
{renderSortLabel('Submitted At', 'submitted_at')}
</th>
<th className="px-6 py-3 text-left">
{renderSortLabel('Status', 'status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Decision
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Rejection Reason
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedPendingPhotos.map((photo) => {
const isPending = photo.status === 'pending'
const isApproved = photo.status === 'approved'
const isRejected = photo.status === 'rejected'
const canMakeDecision = isPending
return (
<tr
key={photo.id}
className={`hover:bg-gray-50 ${
isApproved || isRejected ? 'opacity-60 bg-gray-50' : ''
}`}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={async () => {
const isVideo = photo.mime_type?.startsWith('video/')
if (isVideo) {
// For videos, open the video file directly
const videoUrl = `${apiClient.defaults.baseURL}/api/v1/pending-photos/${photo.id}/image`
window.open(videoUrl, '_blank')
} else {
// For images, fetch as blob and open in new tab
try {
const blobUrl = imageUrls[photo.id] || await pendingPhotosApi.getPendingPhotoImageBlob(photo.id)
// Create a new window with the blob URL
const newWindow = window.open()
if (newWindow) {
newWindow.location.href = blobUrl
}
} catch (err) {
console.error('Failed to open full-size image:', err)
alert('Failed to load full-size image')
}
}
}}
title={photo.mime_type?.startsWith('video/') ? 'Click to open video' : 'Click to open full photo'}
>
{photo.mime_type?.startsWith('video/') ? (
<div className="w-24 h-24 bg-gray-800 rounded border border-gray-300 flex items-center justify-center relative">
<svg
className="w-12 h-12 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
</svg>
<div className="absolute bottom-1 right-1 bg-black bg-opacity-70 text-white text-[8px] px-1 rounded">
VIDEO
</div>
</div>
) : imageUrls[photo.id] ? (
<img
src={imageUrls[photo.id]}
alt={photo.original_filename}
className="w-24 h-24 object-cover rounded border border-gray-300"
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 =
'text-gray-400 text-xs error-fallback'
fallback.textContent = 'Image not found'
parent.appendChild(fallback)
}
}}
/>
) : (
<div className="w-24 h-24 bg-gray-200 rounded border border-gray-300 flex items-center justify-center text-xs text-gray-400">
Loading...
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{photo.user_name || 'Unknown'}
</div>
<div className="text-sm text-gray-500">
{photo.user_email || '-'}
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{photo.original_filename}
</div>
<div className="text-sm text-gray-500">
{formatFileSize(photo.file_size)} {photo.mime_type}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDate(photo.submitted_at)}
</div>
{photo.reviewed_at && (
<div className="text-xs text-gray-400 mt-1">
Reviewed: {formatDate(photo.reviewed_at)}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
photo.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: photo.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{photo.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{canMakeDecision ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decisions[photo.id] === 'approve'}
onChange={(e) => {
if (e.target.checked) {
handleDecisionChange(photo.id, 'approve')
} else {
// Unchecking - remove decision
handleDecisionChange(photo.id, 'approve')
}
}}
className="w-4 h-4 text-green-600 focus:ring-green-500 rounded"
/>
<span className="text-sm text-gray-700">Approve</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decisions[photo.id] === 'reject'}
onChange={(e) => {
if (e.target.checked) {
handleDecisionChange(photo.id, 'reject')
} else {
// Unchecking - remove decision
handleDecisionChange(photo.id, 'reject')
}
}}
className="w-4 h-4 text-red-600 focus:ring-red-500 rounded"
/>
<span className="text-sm text-gray-700">Reject</span>
</label>
</div>
</div>
) : (
<span className="text-sm text-gray-500 italic">
{isApproved ? 'Approved' : isRejected ? 'Rejected' : '-'}
</span>
)}
</td>
<td className="px-6 py-4">
{canMakeDecision && decisions[photo.id] === 'reject' ? (
<textarea
value={rejectionReasons[photo.id] || ''}
onChange={(e) =>
handleRejectionReasonChange(photo.id, e.target.value)
}
placeholder="Optional: Enter rejection reason..."
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md resize-none focus:ring-blue-500 focus:border-blue-500"
rows={2}
/>
) : isRejected && photo.rejection_reason ? (
<div className="text-sm text-gray-700 whitespace-pre-wrap">
<div className="bg-red-50 p-2 rounded border border-red-200">
{photo.rejection_reason}
</div>
</div>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
)
}
+454
View File
@@ -0,0 +1,454 @@
import { useState, useRef, useEffect } from 'react'
import { facesApi, ProcessFacesRequest } from '../api/faces'
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
import { useDeveloperMode } from '../context/DeveloperModeContext'
interface JobProgress {
id: string
status: string
progress: number
message: string
processed?: number
total?: number
faces_detected?: number
faces_stored?: number
}
const DETECTOR_OPTIONS = ['retinaface', 'mtcnn', 'opencv', 'ssd']
const MODEL_OPTIONS = ['ArcFace', 'Facenet', 'Facenet512', 'VGG-Face']
export default function Process() {
const { isDeveloperMode } = useDeveloperMode()
const [batchSize, setBatchSize] = useState<number | undefined>(undefined)
const [detectorBackend, setDetectorBackend] = useState('retinaface')
const [modelName, setModelName] = useState('ArcFace')
const [isProcessing, setIsProcessing] = useState(false)
const [currentJob, setCurrentJob] = useState<JobResponse | null>(null)
const [jobProgress, setJobProgress] = useState<JobProgress | null>(null)
const [processingResult, setProcessingResult] = useState<{
photos_processed?: number
faces_detected?: number
faces_stored?: number
} | null>(null)
const [error, setError] = useState<string | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
// Cleanup event source on unmount
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
}
}, [])
const handleStartProcessing = async () => {
setIsProcessing(true)
setError(null)
setProcessingResult(null)
setCurrentJob(null)
setJobProgress(null)
try {
const request: ProcessFacesRequest = {
batch_size: batchSize || undefined,
detector_backend: detectorBackend,
model_name: modelName,
}
const response = await facesApi.processFaces(request)
// Set processing state immediately
setIsProcessing(true)
setCurrentJob({
id: response.job_id,
status: JobStatus.PENDING,
progress: 0,
message: response.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
// Start SSE stream for job progress
startJobProgressStream(response.job_id)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Processing failed')
setIsProcessing(false)
}
}
const handleStopProcessing = async () => {
if (!currentJob) {
return
}
try {
// Call API to cancel the job
const result = await jobsApi.cancelJob(currentJob.id)
console.log('Job cancellation requested:', result)
// Update job status to show cancellation is in progress
setCurrentJob({
...currentJob,
status: JobStatus.PROGRESS,
message: 'Cancellation requested - finishing current photo...',
})
// Don't close SSE stream yet - keep it open to wait for job to actually stop
// The job will finish the current photo, then stop and send a final status update
// The SSE stream handler will close the stream when job status becomes SUCCESS or FAILURE
// Set a flag to indicate cancellation was requested
// This will be checked in the SSE handler
setError(null) // Clear any previous errors
} catch (err: any) {
console.error('Error cancelling job:', err)
setError(err.response?.data?.detail || err.message || 'Failed to cancel job')
}
}
const startJobProgressStream = (jobId: string) => {
// Close existing stream if any
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
const eventSource = jobsApi.streamJobProgress(jobId)
eventSourceRef.current = eventSource
eventSource.onmessage = (event) => {
try {
const data: JobProgress = JSON.parse(event.data)
setJobProgress(data)
// Update job status
const statusMap: Record<string, JobStatus> = {
pending: JobStatus.PENDING,
started: JobStatus.STARTED,
progress: JobStatus.PROGRESS,
success: JobStatus.SUCCESS,
failure: JobStatus.FAILURE,
}
const jobStatus = statusMap[data.status] || JobStatus.PENDING
setCurrentJob({
id: data.id,
status: jobStatus,
progress: data.progress,
message: data.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
// Keep processing state true while job is running
if (jobStatus === JobStatus.STARTED || jobStatus === JobStatus.PROGRESS) {
setIsProcessing(true)
}
// Check if job is complete
if (jobStatus === JobStatus.SUCCESS || jobStatus === JobStatus.FAILURE) {
setIsProcessing(false)
eventSource.close()
eventSourceRef.current = null
// Show cancellation message if job was cancelled
if (data.message && (data.message.includes('Cancelled') || data.message.includes('cancelled'))) {
setError(`Job cancelled: ${data.message}`)
}
// Fetch final job result to get processing stats
if (jobStatus === JobStatus.SUCCESS) {
fetchJobResult(jobId)
}
}
} catch (err) {
console.error('Error parsing SSE event:', err)
}
}
eventSource.onerror = (err) => {
console.error('SSE error:', err)
// Don't automatically set isProcessing to false on error
// Job might still be running even if SSE connection failed
// Check job status directly instead
if (currentJob) {
// Try to fetch job status directly
jobsApi.getJob(currentJob.id).then((job) => {
const stillRunning = job.status === JobStatus.STARTED || job.status === JobStatus.PROGRESS
setIsProcessing(stillRunning)
setCurrentJob(job)
}).catch(() => {
// If we can't get status, assume job might still be running
console.warn('Could not fetch job status after SSE error')
})
}
}
}
const fetchJobResult = async (jobId: string) => {
try {
const job = await jobsApi.getJob(jobId)
setCurrentJob(job)
// Extract result data from job progress
if (jobProgress) {
setProcessingResult({
photos_processed: jobProgress.processed,
faces_detected: jobProgress.faces_detected,
faces_stored: jobProgress.faces_stored,
})
}
} catch (err) {
console.error('Error fetching job result:', err)
}
}
const getStatusColor = (status: JobStatus) => {
switch (status) {
case JobStatus.SUCCESS:
return 'text-green-600'
case JobStatus.FAILURE:
return 'text-red-600'
case JobStatus.STARTED:
case JobStatus.PROGRESS:
return 'text-blue-600'
default:
return 'text-gray-600'
}
}
return (
<div className="p-6">
<div className="space-y-6">
{/* Configuration Section */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Processing Configuration
</h2>
<div className="space-y-4">
{/* Batch Size */}
<div>
<label
htmlFor="batch-size"
className="block text-sm font-medium text-gray-700 mb-1"
>
Batch Size
</label>
<input
id="batch-size"
type="text"
inputMode="numeric"
pattern="[0-9]*"
value={batchSize || ''}
onChange={(e) => {
const value = e.target.value
// Only allow numeric input
if (value === '' || /^\d+$/.test(value)) {
setBatchSize(value ? parseInt(value, 10) : undefined)
}
}}
onKeyDown={(e) => {
// Allow: backspace, delete, tab, escape, enter, and decimal point
if (
[8, 9, 27, 13, 46, 110, 190].indexOf(e.keyCode) !== -1 ||
// Allow: Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X
(e.keyCode === 65 && e.ctrlKey === true) ||
(e.keyCode === 67 && e.ctrlKey === true) ||
(e.keyCode === 86 && e.ctrlKey === true) ||
(e.keyCode === 88 && e.ctrlKey === true) ||
// Allow: home, end, left, right
(e.keyCode >= 35 && e.keyCode <= 39)
) {
return
}
// Ensure that it is a number and stop the keypress
if ((e.shiftKey || (e.keyCode < 48 || e.keyCode > 57)) && (e.keyCode < 96 || e.keyCode > 105)) {
e.preventDefault()
}
}}
className="w-32 px-2 py-1 text-sm border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
disabled={isProcessing}
/>
<p className="mt-1 text-xs text-gray-500">
Leave empty to process all unprocessed photos
</p>
</div>
{/* Detector Backend - Only visible in developer mode */}
{isDeveloperMode && (
<div>
<label
htmlFor="detector-backend"
className="block text-sm font-medium text-gray-700 mb-2"
>
Face Detector
</label>
<select
id="detector-backend"
value={detectorBackend}
onChange={(e) => setDetectorBackend(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
disabled={isProcessing}
>
{DETECTOR_OPTIONS.map((option) => (
<option key={option} value={option}>
{option.charAt(0).toUpperCase() + option.slice(1)}
</option>
))}
</select>
<p className="mt-1 text-sm text-gray-500">
RetinaFace recommended for best accuracy
</p>
</div>
)}
{/* Model Name - Only visible in developer mode */}
{isDeveloperMode && (
<div>
<label
htmlFor="model-name"
className="block text-sm font-medium text-gray-700 mb-2"
>
Recognition Model
</label>
<select
id="model-name"
value={modelName}
onChange={(e) => setModelName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
disabled={isProcessing}
>
{MODEL_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<p className="mt-1 text-gray-500">
ArcFace recommended for best accuracy
</p>
</div>
)}
{/* Control Buttons */}
<div className="flex gap-2 pt-4">
<button
type="button"
onClick={handleStartProcessing}
disabled={isProcessing}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isProcessing ? 'Processing...' : 'Start Processing'}
</button>
{isProcessing && (
<button
type="button"
onClick={handleStopProcessing}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Stop
</button>
)}
</div>
</div>
</div>
{/* Progress Section */}
{(currentJob || jobProgress) && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Processing Progress
</h2>
{currentJob && (
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span
className={`text-sm font-medium ${getStatusColor(
currentJob.status
)}`}
>
{currentJob.status === JobStatus.SUCCESS && '✓ '}
{currentJob.status === JobStatus.FAILURE && '✗ '}
{currentJob.status.charAt(0).toUpperCase() +
currentJob.status.slice(1)}
</span>
<span className="text-sm text-gray-600">
{currentJob.progress}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${currentJob.progress}%` }}
/>
</div>
</div>
{jobProgress && (
<div className="space-y-2 text-sm text-gray-600">
{jobProgress.processed !== undefined &&
jobProgress.total !== undefined && (
<p>
Photos processed: {jobProgress.processed} /{' '}
{jobProgress.total}
</p>
)}
{jobProgress.faces_detected !== undefined && (
<p>Faces detected: {jobProgress.faces_detected}</p>
)}
{jobProgress.faces_stored !== undefined && (
<p>Faces stored: {jobProgress.faces_stored}</p>
)}
{jobProgress.message && (
<p className="mt-1 font-medium">{jobProgress.message}</p>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Results Section */}
{processingResult && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Processing Results
</h2>
<div className="space-y-2 text-sm">
{processingResult.photos_processed !== undefined && (
<p className="text-green-600">
{processingResult.photos_processed} photos processed
</p>
)}
{processingResult.faces_detected !== undefined && (
<p className="text-gray-600">
{processingResult.faces_detected} faces detected
</p>
)}
{processingResult.faces_stored !== undefined && (
<p className="text-gray-700 font-medium">
{processingResult.faces_stored} faces stored in database
</p>
)}
</div>
</div>
)}
{/* Error Section */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
</div>
</div>
)
}
+509
View File
@@ -0,0 +1,509 @@
import { useEffect, useState, useCallback } from 'react'
import {
reportedPhotosApi,
ReportedPhotoResponse,
ReviewDecision,
} from '../api/reportedPhotos'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { videosApi } from '../api/videos'
export default function ReportedPhotos() {
const { isAdmin } = useAuth()
const [reportedPhotos, setReportedPhotos] = useState<ReportedPhotoResponse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [decisions, setDecisions] = useState<Record<number, 'keep' | 'remove' | null>>({})
const [reviewNotes, setReviewNotes] = useState<Record<number, string>>({})
const [submitting, setSubmitting] = useState(false)
const [clearing, setClearing] = useState(false)
const [statusFilter, setStatusFilter] = useState<string>('pending')
const loadReportedPhotos = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await reportedPhotosApi.listReportedPhotos(
statusFilter || undefined
)
setReportedPhotos(response.items)
// Initialize review notes from existing data
const existingNotes: Record<number, string> = {}
response.items.forEach((item) => {
if (item.review_notes) {
existingNotes[item.id] = item.review_notes
}
})
setReviewNotes(existingNotes)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load reported photos')
console.error('Error loading reported photos:', err)
} finally {
setLoading(false)
}
}, [statusFilter])
useEffect(() => {
loadReportedPhotos()
}, [loadReportedPhotos])
const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return '-'
try {
const date = new Date(dateString)
return date.toLocaleString()
} catch {
return dateString
}
}
const handleDecisionChange = (id: number, decision: 'keep' | 'remove') => {
setDecisions((prev) => {
const currentDecision = prev[id] ?? null
const nextDecision = currentDecision === decision ? null : decision
return {
...prev,
[id]: nextDecision,
}
})
}
const handleReviewNotesChange = (id: number, notes: string) => {
setReviewNotes((prev) => ({
...prev,
[id]: notes,
}))
}
const handleSubmit = async () => {
// Get all decisions that have been made for pending or reviewed items
const decisionsList: ReviewDecision[] = Object.entries(decisions)
.filter(([id, decision]) => {
const reported = reportedPhotos.find((p) => p.id === parseInt(id))
return decision !== null && reported && (reported.status === 'pending' || reported.status === 'reviewed')
})
.map(([id, decision]) => ({
id: parseInt(id),
decision: decision!,
review_notes: reviewNotes[parseInt(id)] || null,
}))
if (decisionsList.length === 0) {
alert('Please select Keep or Remove for at least one reported photo.')
return
}
// Check if there are any 'remove' decisions
const removeDecisions = decisionsList.filter((d) => d.decision === 'remove')
const keepDecisions = decisionsList.filter((d) => d.decision === 'keep')
// Show specific confirmation for removal
if (removeDecisions.length > 0) {
const removeCount = removeDecisions.length
const photoDetails = removeDecisions
.map((d) => {
const reported = reportedPhotos.find((p) => p.id === d.id)
return reported?.photo_filename || `Photo #${reported?.photo_id || d.id}`
})
.join('\n - ')
const confirmMessage = `⚠️ WARNING: You are about to PERMANENTLY REMOVE ${removeCount} photo(s):\n\n - ${photoDetails}\n\nThis will:\n • Delete the photo(s) from the database\n • Delete all faces detected in the photo(s)\n • Delete all encodings related to those faces\n\nThis action CANNOT be undone!\n\nAre you sure you want to proceed?`
if (!confirm(confirmMessage)) {
return
}
}
// Show general confirmation if there are also 'keep' decisions
if (keepDecisions.length > 0) {
const confirmMessage = `Submit ${decisionsList.length} decision(s)?\n\nThis will ${
removeDecisions.length
} remove photo(s) and ${keepDecisions.length} keep photo(s).`
if (!confirm(confirmMessage)) {
return
}
}
setSubmitting(true)
try {
const response = await reportedPhotosApi.reviewReportedPhotos({
decisions: decisionsList,
})
const messageParts = [
`✅ Kept: ${response.kept}`,
`❌ Removed: ${response.removed}`,
]
if (import.meta.env.DEV && response.errors.length > 0) {
messageParts.push(`⚠️ Errors: ${response.errors.length}`)
}
const message = messageParts.join('\n')
alert(message)
if (response.errors.length > 0) {
console.error('Reported photo review errors:', response.errors)
}
// Reload the list to show updated status
await loadReportedPhotos()
// Clear decisions and notes
setDecisions({})
setReviewNotes({})
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to submit decisions'
alert(`Error: ${errorMessage}`)
console.error('Error submitting decisions:', err)
} finally {
setSubmitting(false)
}
}
const handleClearDatabase = async () => {
const confirmMessage = [
'Delete all kept and removed reported photo records from the auth database?',
'',
'Only photos with Pending status will remain.',
'This action cannot be undone.',
].join('\n')
if (!confirm(confirmMessage)) {
return
}
setClearing(true)
try {
const response = await reportedPhotosApi.cleanupReportedPhotos()
const summary = [
`✅ Deleted ${response.deleted_records} record(s)`,
response.warnings && response.warnings.length > 0
? `${response.warnings.join('; ')}`
: '',
response.errors.length > 0 ? `⚠️ ${response.errors.join('; ')}` : '',
]
.filter(Boolean)
.join('\n')
alert(summary || 'Cleanup complete.')
if (response.errors.length > 0) {
console.error('Cleanup errors:', response.errors)
}
if (response.warnings && response.warnings.length > 0) {
console.info('Cleanup warnings:', response.warnings)
}
await loadReportedPhotos()
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to cleanup reported photos'
alert(`Error: ${errorMessage}`)
console.error('Error clearing reported photos:', err)
} finally {
setClearing(false)
}
}
return (
<div>
<div className="bg-white rounded-lg shadow p-6">
{loading && (
<div className="text-center py-8">
<p className="text-gray-600">Loading reported photos...</p>
</div>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
<p className="font-semibold">Error loading data</p>
<p className="text-sm mt-1">{error}</p>
<button
onClick={loadReportedPhotos}
className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Retry
</button>
</div>
)}
{!loading && !error && (
<>
<div className="mb-4 flex flex-col gap-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-600">
Total reported photos:{' '}
<span className="font-semibold">{reportedPhotos.length}</span>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
>
<option value="">All Status</option>
<option value="pending">Pending</option>
<option value="reviewed">Reviewed</option>
<option value="dismissed">Dismissed</option>
</select>
</div>
<button
onClick={handleSubmit}
disabled={
submitting ||
Object.values(decisions).filter((d) => d !== null).length === 0
}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
>
{submitting ? 'Submitting...' : 'Submit Decisions'}
</button>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => {
if (!isAdmin) {
return
}
handleClearDatabase()
}}
disabled={clearing || !isAdmin}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-200 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
title={
isAdmin
? 'Delete kept/removed records'
: 'Only admins can clear reported photos'
}
>
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
</button>
<span className="text-sm text-gray-700">
Clear kept/removed records
</span>
</div>
</div>
{reportedPhotos.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<p>No reported photos found.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Photo
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reported By
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Reported At
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Report Comment
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Decision
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Review Notes
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{reportedPhotos.map((reported) => {
const isReviewed = reported.status === 'reviewed'
const isDismissed = reported.status === 'dismissed'
const canMakeDecision = !isDismissed && (reported.status === 'pending' || reported.status === 'reviewed')
return (
<tr
key={reported.id}
className={`hover:bg-gray-50 ${
isReviewed || isDismissed ? 'opacity-60 bg-gray-50' : ''
}`}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{reported.photo_id ? (
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const isVideo = reported.photo_media_type === 'video'
const url = isVideo
? videosApi.getVideoUrl(reported.photo_id)
: `${apiClient.defaults.baseURL}/api/v1/photos/${reported.photo_id}/image`
window.open(url, '_blank')
}}
title={reported.photo_media_type === 'video' ? 'Click to open video' : 'Click to open full photo'}
>
{reported.photo_media_type === 'video' ? (
<img
src={videosApi.getThumbnailUrl(reported.photo_id)}
alt={`Video ${reported.photo_id}`}
className="w-24 h-24 object-cover rounded border border-gray-300"
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 =
'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${reported.photo_id}`
parent.appendChild(fallback)
}
}}
/>
) : (
<img
src={`/api/v1/photos/${reported.photo_id}/image`}
alt={`Photo ${reported.photo_id}`}
className="w-24 h-24 object-cover rounded border border-gray-300"
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 =
'text-gray-400 text-xs error-fallback'
fallback.textContent = `#${reported.photo_id}`
parent.appendChild(fallback)
}
}}
/>
)}
</div>
) : (
<div className="text-gray-400 text-xs">Photo not found</div>
)}
</div>
<div className="text-xs text-gray-500 mt-1">
{reported.photo_filename || `Photo #${reported.photo_id}`}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{reported.user_name || 'Unknown'}
</div>
<div className="text-sm text-gray-500">
{reported.user_email || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500">
{formatDate(reported.reported_at)}
</div>
</td>
<td className="px-6 py-4">
{reported.report_comment ? (
<div className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-2 rounded border border-gray-200">
{reported.report_comment}
</div>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
reported.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: reported.status === 'reviewed'
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{reported.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{canMakeDecision ? (
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name={`decision-${reported.id}-keep`}
value="keep"
checked={decisions[reported.id] === 'keep'}
onChange={() => handleDecisionChange(reported.id, 'keep')}
className="w-4 h-4 text-green-600 focus:ring-green-500"
/>
<span className="text-sm text-gray-700">Keep</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name={`decision-${reported.id}-remove`}
value="remove"
checked={decisions[reported.id] === 'remove'}
onChange={() => handleDecisionChange(reported.id, 'remove')}
className="w-4 h-4 text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">Remove</span>
</label>
</div>
) : (
<span className="text-sm text-gray-500 italic">-</span>
)}
</td>
<td className="px-6 py-4">
{isReviewed || isDismissed ? (
<div className="text-sm text-gray-700 whitespace-pre-wrap">
{reported.review_notes ? (
<div className="bg-gray-50 p-2 rounded border border-gray-200">
{reported.review_notes}
</div>
) : (
<span className="text-gray-400 italic">-</span>
)}
</div>
) : (
<div className="flex flex-col gap-2">
{reported.review_notes && (
<div className="bg-blue-50 p-2 rounded border border-blue-200 text-sm text-gray-700">
<div className="text-xs text-blue-600 font-medium mb-1">
Existing notes:
</div>
<div className="whitespace-pre-wrap">
{reported.review_notes}
</div>
</div>
)}
<textarea
value={reviewNotes[reported.id] || ''}
onChange={(e) =>
handleReviewNotesChange(reported.id, e.target.value)
}
placeholder="Optional review notes..."
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md resize-none"
rows={2}
/>
</div>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
)
}
+384
View File
@@ -0,0 +1,384 @@
import { useState, useRef, useEffect } from 'react'
import { photosApi, PhotoImportRequest } from '../api/photos'
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
interface JobProgress {
id: string
status: string
progress: number
message: string
processed?: number
total?: number
}
export default function Scan() {
const [folderPath, setFolderPath] = useState('')
const [recursive, setRecursive] = useState(true)
const [isImporting, setIsImporting] = useState(false)
const [currentJob, setCurrentJob] = useState<JobResponse | null>(null)
const [jobProgress, setJobProgress] = useState<JobProgress | null>(null)
const [importResult, setImportResult] = useState<{
added?: number
existing?: number
total?: number
} | null>(null)
const [error, setError] = useState<string | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
// Cleanup event source on unmount
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
}
}, [])
const handleFolderBrowse = async () => {
// Try backend API first (uses tkinter for native folder picker with full path)
try {
const result = await photosApi.browseFolder()
if (result.success && result.path) {
setFolderPath(result.path)
return
}
} catch (err: any) {
// Backend API failed, fall back to browser picker
console.warn('Backend folder picker unavailable, using browser fallback:', err)
// Check if it's a display/availability issue
const errorMsg = err?.response?.data?.detail || err?.message || ''
if (errorMsg.includes('display') || errorMsg.includes('DISPLAY')) {
// Show user-friendly message about display issue
alert('Native folder picker unavailable (no display). Using browser fallback.\n\nNote: Browser picker may only show folder name. You may need to manually complete the full path.')
}
}
// Fallback: Use browser-based folder picker
// Use File System Access API if available (modern browsers)
if (typeof window !== 'undefined' && 'showDirectoryPicker' in window) {
try {
const directoryHandle = await (window as any).showDirectoryPicker()
// Get the folder name from the handle
const folderName = directoryHandle.name
// Note: Browsers don't expose full absolute paths for security reasons
setFolderPath(folderName)
} catch (err: any) {
// User cancelled the picker
if (err.name !== 'AbortError') {
console.error('Error selecting folder:', err)
}
}
} else {
// Fallback: use a hidden directory input
// Note: This will show a browser confirmation dialog that cannot be removed
const input = document.createElement('input')
input.type = 'file'
input.setAttribute('webkitdirectory', '')
input.setAttribute('directory', '')
input.setAttribute('multiple', '')
input.style.display = 'none'
input.onchange = (e: any) => {
const files = e.target.files
if (files && files.length > 0) {
const firstFile = files[0]
const relativePath = firstFile.webkitRelativePath
const pathParts = relativePath.split('/')
const rootFolder = pathParts[0]
// Note: Browsers don't expose full absolute paths for security reasons
setFolderPath(rootFolder)
}
if (document.body.contains(input)) {
document.body.removeChild(input)
}
}
input.oncancel = () => {
if (document.body.contains(input)) {
document.body.removeChild(input)
}
}
document.body.appendChild(input)
input.click()
}
}
const handleScanFolder = async () => {
if (!folderPath.trim()) {
setError('Please enter a folder path')
return
}
setIsImporting(true)
setError(null)
setImportResult(null)
setCurrentJob(null)
setJobProgress(null)
try {
const request: PhotoImportRequest = {
folder_path: folderPath.trim(),
recursive,
}
const response = await photosApi.importPhotos(request)
setCurrentJob({
id: response.job_id,
status: JobStatus.PENDING,
progress: 0,
message: response.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
// Start SSE stream for job progress
startJobProgressStream(response.job_id)
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Import failed')
setIsImporting(false)
}
}
const startJobProgressStream = (jobId: string) => {
// Close existing stream if any
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
const eventSource = photosApi.streamJobProgress(jobId)
eventSourceRef.current = eventSource
eventSource.onmessage = (event) => {
try {
const data: JobProgress = JSON.parse(event.data)
setJobProgress(data)
// Update job status
const statusMap: Record<string, JobStatus> = {
pending: JobStatus.PENDING,
started: JobStatus.STARTED,
progress: JobStatus.PROGRESS,
success: JobStatus.SUCCESS,
failure: JobStatus.FAILURE,
}
setCurrentJob({
id: data.id,
status: statusMap[data.status] || JobStatus.PENDING,
progress: data.progress,
message: data.message,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
// Check if job is complete
if (data.status === 'success' || data.status === 'failure') {
setIsImporting(false)
eventSource.close()
eventSourceRef.current = null
// Fetch final job result to get added/existing counts
if (data.status === 'success') {
fetchJobResult(jobId)
}
}
} catch (err) {
console.error('Error parsing SSE event:', err)
}
}
eventSource.onerror = (err) => {
console.error('SSE error:', err)
eventSource.close()
eventSourceRef.current = null
}
}
const fetchJobResult = async (jobId: string) => {
try {
const job = await jobsApi.getJob(jobId)
// Job result may contain added/existing counts in metadata
// For now, we'll just update the job status
setCurrentJob(job)
} catch (err) {
console.error('Error fetching job result:', err)
}
}
const getStatusColor = (status: JobStatus) => {
switch (status) {
case JobStatus.SUCCESS:
return 'text-green-600'
case JobStatus.FAILURE:
return 'text-red-600'
case JobStatus.STARTED:
case JobStatus.PROGRESS:
return 'text-blue-600'
default:
return 'text-gray-600'
}
}
return (
<div className="p-6">
<div className="space-y-6">
{/* Folder Scan Section */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Scan Folder
</h2>
<div className="space-y-4">
<div>
<label
htmlFor="folder-path"
className="block text-sm font-medium text-gray-700 mb-2"
>
Folder Path
</label>
<div className="flex gap-2">
<input
id="folder-path"
type="text"
value={folderPath}
onChange={(e) => setFolderPath(e.target.value)}
placeholder="/path/to/photos"
className="w-1/2 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
disabled={isImporting}
/>
<button
type="button"
onClick={handleFolderBrowse}
disabled={isImporting}
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Browse
</button>
</div>
<p className="mt-1 text-sm text-gray-500">
Enter the full path to the folder containing photos / videos.
<span className="text-xs text-gray-400 block mt-1">
Click Browse to open a native folder picker. The full path will be automatically filled.
If the native picker is unavailable, a browser fallback will be used (may require manual path completion).
</span>
</p>
</div>
<div className="flex items-center">
<input
id="recursive"
type="checkbox"
checked={recursive}
onChange={(e) => setRecursive(e.target.checked)}
disabled={isImporting}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label
htmlFor="recursive"
className="ml-2 block text-sm text-gray-700"
>
Scan subdirectories recursively
</label>
</div>
<button
type="button"
onClick={handleScanFolder}
disabled={isImporting || !folderPath.trim()}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isImporting ? 'Scanning...' : 'Start Scanning'}
</button>
</div>
</div>
{/* Progress Section */}
{(currentJob || jobProgress) && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Import Progress
</h2>
{currentJob && (
<div className="space-y-4">
<div>
<div className="flex justify-between items-center mb-2">
<span
className={`text-sm font-medium ${getStatusColor(currentJob.status)}`}
>
{currentJob.status === JobStatus.SUCCESS && '✓ '}
{currentJob.status === JobStatus.FAILURE && '✗ '}
{currentJob.status.charAt(0).toUpperCase() +
currentJob.status.slice(1)}
</span>
<span className="text-sm text-gray-600">
{currentJob.progress}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${currentJob.progress}%` }}
/>
</div>
</div>
{jobProgress && (
<div className="text-sm text-gray-600">
{jobProgress.processed !== undefined &&
jobProgress.total !== undefined && (
<p>
Processed: {jobProgress.processed} /{' '}
{jobProgress.total}
</p>
)}
{jobProgress.message && (
<p className="mt-1">{jobProgress.message}</p>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Results Section */}
{importResult && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">
Import Results
</h2>
<div className="space-y-2 text-sm">
{importResult.added !== undefined && (
<p className="text-green-600">
{importResult.added} new photos added
</p>
)}
{importResult.existing !== undefined && (
<p className="text-gray-600">
{importResult.existing} photos already in database
</p>
)}
{importResult.total !== undefined && (
<p className="text-gray-700 font-medium">
Total: {importResult.total} photos
</p>
)}
</div>
</div>
)}
{/* Error Section */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
)}
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
import { useDeveloperMode } from '../context/DeveloperModeContext'
export default function Settings() {
const { isDeveloperMode, setDeveloperMode } = useDeveloperMode()
return (
<div>
<div className="bg-white rounded-lg shadow p-6 mb-4">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Developer Options</h2>
<div className="flex items-center justify-between py-3 border-b border-gray-200">
<div className="flex-1">
<label htmlFor="developer-mode" className="text-sm font-medium text-gray-700">
Developer Mode
</label>
<p className="text-xs text-gray-500 mt-1">
Enable developer features. Additional features will be available when enabled.
</p>
</div>
<div className="ml-4">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
id="developer-mode"
checked={isDeveloperMode}
onChange={(e) => setDeveloperMode(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,598 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
pendingLinkagesApi,
PendingLinkageResponse,
ReviewDecision,
} from '../api/pendingLinkages'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { videosApi } from '../api/videos'
type DecisionValue = 'approve' | 'deny'
type SortKey = 'photo' | 'tag' | 'submitted_by' | 'submitted_at' | 'status'
function formatDate(value: string | null | undefined): string {
if (!value) {
return '-'
}
try {
return new Date(value).toLocaleString()
} catch (error) {
console.error('Failed to format date', error)
return value
}
}
export default function UserTaggedPhotos() {
const { isAdmin } = useAuth()
const [linkages, setLinkages] = useState<PendingLinkageResponse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [statusFilter, setStatusFilter] = useState<string>('pending')
const [decisions, setDecisions] = useState<Record<number, DecisionValue | null>>({})
const [submitting, setSubmitting] = useState(false)
const [clearing, setClearing] = useState(false)
const [sortBy, setSortBy] = useState<SortKey>('submitted_at')
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
const loadLinkages = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await pendingLinkagesApi.listPendingLinkages(
statusFilter || undefined
)
setLinkages(response.items)
setDecisions({})
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load user tagged photos')
console.error('Error loading pending linkages:', err)
} finally {
setLoading(false)
}
}, [statusFilter])
useEffect(() => {
loadLinkages()
}, [loadLinkages])
const pendingCount = useMemo(
() => linkages.filter((item) => item.status === 'pending').length,
[linkages]
)
const sortedLinkages = useMemo(() => {
const items = [...linkages]
items.sort((a, b) => {
const direction = sortDirection === 'asc' ? 1 : -1
const compareStrings = (x: string | null | undefined, y: string | null | undefined) =>
(x || '').localeCompare(y || '', undefined, { sensitivity: 'base' })
if (sortBy === 'photo') {
return (a.photo_id - b.photo_id) * direction
}
if (sortBy === 'tag') {
const aTag = a.resolved_tag_name || a.proposed_tag_name || ''
const bTag = b.resolved_tag_name || b.proposed_tag_name || ''
return compareStrings(aTag, bTag) * direction
}
if (sortBy === 'submitted_by') {
const aName = a.user_name || a.user_email || ''
const bName = b.user_name || b.user_email || ''
return compareStrings(aName, bName) * direction
}
if (sortBy === 'submitted_at') {
const aTime = a.created_at || ''
const bTime = b.created_at || ''
return (aTime < bTime ? -1 : aTime > bTime ? 1 : 0) * direction
}
if (sortBy === 'status') {
return compareStrings(a.status, b.status) * direction
}
return 0
})
return items
}, [linkages, sortBy, sortDirection])
const toggleSort = (key: SortKey) => {
setSortBy((currentKey) => {
if (currentKey === key) {
setSortDirection((currentDirection) => (currentDirection === 'asc' ? 'desc' : 'asc'))
return currentKey
}
setSortDirection('asc')
return key
})
}
const renderSortLabel = (label: string, key: SortKey) => {
const isActive = sortBy === key
const directionSymbol = !isActive ? '↕' : sortDirection === 'asc' ? '▲' : '▼'
return (
<button
type="button"
onClick={() => toggleSort(key)}
className="inline-flex items-center gap-1 text-xs font-medium text-gray-500 uppercase tracking-wider hover:text-gray-700"
>
<span>{label}</span>
<span className="text-[10px]">{directionSymbol}</span>
</button>
)
}
const hasPendingDecision = useMemo(
() =>
Object.entries(decisions).some(([id, value]) => {
const linkage = linkages.find((item) => item.id === Number(id))
return value !== null && linkage?.status === 'pending'
}),
[decisions, linkages]
)
const handleDecisionChange = (id: number, nextDecision: DecisionValue) => {
setDecisions((prev) => {
const current = prev[id] ?? null
const toggled = current === nextDecision ? null : nextDecision
return {
...prev,
[id]: toggled,
}
})
}
const handleSelectAllApprove = () => {
const pendingIds = linkages
.filter((item) => item.status === 'pending')
.map((item) => item.id)
if (pendingIds.length === 0) {
return
}
const newDecisions: Record<number, DecisionValue> = {}
pendingIds.forEach((id) => {
newDecisions[id] = 'approve'
})
setDecisions((prev) => ({
...prev,
...newDecisions,
}))
}
const handleSelectAllDeny = () => {
const pendingIds = linkages
.filter((item) => item.status === 'pending')
.map((item) => item.id)
if (pendingIds.length === 0) {
return
}
const newDecisions: Record<number, DecisionValue> = {}
pendingIds.forEach((id) => {
newDecisions[id] = 'deny'
})
setDecisions((prev) => ({
...prev,
...newDecisions,
}))
}
const handleSubmit = async () => {
const decisionsList: ReviewDecision[] = Object.entries(decisions)
.filter(([id, decision]) => {
const linkage = linkages.find((item) => item.id === Number(id))
return decision !== null && linkage?.status === 'pending'
})
.map(([id, decision]) => ({
id: Number(id),
decision: decision as DecisionValue,
}))
if (decisionsList.length === 0) {
alert('Select Approve or Deny for at least one pending tag.')
return
}
const approveCount = decisionsList.filter((item) => item.decision === 'approve').length
const denyCount = decisionsList.length - approveCount
const confirmMessage = [
`Submit ${decisionsList.length} decision(s)?`,
approveCount ? `✅ Approve: ${approveCount}` : null,
denyCount ? `❌ Deny: ${denyCount}` : null,
]
.filter(Boolean)
.join('\n')
if (!confirm(confirmMessage)) {
return
}
setSubmitting(true)
try {
const response = await pendingLinkagesApi.reviewPendingLinkages({
decisions: decisionsList,
})
const summary = [
`Approved: ${response.approved}`,
`Denied: ${response.denied}`,
response.tags_created ? `New tags: ${response.tags_created}` : null,
response.linkages_created ? `New linkages: ${response.linkages_created}` : null,
response.errors.length ? `Errors: ${response.errors.join('; ')}` : null,
]
.filter(Boolean)
.join('\n')
alert(summary || 'Review complete.')
await loadLinkages()
setDecisions({})
} catch (err: any) {
const message = err.response?.data?.detail || err.message || 'Failed to submit decisions'
alert(message)
console.error('Error submitting pending linkage decisions:', err)
} finally {
setSubmitting(false)
}
}
const handleClearDatabase = async () => {
const confirmMessage = [
'Delete all approved and denied records?',
'',
'Only records with Pending status will remain.',
'This action cannot be undone.',
].join('\n')
if (!confirm(confirmMessage)) {
return
}
setClearing(true)
try {
const response = await pendingLinkagesApi.cleanupPendingLinkages()
const summary = [
`✅ Deleted ${response.deleted_records} record(s)`,
response.warnings && response.warnings.length > 0
? `${response.warnings.join('; ')}`
: '',
response.errors.length > 0 ? `⚠️ ${response.errors.join('; ')}` : '',
]
.filter(Boolean)
.join('\n')
alert(summary || 'Cleanup complete.')
if (response.errors.length > 0) {
console.error('Cleanup errors:', response.errors)
}
if (response.warnings && response.warnings.length > 0) {
console.info('Cleanup warnings:', response.warnings)
}
await loadLinkages()
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to cleanup pending linkages'
alert(`Error: ${errorMessage}`)
console.error('Error clearing pending linkages:', err)
} finally {
setClearing(false)
}
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4">
<div>
<p className="text-gray-600">
Review tags suggested by users. Approving creates/links the tag to the selected photo.
</p>
</div>
<div className="flex flex-wrap items-center gap-4">
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
Status
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
className="border border-gray-300 rounded-md px-3 py-1 text-sm"
>
<option value="pending">Pending</option>
<option value="">All Statuses</option>
<option value="approved">Approved</option>
<option value="denied">Denied</option>
</select>
</label>
<button
type="button"
onClick={loadLinkages}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Refresh
</button>
<div className="text-sm text-gray-500">
Pending items: <span className="font-semibold text-gray-800">{pendingCount}</span>
</div>
<div className="flex-1" />
{linkages.filter((item) => item.status === 'pending').length > 0 && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleSelectAllApprove}
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Approve
</button>
<button
type="button"
onClick={handleSelectAllDeny}
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
>
Select All to Deny
</button>
</div>
)}
<button
type="button"
onClick={handleSubmit}
disabled={submitting || loading || !hasPendingDecision}
className={`inline-flex items-center px-4 py-2 rounded-md text-sm font-semibold text-white ${
submitting || loading || !hasPendingDecision
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{submitting ? 'Submitting...' : 'Submit Decisions'}
</button>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
onClick={() => {
if (!isAdmin) {
return
}
handleClearDatabase()
}}
disabled={clearing || !isAdmin}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-200 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
title={
isAdmin
? 'Delete approved/denied records'
: 'Only admins can clear pending linkages'
}
>
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
</button>
<span className="text-sm text-gray-700">
Clear approved/denied records
</span>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
{loading ? (
<div className="flex items-center justify-center h-64 text-gray-500">Loading...</div>
) : linkages.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-lg p-6 text-center text-gray-500">
No user tagged photos found for this filter.
</div>
) : (
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left">
{renderSortLabel('Photo', 'photo')}
</th>
<th scope="col" className="px-6 py-3 text-left">
{renderSortLabel('Proposed Tag', 'tag')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Current Tags
</th>
<th scope="col" className="px-6 py-3 text-left">
{renderSortLabel('Submitted By', 'submitted_by')}
</th>
<th scope="col" className="px-6 py-3 text-left">
{renderSortLabel('Submitted At', 'submitted_at')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Notes
</th>
<th scope="col" className="px-6 py-3 text-left">
{renderSortLabel('Status', 'status')}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Decision
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedLinkages.map((linkage) => {
const canReview = linkage.status === 'pending'
const decision = decisions[linkage.id] ?? null
return (
<tr key={linkage.id} className={canReview ? '' : 'opacity-70 bg-gray-50'}>
<td className="px-6 py-4 whitespace-nowrap">
{linkage.photo_id ? (
<div
className="cursor-pointer hover:opacity-90 transition-opacity w-24"
onClick={() => {
const isVideo = linkage.photo_media_type === 'video'
const url = isVideo
? videosApi.getVideoUrl(linkage.photo_id)
: `${apiClient.defaults.baseURL}/api/v1/photos/${linkage.photo_id}/image`
window.open(url, '_blank')
}}
title={linkage.photo_media_type === 'video' ? 'Open video in new tab' : 'Open photo in new tab'}
>
{linkage.photo_media_type === 'video' ? (
<img
src={videosApi.getThumbnailUrl(linkage.photo_id)}
alt={`Video ${linkage.photo_id}`}
className="w-24 h-24 object-cover rounded border border-gray-300"
loading="lazy"
onError={(event) => {
const target = event.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.fallback-text')) {
const fallback = document.createElement('div')
fallback.className = 'fallback-text text-gray-400 text-xs text-center'
fallback.textContent = `#${linkage.photo_id}`
parent.appendChild(fallback)
}
}}
/>
) : (
<img
src={`/api/v1/photos/${linkage.photo_id}/image`}
alt={`Photo ${linkage.photo_id}`}
className="w-24 h-24 object-cover rounded border border-gray-300"
loading="lazy"
onError={(event) => {
const target = event.target as HTMLImageElement
target.style.display = 'none'
const parent = target.parentElement
if (parent && !parent.querySelector('.fallback-text')) {
const fallback = document.createElement('div')
fallback.className = 'fallback-text text-gray-400 text-xs text-center'
fallback.textContent = `#${linkage.photo_id}`
parent.appendChild(fallback)
}
}}
/>
)}
</div>
) : (
<div className="text-xs text-gray-400">Photo not found</div>
)}
<div className="text-xs text-gray-500 mt-1">
{linkage.photo_filename || `Photo #${linkage.photo_id}`}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{linkage.resolved_tag_name || linkage.proposed_tag_name || '-'}
</span>
{linkage.tag_id === null && linkage.proposed_tag_name && (
<span className="text-xs text-yellow-700 bg-yellow-50 px-2 py-0.5 rounded mt-1 inline-flex items-center gap-1">
<span>New tag</span>
</span>
)}
{linkage.tag_id && (
<span className="text-xs text-green-700 bg-green-50 px-2 py-0.5 rounded mt-1 inline-flex items-center gap-1">
<span>Existing tag</span>
<span>#{linkage.tag_id}</span>
</span>
)}
</div>
</td>
<td className="px-6 py-4">
{linkage.photo_tags.length === 0 ? (
<span className="text-sm text-gray-400 italic">No tags</span>
) : (
<div className="flex flex-wrap gap-2">
{linkage.photo_tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-xs bg-gray-100 text-gray-700 rounded-full"
>
{tag}
</span>
))}
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{linkage.user_name || 'Unknown'}</div>
<div className="text-xs text-gray-500">{linkage.user_email || '-'}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(linkage.created_at)}
</td>
<td className="px-6 py-4">
{linkage.notes ? (
<div className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 border border-gray-200 rounded p-2">
{linkage.notes}
</div>
) : (
<span className="text-sm text-gray-400 italic">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs font-semibold rounded-full ${
linkage.status === 'pending'
? 'bg-yellow-100 text-yellow-800'
: linkage.status === 'approved'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{linkage.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{canReview ? (
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decision === 'approve'}
onChange={() => handleDecisionChange(linkage.id, 'approve')}
className="w-4 h-4 text-green-600 focus:ring-green-500"
/>
<span className="text-sm text-gray-700">Approve</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={decision === 'deny'}
onChange={() => handleDecisionChange(linkage.id, 'deny')}
className="w-4 h-4 text-red-600 focus:ring-red-500"
/>
<span className="text-sm text-gray-700">Deny</span>
</label>
</div>
) : (
<span className="text-sm text-gray-500 italic">-</span>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
},
},
},
})