feat: Add photo_media_type field to API responses and enhance media handling in frontend

This commit introduces a new `photo_media_type` field in the `PendingLinkageResponse` and `ReportedPhotoResponse` interfaces, allowing differentiation between image and video files. The frontend has been updated to handle video links appropriately, including opening video files directly and displaying video thumbnails. Additionally, the search functionality has been enhanced to exclude videos when searching for "Photos without faces." Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09 2025-12-02 16:19:02 -05:00
parent e0e5aae2ff
commit 0c212348f6
8 changed files with 135 additions and 54 deletions

View File

@ -15,6 +15,7 @@ export interface PendingLinkageResponse {
updated_at: string | null
photo_filename: string | null
photo_path: string | null
photo_media_type: string | null
photo_tags: string[]
}

View File

@ -14,6 +14,7 @@ export interface ReportedPhotoResponse {
report_comment: string | null
photo_path: string | null
photo_filename: string | null
photo_media_type: string | null
}
export interface ReportedPhotosListResponse {

View File

@ -2,6 +2,7 @@ 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'
@ -625,22 +626,42 @@ export default function PendingPhotos() {
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={async () => {
// For full-size view, 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
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')
}
} catch (err) {
console.error('Failed to open full-size image:', err)
alert('Failed to load full-size image')
}
}}
title="Click to open full photo"
title={photo.mime_type?.startsWith('video/') ? 'Click to open video' : 'Click to open full photo'}
>
{imageUrls[photo.id] ? (
{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}

View File

@ -6,6 +6,7 @@ import {
} 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()
@ -336,29 +337,53 @@ export default function ReportedPhotos() {
<div
className="cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${reported.photo_id}/image`
window.open(photoUrl, '_blank')
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="Click to open full photo"
title={reported.photo_media_type === 'video' ? 'Click to open video' : 'Click to open full photo'}
>
<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)
}
}}
/>
{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>

View File

@ -93,8 +93,11 @@ export default function Search() {
page_size: pageSize,
}
// Add media type filter if not 'all'
if (mediaType && mediaType !== 'all') {
// For "Photos without faces" search, always exclude videos
if (searchType === 'no_faces') {
params.media_type = 'image'
} else if (mediaType && mediaType !== 'all') {
// Add media type filter if not 'all' for other search types
params.media_type = mediaType
}

View File

@ -6,6 +6,7 @@ import {
} from '../api/pendingLinkages'
import { apiClient } from '../api/client'
import { useAuth } from '../context/AuthContext'
import { videosApi } from '../api/videos'
type DecisionValue = 'approve' | 'deny'
@ -440,28 +441,51 @@ export default function UserTaggedPhotos() {
<div
className="cursor-pointer hover:opacity-90 transition-opacity w-24"
onClick={() => {
const url = `${apiClient.defaults.baseURL}/api/v1/photos/${linkage.photo_id}/image`
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="Open photo in new tab"
title={linkage.photo_media_type === 'video' ? 'Open video in new tab' : 'Open photo in new tab'}
>
<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)
}
}}
/>
{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>

View File

@ -67,6 +67,7 @@ class PendingLinkageResponse(BaseModel):
updated_at: Optional[str] = None
photo_filename: Optional[str] = None
photo_path: Optional[str] = None
photo_media_type: Optional[str] = None
photo_tags: list[str] = Field(default_factory=list)
@ -221,6 +222,7 @@ def list_pending_linkages(
updated_at=updated_at,
photo_filename=photo.filename if photo else None,
photo_path=photo.path if photo else None,
photo_media_type=photo.media_type if photo else None,
photo_tags=photo_tags_map.get(row.photo_id, []),
)
)

View File

@ -36,6 +36,7 @@ class ReportedPhotoResponse(BaseModel):
# Photo details from main database
photo_path: Optional[str] = None
photo_filename: Optional[str] = None
photo_media_type: Optional[str] = None
class ReportedPhotosListResponse(BaseModel):
@ -147,10 +148,12 @@ def list_reported_photos(
# Get photo details from main database
photo_path = None
photo_filename = None
photo_media_type = None
photo = main_db.query(Photo).filter(Photo.id == row.photo_id).first()
if photo:
photo_path = photo.path
photo_filename = photo.filename
photo_media_type = photo.media_type
items.append(ReportedPhotoResponse(
id=row.id,
@ -166,6 +169,7 @@ def list_reported_photos(
report_comment=row.report_comment,
photo_path=photo_path,
photo_filename=photo_filename,
photo_media_type=photo_media_type,
))
return ReportedPhotosListResponse(items=items, total=len(items))