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:
@@ -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: '^_' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
|
||||
Generated
+6182
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 || {}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || {}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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}</>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
@@ -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" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user