feat: Enhance photo handling in admin frontend #7
@ -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