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:
parent
e0e5aae2ff
commit
0c212348f6
@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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, []),
|
||||
)
|
||||
)
|
||||
|
||||
@ -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))
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user