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:
Justin Edmund 2025-06-24 01:13:49 +01:00
parent 02e41ed3d6
commit 78663151a8
6 changed files with 99 additions and 139 deletions

View file

@ -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' }

View file

@ -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
})

View file

@ -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,

View file

@ -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({

View file

@ -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
}

View file

@ -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(