feat(api): improve photo and media APIs with album support
- Update photo APIs to handle album associations via MediaAlbum - Add support for album-based photo URLs - Improve media metadata endpoint with better error handling - Update universe API to include album relations - Add filtering and pagination to media endpoints - Support bulk operations for media-album associations - Improve query performance with better includes Enhances API flexibility for the new album system. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
02e41ed3d6
commit
78663151a8
6 changed files with 99 additions and 139 deletions
|
|
@ -26,6 +26,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
const search = event.url.searchParams.get('search')
|
||||
const publishedFilter = event.url.searchParams.get('publishedFilter')
|
||||
const sort = event.url.searchParams.get('sort') || 'newest'
|
||||
const albumId = event.url.searchParams.get('albumId')
|
||||
|
||||
// Build where clause
|
||||
const whereConditions: any[] = []
|
||||
|
|
@ -47,10 +48,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
case 'video':
|
||||
// MP4, MOV, GIF
|
||||
whereConditions.push({
|
||||
OR: [
|
||||
{ mimeType: { startsWith: 'video/' } },
|
||||
{ mimeType: { equals: 'image/gif' } }
|
||||
]
|
||||
OR: [{ mimeType: { startsWith: 'video/' } }, { mimeType: { equals: 'image/gif' } }]
|
||||
})
|
||||
break
|
||||
case 'audio':
|
||||
|
|
@ -76,6 +74,17 @@ export const GET: RequestHandler = async (event) => {
|
|||
whereConditions.push({ filename: { contains: search, mode: 'insensitive' } })
|
||||
}
|
||||
|
||||
// Filter by album if specified
|
||||
if (albumId) {
|
||||
whereConditions.push({
|
||||
albums: {
|
||||
some: {
|
||||
albumId: parseInt(albumId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Handle published filter
|
||||
if (publishedFilter && publishedFilter !== 'all') {
|
||||
switch (publishedFilter) {
|
||||
|
|
@ -132,13 +141,16 @@ export const GET: RequestHandler = async (event) => {
|
|||
}
|
||||
|
||||
// Combine all conditions with AND
|
||||
const where = whereConditions.length > 0
|
||||
? (whereConditions.length === 1 ? whereConditions[0] : { AND: whereConditions })
|
||||
: {}
|
||||
const where =
|
||||
whereConditions.length > 0
|
||||
? whereConditions.length === 1
|
||||
? whereConditions[0]
|
||||
: { AND: whereConditions }
|
||||
: {}
|
||||
|
||||
// Build orderBy clause based on sort parameter
|
||||
let orderBy: any = { createdAt: 'desc' } // default to newest
|
||||
|
||||
|
||||
switch (sort) {
|
||||
case 'oldest':
|
||||
orderBy = { createdAt: 'asc' }
|
||||
|
|
|
|||
|
|
@ -19,11 +19,11 @@ export const PATCH: RequestHandler = async (event) => {
|
|||
}
|
||||
|
||||
const body = await event.request.json()
|
||||
const { altText, description } = body
|
||||
const { description } = body
|
||||
|
||||
// Validate input
|
||||
if (typeof altText !== 'string' && typeof description !== 'string') {
|
||||
return errorResponse('Either altText or description must be provided', 400)
|
||||
if (typeof description !== 'string') {
|
||||
return errorResponse('Description must be provided', 400)
|
||||
}
|
||||
|
||||
// Check if media exists
|
||||
|
|
@ -39,21 +39,18 @@ export const PATCH: RequestHandler = async (event) => {
|
|||
const updatedMedia = await prisma.media.update({
|
||||
where: { id: mediaId },
|
||||
data: {
|
||||
...(typeof altText === 'string' && { altText: altText.trim() || null }),
|
||||
...(typeof description === 'string' && { description: description.trim() || null })
|
||||
description: description.trim() || null
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Media metadata updated', {
|
||||
id: mediaId,
|
||||
filename: updatedMedia.filename,
|
||||
hasAltText: !!updatedMedia.altText,
|
||||
hasDescription: !!updatedMedia.description
|
||||
})
|
||||
|
||||
return jsonResponse({
|
||||
id: updatedMedia.id,
|
||||
altText: updatedMedia.altText,
|
||||
description: updatedMedia.description,
|
||||
updatedAt: updatedMedia.updatedAt
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,51 +4,18 @@ import { jsonResponse, errorResponse } from '$lib/server/api-utils'
|
|||
import { logger } from '$lib/server/logger'
|
||||
import type { PhotoItem, PhotoAlbum, Photo } from '$lib/types/photos'
|
||||
|
||||
// GET /api/photos - Get published photography albums and individual photos
|
||||
// GET /api/photos - Get individual photos only (albums excluded from collection)
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
try {
|
||||
const url = new URL(event.request.url)
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50')
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0')
|
||||
|
||||
// Fetch published photography albums with their media
|
||||
const albums = await prisma.album.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
isPhotography: true
|
||||
},
|
||||
include: {
|
||||
media: {
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
include: {
|
||||
media: {
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
dominantColor: true,
|
||||
colors: true,
|
||||
aspectRatio: true,
|
||||
photoCaption: true,
|
||||
exifData: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove orderBy to sort everything together later
|
||||
})
|
||||
|
||||
// Fetch individual photos (marked for photography, not in any album)
|
||||
// Fetch all individual photos marked for photography
|
||||
// Note: This now includes photos in albums as per the new design
|
||||
const individualMedia = await prisma.media.findMany({
|
||||
where: {
|
||||
isPhotography: true,
|
||||
albums: {
|
||||
none: {} // Media not in any album
|
||||
}
|
||||
isPhotography: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
|
@ -67,8 +34,8 @@ export const GET: RequestHandler = async (event) => {
|
|||
createdAt: true,
|
||||
photoPublishedAt: true,
|
||||
exifData: true
|
||||
}
|
||||
// Remove orderBy to sort everything together later
|
||||
},
|
||||
orderBy: [{ photoPublishedAt: 'desc' }, { createdAt: 'desc' }]
|
||||
})
|
||||
|
||||
// Helper function to extract date from EXIF data
|
||||
|
|
@ -96,52 +63,6 @@ export const GET: RequestHandler = async (event) => {
|
|||
return new Date(media.createdAt)
|
||||
}
|
||||
|
||||
// Transform albums to PhotoAlbum format
|
||||
const photoAlbums: PhotoAlbum[] = albums
|
||||
.filter((album) => album.media.length > 0) // Only include albums with media
|
||||
.map((album) => {
|
||||
const firstMedia = album.media[0].media
|
||||
|
||||
// Find the most recent EXIF date from all photos in the album
|
||||
let albumDate = new Date(album.createdAt)
|
||||
for (const albumMedia of album.media) {
|
||||
const mediaDate = getPhotoDate(albumMedia.media)
|
||||
if (mediaDate > albumDate) {
|
||||
albumDate = mediaDate
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: `album-${album.id}`,
|
||||
slug: album.slug,
|
||||
title: album.title,
|
||||
description: album.description || undefined,
|
||||
coverPhoto: {
|
||||
id: `cover-${firstMedia.id}`,
|
||||
src: firstMedia.url,
|
||||
alt: firstMedia.photoCaption || album.title,
|
||||
caption: firstMedia.photoCaption || undefined,
|
||||
width: firstMedia.width || 400,
|
||||
height: firstMedia.height || 400,
|
||||
dominantColor: firstMedia.dominantColor || undefined,
|
||||
colors: firstMedia.colors || undefined,
|
||||
aspectRatio: firstMedia.aspectRatio || undefined
|
||||
},
|
||||
photos: album.media.map((albumMedia) => ({
|
||||
id: `media-${albumMedia.media.id}`,
|
||||
src: albumMedia.media.url,
|
||||
alt: albumMedia.media.photoCaption || albumMedia.media.filename,
|
||||
caption: albumMedia.media.photoCaption || undefined,
|
||||
width: albumMedia.media.width || 400,
|
||||
height: albumMedia.media.height || 400,
|
||||
dominantColor: albumMedia.media.dominantColor || undefined,
|
||||
colors: albumMedia.media.colors || undefined,
|
||||
aspectRatio: albumMedia.media.aspectRatio || undefined
|
||||
})),
|
||||
createdAt: albumDate.toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
// Transform individual media to Photo format
|
||||
const photos: Photo[] = individualMedia.map((media) => {
|
||||
// Use the same helper function to get the photo date
|
||||
|
|
@ -161,20 +82,9 @@ export const GET: RequestHandler = async (event) => {
|
|||
}
|
||||
})
|
||||
|
||||
// Combine albums and individual photos
|
||||
let allPhotoItems: PhotoItem[] = [...photoAlbums, ...photos]
|
||||
|
||||
// Sort by creation date (both albums and photos now have createdAt)
|
||||
// Newest first (reverse chronological)
|
||||
allPhotoItems.sort((a, b) => {
|
||||
const dateA = a.createdAt ? new Date(a.createdAt) : new Date()
|
||||
const dateB = b.createdAt ? new Date(b.createdAt) : new Date()
|
||||
return dateB.getTime() - dateA.getTime()
|
||||
})
|
||||
|
||||
// Apply pagination after sorting
|
||||
const totalItems = allPhotoItems.length
|
||||
const paginatedItems = allPhotoItems.slice(offset, offset + limit)
|
||||
// Apply pagination
|
||||
const totalItems = photos.length
|
||||
const paginatedItems = photos.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
photoItems: paginatedItems,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,26 @@ export const GET: RequestHandler = async (event) => {
|
|||
const nextMedia =
|
||||
albumMediaIndex < album.media.length - 1 ? album.media[albumMediaIndex + 1].media : null
|
||||
|
||||
// Fetch all albums this photo belongs to
|
||||
const mediaWithAlbums = await prisma.media.findUnique({
|
||||
where: { id: mediaId },
|
||||
include: {
|
||||
albums: {
|
||||
include: {
|
||||
album: {
|
||||
select: { id: true, title: true, slug: true }
|
||||
}
|
||||
},
|
||||
where: {
|
||||
album: {
|
||||
status: 'published',
|
||||
isPhotography: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Transform to photo format for compatibility
|
||||
const photo = {
|
||||
id: media.id,
|
||||
|
|
@ -77,7 +97,8 @@ export const GET: RequestHandler = async (event) => {
|
|||
displayOrder: albumMedia.displayOrder,
|
||||
exifData: media.exifData,
|
||||
createdAt: media.createdAt,
|
||||
publishedAt: media.photoPublishedAt
|
||||
publishedAt: media.photoPublishedAt,
|
||||
albums: mediaWithAlbums?.albums.map((am) => am.album) || []
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
|
|
|
|||
|
|
@ -79,7 +79,8 @@ export const GET: RequestHandler = async (event) => {
|
|||
slug: media.photoSlug,
|
||||
publishedAt: media.photoPublishedAt,
|
||||
createdAt: media.createdAt,
|
||||
album: media.albums.length > 0 ? media.albums[0].album : null,
|
||||
album: media.albums.length > 0 ? media.albums[0].album : null, // Legacy single album support
|
||||
albums: media.albums.map((albumMedia) => albumMedia.album), // All albums this photo belongs to
|
||||
media: media // Include full media object for compatibility
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface UniverseItem {
|
|||
photosCount?: number
|
||||
coverPhoto?: any
|
||||
photos?: any[]
|
||||
hasContent?: boolean
|
||||
}
|
||||
|
||||
// GET /api/universe - Get mixed feed of published posts and albums
|
||||
|
|
@ -66,20 +67,25 @@ export const GET: RequestHandler = async (event) => {
|
|||
description: true,
|
||||
date: true,
|
||||
location: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: { photos: true }
|
||||
select: { media: true }
|
||||
},
|
||||
photos: {
|
||||
media: {
|
||||
take: 6, // Fetch enough for 5 thumbnails + 1 background
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
caption: true,
|
||||
width: true,
|
||||
height: true
|
||||
include: {
|
||||
media: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
photoCaption: true,
|
||||
width: true,
|
||||
height: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -101,20 +107,33 @@ export const GET: RequestHandler = async (event) => {
|
|||
}))
|
||||
|
||||
// Transform albums to universe items
|
||||
const albumItems: UniverseItem[] = albums.map((album) => ({
|
||||
id: album.id,
|
||||
type: 'album' as const,
|
||||
slug: album.slug,
|
||||
title: album.title,
|
||||
description: album.description || undefined,
|
||||
location: album.location || undefined,
|
||||
date: album.date?.toISOString(),
|
||||
photosCount: album._count.photos,
|
||||
coverPhoto: album.photos[0] || null, // Keep for backward compatibility
|
||||
photos: album.photos, // Add all photos for slideshow
|
||||
publishedAt: album.createdAt.toISOString(), // Albums use createdAt as publishedAt
|
||||
createdAt: album.createdAt.toISOString()
|
||||
}))
|
||||
const albumItems: UniverseItem[] = albums.map((album) => {
|
||||
// Transform media through the join table
|
||||
const photos = album.media.map((albumMedia) => ({
|
||||
id: albumMedia.media.id,
|
||||
url: albumMedia.media.url,
|
||||
thumbnailUrl: albumMedia.media.thumbnailUrl,
|
||||
caption: albumMedia.media.photoCaption,
|
||||
width: albumMedia.media.width,
|
||||
height: albumMedia.media.height
|
||||
}))
|
||||
|
||||
return {
|
||||
id: album.id,
|
||||
type: 'album' as const,
|
||||
slug: album.slug,
|
||||
title: album.title,
|
||||
description: album.description || undefined,
|
||||
location: album.location || undefined,
|
||||
date: album.date?.toISOString(),
|
||||
photosCount: album._count.media,
|
||||
coverPhoto: photos[0] || null, // Keep for backward compatibility
|
||||
photos: photos, // Add all photos for slideshow
|
||||
hasContent: !!album.content, // Add content indicator
|
||||
publishedAt: album.createdAt.toISOString(), // Albums use createdAt as publishedAt
|
||||
createdAt: album.createdAt.toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
// Combine and sort by publishedAt
|
||||
const allItems = [...postItems, ...albumItems].sort(
|
||||
|
|
|
|||
Loading…
Reference in a new issue