feat: Enhance photo handling in admin frontend #7

Merged
tanyar09 merged 1 commits from fix/image-urls-and-tag-display into dev 2026-01-22 13:44:23 -05:00
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>
);
})}