feat: Enhance photo handling in admin frontend
All checks were successful
CI / skip-ci-check (pull_request) Successful in 1m51s
CI / lint-and-type-check (pull_request) Successful in 2m29s
CI / python-lint (pull_request) Successful in 2m15s
CI / test-backend (pull_request) Successful in 4m10s
CI / build (pull_request) Successful in 4m56s
CI / secret-scanning (pull_request) Successful in 1m58s
CI / dependency-scan (pull_request) Successful in 1m57s
CI / sast-scan (pull_request) Successful in 3m3s
CI / workflow-summary (pull_request) Successful in 1m49s

- Added a new API method to fetch photo images as blobs, enabling direct image retrieval.
- Updated image source paths in multiple components to use the base URL from the API client for consistency.
- Implemented cleanup for blob URLs in the ReportedPhotos component to prevent memory leaks.
- Improved user experience by displaying loading states for images in the ReportedPhotos component.

These changes improve the efficiency and reliability of photo handling in the admin interface.
This commit is contained in:
tanyar09 2026-01-22 18:33:44 +00:00
parent 845273cfd3
commit afaacf7403
10 changed files with 70 additions and 22 deletions

View File

@ -1,3 +1,3 @@
#!/bin/bash
cd "$(dirname "$0")"
PORT=3000 HOST=0.0.0.0 exec npx --yes serve dist
PORT=3000 HOST=0.0.0.0 exec npx --yes serve dist --single

View File

@ -143,6 +143,18 @@ export const photosApi = {
)
return data
},
getPhotoImageBlob: async (photoId: number): Promise<string> => {
// Fetch image as blob with authentication
const response = await apiClient.get(
`/api/v1/photos/${photoId}/image`,
{
responseType: 'blob',
}
)
// Create object URL from blob
return URL.createObjectURL(response.data)
},
}
export interface PhotoSearchResult {

View File

@ -334,7 +334,7 @@ export default function ApproveIdentified() {
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${pending.face_id}/crop`}
src={`${apiClient.defaults.baseURL}/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"
@ -353,7 +353,7 @@ export default function ApproveIdentified() {
</div>
) : (
<img
src={`/api/v1/faces/${pending.face_id}/crop`}
src={`${apiClient.defaults.baseURL}/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"

View File

@ -806,7 +806,7 @@ export default function AutoMatch() {
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${currentPerson.reference_face_id}/crop`}
src={`${apiClient.defaults.baseURL}/api/v1/faces/${currentPerson.reference_face_id}/crop`}
alt="Reference face"
className="max-w-[300px] max-h-[300px] rounded border border-gray-300"
/>
@ -875,7 +875,7 @@ export default function AutoMatch() {
title="Click to open full photo"
>
<img
src={`/api/v1/faces/${match.id}/crop`}
src={`${apiClient.defaults.baseURL}/api/v1/faces/${match.id}/crop`}
alt="Match face"
className="w-20 h-20 object-cover rounded border border-gray-300"
/>

View File

@ -2,6 +2,7 @@ import { useEffect, useState, useRef, useCallback } from 'react'
import peopleApi, { PersonWithFaces, PersonFaceItem, PersonVideoItem, PersonUpdateRequest } from '../api/people'
import facesApi from '../api/faces'
import videosApi from '../api/videos'
import { apiClient } from '../api/client'
interface EditDialogProps {
person: PersonWithFaces
@ -879,12 +880,12 @@ export default function Modify() {
<div key={face.id} className="flex flex-col items-center">
<div className="w-20 h-20 mb-2">
<img
src={`/api/v1/faces/${face.id}/crop`}
src={`${apiClient.defaults.baseURL}/api/v1/faces/${face.id}/crop`}
alt={`Face ${face.id}`}
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => {
// Open photo in new window
window.open(`/api/v1/photos/${face.photo_id}/image`, '_blank')
window.open(`${apiClient.defaults.baseURL}/api/v1/photos/${face.photo_id}/image`, '_blank')
}}
title="Click to show original photo"
onError={(e) => {

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react'
import { useEffect, useState, useCallback, useRef } from 'react'
import {
reportedPhotosApi,
ReportedPhotoResponse,
@ -18,6 +18,8 @@ export default function ReportedPhotos() {
const [submitting, setSubmitting] = useState(false)
const [clearing, setClearing] = useState(false)
const [statusFilter, setStatusFilter] = useState<string>('pending')
const [imageUrls, setImageUrls] = useState<Record<number, string>>({})
const imageUrlsRef = useRef<Record<number, string>>({})
const loadReportedPhotos = useCallback(async () => {
setLoading(true)
@ -36,6 +38,18 @@ export default function ReportedPhotos() {
}
})
setReviewNotes(existingNotes)
// Create direct backend URLs for images (only for non-video photos)
const newImageUrls: Record<number, string> = {}
const baseURL = apiClient.defaults.baseURL || 'http://10.0.10.121:8000'
response.items.forEach((reported) => {
if (reported.photo_id && reported.photo_media_type !== 'video') {
// Use direct backend URL - the backend endpoint doesn't require auth for images
newImageUrls[reported.photo_id] = `${baseURL}/api/v1/photos/${reported.photo_id}/image`
}
})
setImageUrls(newImageUrls)
imageUrlsRef.current = newImageUrls
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load reported photos')
console.error('Error loading reported photos:', err)
@ -43,6 +57,15 @@ export default function ReportedPhotos() {
setLoading(false)
}
}, [statusFilter])
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
Object.values(imageUrlsRef.current).forEach((url) => {
URL.revokeObjectURL(url)
})
}
}, [])
useEffect(() => {
loadReportedPhotos()
@ -364,9 +387,10 @@ export default function ReportedPhotos() {
}
}}
/>
) : (
) : imageUrls[reported.photo_id] ? (
<img
src={`/api/v1/photos/${reported.photo_id}/image`}
key={`photo-${reported.photo_id}-${imageUrls[reported.photo_id]}`}
src={imageUrls[reported.photo_id]}
alt={`Photo ${reported.photo_id}`}
className="w-24 h-24 object-cover rounded border border-gray-300"
loading="lazy"
@ -383,6 +407,10 @@ export default function ReportedPhotos() {
}
}}
/>
) : (
<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>
) : (

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'
import tagsApi, { PhotoWithTagsItem, TagResponse } from '../api/tags'
import { useDeveloperMode } from '../context/DeveloperModeContext'
import { apiClient } from '../api/client'
type ViewMode = 'list' | 'icons' | 'compact'
@ -753,7 +754,7 @@ export default function Tags() {
<td className="p-2">{photo.id}</td>
<td className="p-2">
<a
href={`/api/v1/photos/${photo.id}/image`}
href={`${apiClient.defaults.baseURL}/api/v1/photos/${photo.id}/image`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
@ -871,7 +872,7 @@ export default function Tags() {
{folderStates[folder.folderPath] === true && (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{folder.photos.map(photo => {
const photoUrl = `/api/v1/photos/${photo.id}/image`
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${photo.id}/image`
const isSelected = selectedPhotoIds.has(photo.id)
return (

View File

@ -469,7 +469,7 @@ export default function UserTaggedPhotos() {
/>
) : (
<img
src={`/api/v1/photos/${linkage.photo_id}/image`}
src={`${apiClient.defaults.baseURL}/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"

View File

@ -185,14 +185,14 @@ export async function POST(request: NextRequest) {
const pendingConditions: any[] = [];
if (tagIds.length > 0) {
pendingConditions.push({
photo_id: { in: photoIds },
tag_id: { in: tagIds },
photoId: { in: photoIds },
tagId: { in: tagIds },
});
}
normalizedNewNames.forEach((name) => {
pendingConditions.push({
photo_id: { in: photoIds },
tag_name: name,
photoId: { in: photoIds },
tagName: name,
});
});

View File

@ -1,7 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Tag as TagModel } from '@prisma/client';
import {
Dialog,
DialogContent,
@ -16,11 +15,18 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Loader2, Tag as TagIcon, X } from 'lucide-react';
interface Tag {
id: number;
tagName?: string;
tag_name?: string;
created_date?: Date | string | null;
}
interface TagSelectionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
photoIds: number[];
tags: TagModel[];
tags: Tag[];
onSuccess?: () => void;
}
@ -44,7 +50,7 @@ export function TagSelectionDialog({
return tags;
}
const query = searchQuery.toLowerCase();
return tags.filter((tag) => tag.tag_name.toLowerCase().includes(query));
return tags.filter((tag) => (tag.tagName || tag.tag_name || '').toLowerCase().includes(query));
}, [searchQuery, tags]);
useEffect(() => {
@ -197,7 +203,7 @@ export function TagSelectionDialog({
checked={selectedTagIds.includes(tag.id)}
onCheckedChange={() => toggleTagSelection(tag.id)}
/>
<span className="text-sm">{tag.tag_name}</span>
<span className="text-sm">{tag.tagName || tag.tag_name}</span>
</label>
))
)}
@ -214,7 +220,7 @@ export function TagSelectionDialog({
className="flex items-center gap-1"
>
<TagIcon className="h-3 w-3" />
{tag.tag_name}
{tag.tagName || tag.tag_name}
</Badge>
);
})}