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
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:
parent
845273cfd3
commit
afaacf7403
@ -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
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user