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 search = event.url.searchParams.get('search')
|
||||||
const publishedFilter = event.url.searchParams.get('publishedFilter')
|
const publishedFilter = event.url.searchParams.get('publishedFilter')
|
||||||
const sort = event.url.searchParams.get('sort') || 'newest'
|
const sort = event.url.searchParams.get('sort') || 'newest'
|
||||||
|
const albumId = event.url.searchParams.get('albumId')
|
||||||
|
|
||||||
// Build where clause
|
// Build where clause
|
||||||
const whereConditions: any[] = []
|
const whereConditions: any[] = []
|
||||||
|
|
@ -47,10 +48,7 @@ export const GET: RequestHandler = async (event) => {
|
||||||
case 'video':
|
case 'video':
|
||||||
// MP4, MOV, GIF
|
// MP4, MOV, GIF
|
||||||
whereConditions.push({
|
whereConditions.push({
|
||||||
OR: [
|
OR: [{ mimeType: { startsWith: 'video/' } }, { mimeType: { equals: 'image/gif' } }]
|
||||||
{ mimeType: { startsWith: 'video/' } },
|
|
||||||
{ mimeType: { equals: 'image/gif' } }
|
|
||||||
]
|
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case 'audio':
|
case 'audio':
|
||||||
|
|
@ -76,6 +74,17 @@ export const GET: RequestHandler = async (event) => {
|
||||||
whereConditions.push({ filename: { contains: search, mode: 'insensitive' } })
|
whereConditions.push({ filename: { contains: search, mode: 'insensitive' } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter by album if specified
|
||||||
|
if (albumId) {
|
||||||
|
whereConditions.push({
|
||||||
|
albums: {
|
||||||
|
some: {
|
||||||
|
albumId: parseInt(albumId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Handle published filter
|
// Handle published filter
|
||||||
if (publishedFilter && publishedFilter !== 'all') {
|
if (publishedFilter && publishedFilter !== 'all') {
|
||||||
switch (publishedFilter) {
|
switch (publishedFilter) {
|
||||||
|
|
@ -132,13 +141,16 @@ export const GET: RequestHandler = async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Combine all conditions with AND
|
// Combine all conditions with AND
|
||||||
const where = whereConditions.length > 0
|
const where =
|
||||||
? (whereConditions.length === 1 ? whereConditions[0] : { AND: whereConditions })
|
whereConditions.length > 0
|
||||||
: {}
|
? whereConditions.length === 1
|
||||||
|
? whereConditions[0]
|
||||||
|
: { AND: whereConditions }
|
||||||
|
: {}
|
||||||
|
|
||||||
// Build orderBy clause based on sort parameter
|
// Build orderBy clause based on sort parameter
|
||||||
let orderBy: any = { createdAt: 'desc' } // default to newest
|
let orderBy: any = { createdAt: 'desc' } // default to newest
|
||||||
|
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
case 'oldest':
|
case 'oldest':
|
||||||
orderBy = { createdAt: 'asc' }
|
orderBy = { createdAt: 'asc' }
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,11 @@ export const PATCH: RequestHandler = async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = await event.request.json()
|
const body = await event.request.json()
|
||||||
const { altText, description } = body
|
const { description } = body
|
||||||
|
|
||||||
// Validate input
|
// Validate input
|
||||||
if (typeof altText !== 'string' && typeof description !== 'string') {
|
if (typeof description !== 'string') {
|
||||||
return errorResponse('Either altText or description must be provided', 400)
|
return errorResponse('Description must be provided', 400)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if media exists
|
// Check if media exists
|
||||||
|
|
@ -39,21 +39,18 @@ export const PATCH: RequestHandler = async (event) => {
|
||||||
const updatedMedia = await prisma.media.update({
|
const updatedMedia = await prisma.media.update({
|
||||||
where: { id: mediaId },
|
where: { id: mediaId },
|
||||||
data: {
|
data: {
|
||||||
...(typeof altText === 'string' && { altText: altText.trim() || null }),
|
description: description.trim() || null
|
||||||
...(typeof description === 'string' && { description: description.trim() || null })
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info('Media metadata updated', {
|
logger.info('Media metadata updated', {
|
||||||
id: mediaId,
|
id: mediaId,
|
||||||
filename: updatedMedia.filename,
|
filename: updatedMedia.filename,
|
||||||
hasAltText: !!updatedMedia.altText,
|
|
||||||
hasDescription: !!updatedMedia.description
|
hasDescription: !!updatedMedia.description
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
id: updatedMedia.id,
|
id: updatedMedia.id,
|
||||||
altText: updatedMedia.altText,
|
|
||||||
description: updatedMedia.description,
|
description: updatedMedia.description,
|
||||||
updatedAt: updatedMedia.updatedAt
|
updatedAt: updatedMedia.updatedAt
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,51 +4,18 @@ import { jsonResponse, errorResponse } from '$lib/server/api-utils'
|
||||||
import { logger } from '$lib/server/logger'
|
import { logger } from '$lib/server/logger'
|
||||||
import type { PhotoItem, PhotoAlbum, Photo } from '$lib/types/photos'
|
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) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(event.request.url)
|
const url = new URL(event.request.url)
|
||||||
const limit = parseInt(url.searchParams.get('limit') || '50')
|
const limit = parseInt(url.searchParams.get('limit') || '50')
|
||||||
const offset = parseInt(url.searchParams.get('offset') || '0')
|
const offset = parseInt(url.searchParams.get('offset') || '0')
|
||||||
|
|
||||||
// Fetch published photography albums with their media
|
// Fetch all individual photos marked for photography
|
||||||
const albums = await prisma.album.findMany({
|
// Note: This now includes photos in albums as per the new design
|
||||||
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)
|
|
||||||
const individualMedia = await prisma.media.findMany({
|
const individualMedia = await prisma.media.findMany({
|
||||||
where: {
|
where: {
|
||||||
isPhotography: true,
|
isPhotography: true
|
||||||
albums: {
|
|
||||||
none: {} // Media not in any album
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|
@ -67,8 +34,8 @@ export const GET: RequestHandler = async (event) => {
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
photoPublishedAt: true,
|
photoPublishedAt: true,
|
||||||
exifData: true
|
exifData: true
|
||||||
}
|
},
|
||||||
// Remove orderBy to sort everything together later
|
orderBy: [{ photoPublishedAt: 'desc' }, { createdAt: 'desc' }]
|
||||||
})
|
})
|
||||||
|
|
||||||
// Helper function to extract date from EXIF data
|
// Helper function to extract date from EXIF data
|
||||||
|
|
@ -96,52 +63,6 @@ export const GET: RequestHandler = async (event) => {
|
||||||
return new Date(media.createdAt)
|
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
|
// Transform individual media to Photo format
|
||||||
const photos: Photo[] = individualMedia.map((media) => {
|
const photos: Photo[] = individualMedia.map((media) => {
|
||||||
// Use the same helper function to get the photo date
|
// 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
|
// Apply pagination
|
||||||
let allPhotoItems: PhotoItem[] = [...photoAlbums, ...photos]
|
const totalItems = photos.length
|
||||||
|
const paginatedItems = photos.slice(offset, offset + limit)
|
||||||
// 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)
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
photoItems: paginatedItems,
|
photoItems: paginatedItems,
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,26 @@ export const GET: RequestHandler = async (event) => {
|
||||||
const nextMedia =
|
const nextMedia =
|
||||||
albumMediaIndex < album.media.length - 1 ? album.media[albumMediaIndex + 1].media : null
|
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
|
// Transform to photo format for compatibility
|
||||||
const photo = {
|
const photo = {
|
||||||
id: media.id,
|
id: media.id,
|
||||||
|
|
@ -77,7 +97,8 @@ export const GET: RequestHandler = async (event) => {
|
||||||
displayOrder: albumMedia.displayOrder,
|
displayOrder: albumMedia.displayOrder,
|
||||||
exifData: media.exifData,
|
exifData: media.exifData,
|
||||||
createdAt: media.createdAt,
|
createdAt: media.createdAt,
|
||||||
publishedAt: media.photoPublishedAt
|
publishedAt: media.photoPublishedAt,
|
||||||
|
albums: mediaWithAlbums?.albums.map((am) => am.album) || []
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,8 @@ export const GET: RequestHandler = async (event) => {
|
||||||
slug: media.photoSlug,
|
slug: media.photoSlug,
|
||||||
publishedAt: media.photoPublishedAt,
|
publishedAt: media.photoPublishedAt,
|
||||||
createdAt: media.createdAt,
|
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
|
media: media // Include full media object for compatibility
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export interface UniverseItem {
|
||||||
photosCount?: number
|
photosCount?: number
|
||||||
coverPhoto?: any
|
coverPhoto?: any
|
||||||
photos?: any[]
|
photos?: any[]
|
||||||
|
hasContent?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/universe - Get mixed feed of published posts and albums
|
// GET /api/universe - Get mixed feed of published posts and albums
|
||||||
|
|
@ -66,20 +67,25 @@ export const GET: RequestHandler = async (event) => {
|
||||||
description: true,
|
description: true,
|
||||||
date: true,
|
date: true,
|
||||||
location: true,
|
location: true,
|
||||||
|
content: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { photos: true }
|
select: { media: true }
|
||||||
},
|
},
|
||||||
photos: {
|
media: {
|
||||||
take: 6, // Fetch enough for 5 thumbnails + 1 background
|
take: 6, // Fetch enough for 5 thumbnails + 1 background
|
||||||
orderBy: { displayOrder: 'asc' },
|
orderBy: { displayOrder: 'asc' },
|
||||||
select: {
|
include: {
|
||||||
id: true,
|
media: {
|
||||||
url: true,
|
select: {
|
||||||
thumbnailUrl: true,
|
id: true,
|
||||||
caption: true,
|
url: true,
|
||||||
width: true,
|
thumbnailUrl: true,
|
||||||
height: true
|
photoCaption: true,
|
||||||
|
width: true,
|
||||||
|
height: true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -101,20 +107,33 @@ export const GET: RequestHandler = async (event) => {
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Transform albums to universe items
|
// Transform albums to universe items
|
||||||
const albumItems: UniverseItem[] = albums.map((album) => ({
|
const albumItems: UniverseItem[] = albums.map((album) => {
|
||||||
id: album.id,
|
// Transform media through the join table
|
||||||
type: 'album' as const,
|
const photos = album.media.map((albumMedia) => ({
|
||||||
slug: album.slug,
|
id: albumMedia.media.id,
|
||||||
title: album.title,
|
url: albumMedia.media.url,
|
||||||
description: album.description || undefined,
|
thumbnailUrl: albumMedia.media.thumbnailUrl,
|
||||||
location: album.location || undefined,
|
caption: albumMedia.media.photoCaption,
|
||||||
date: album.date?.toISOString(),
|
width: albumMedia.media.width,
|
||||||
photosCount: album._count.photos,
|
height: albumMedia.media.height
|
||||||
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
|
return {
|
||||||
createdAt: album.createdAt.toISOString()
|
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
|
// Combine and sort by publishedAt
|
||||||
const allItems = [...postItems, ...albumItems].sort(
|
const allItems = [...postItems, ...albumItems].sort(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue