diff --git a/src/routes/albums/+page.svelte b/src/routes/albums/+page.svelte new file mode 100644 index 0000000..b1a813f --- /dev/null +++ b/src/routes/albums/+page.svelte @@ -0,0 +1,481 @@ + + + + {metaTags.title} + + + + {#each Object.entries(metaTags.openGraph) as [property, content]} + + {/each} + + + {#each Object.entries(metaTags.twitter) as [property, content]} + + {/each} + + + + + +
+ + + {#if error} +
+
+

Unable to load albums

+

{error}

+
+
+ {:else if allAlbums.length === 0} +
+
+

No albums yet

+

Photo albums will be added soon

+
+
+ {:else} +
+ {#each allAlbums as album} + + {#if album.coverPhoto} +
+ +
+ {:else} +
+
📷
+
+ {/if} + +
+

{album.title}

+ + {#if album.description} +

{album.description}

+ {/if} + +
+ {#if album.date} + {formatDate(album.date)} + {/if} + {#if album.location} + 📍 {album.location} + {/if} + {album.photoCount} photos + {#if album.hasContent} + 📖 Story + {/if} +
+
+
+ {/each} +
+ + + +
+ + {#snippet loading()} +
+ +
+ {/snippet} + + {#snippet error()} +
+

{lastError || 'Failed to load albums'}

+ +
+ {/snippet} + + {#snippet noData()} +
+

You've reached the end

+
+ {/snippet} +
+ {/if} +
+ + diff --git a/src/routes/albums/+page.ts b/src/routes/albums/+page.ts new file mode 100644 index 0000000..b0cf548 --- /dev/null +++ b/src/routes/albums/+page.ts @@ -0,0 +1,33 @@ +import type { PageLoad } from './$types' + +export const load: PageLoad = async ({ fetch }) => { + try { + const response = await fetch('/api/albums?limit=20&offset=0') + if (!response.ok) { + throw new Error('Failed to load albums') + } + + const data = await response.json() + return { + albums: data.albums || [], + pagination: data.pagination || { + total: 0, + limit: 20, + offset: 0, + hasMore: false + } + } + } catch (error) { + console.error('Error loading albums:', error) + return { + albums: [], + pagination: { + total: 0, + limit: 20, + offset: 0, + hasMore: false + }, + error: error instanceof Error ? error.message : 'Failed to load albums' + } + } +} diff --git a/src/routes/api/albums/+server.ts b/src/routes/api/albums/+server.ts index 6850832..53a1b50 100644 --- a/src/routes/api/albums/+server.ts +++ b/src/routes/api/albums/+server.ts @@ -1,128 +1,164 @@ import type { RequestHandler } from './$types' import { prisma } from '$lib/server/database' -import { - jsonResponse, - errorResponse, - getPaginationParams, - getPaginationMeta, - checkAdminAuth, - parseRequestBody -} from '$lib/server/api-utils' +import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils' import { logger } from '$lib/server/logger' -// GET /api/albums - List all albums +// GET /api/albums - Get published photography albums (or all albums if admin) export const GET: RequestHandler = async (event) => { try { - const { page, limit } = getPaginationParams(event.url) - const skip = (page - 1) * limit + const url = new URL(event.request.url) + const limit = parseInt(url.searchParams.get('limit') || '50') + const offset = parseInt(url.searchParams.get('offset') || '0') - // Get filter parameters - const status = event.url.searchParams.get('status') - const isPhotography = event.url.searchParams.get('isPhotography') + // Check if this is an admin request + const isAdmin = checkAdminAuth(event) - // Build where clause - const where: any = {} - if (status) { - where.status = status - } - - if (isPhotography !== null) { - where.isPhotography = isPhotography === 'true' - } - - // Get total count - const total = await prisma.album.count({ where }) - - // Get albums with photo count and photos for thumbnails + // Fetch albums - all for admin, only published for public const albums = await prisma.album.findMany({ - where, - orderBy: { createdAt: 'desc' }, - skip, - take: limit, - include: { - photos: { - select: { - id: true, - url: true, - thumbnailUrl: true, - caption: true + where: isAdmin + ? {} + : { + status: 'published' }, + include: { + media: { orderBy: { displayOrder: 'asc' }, - take: 5 // Only get first 5 photos for thumbnails + take: 1, // Only need the first photo for cover + include: { + media: { + select: { + id: true, + url: true, + thumbnailUrl: true, + width: true, + height: true, + dominantColor: true, + colors: true, + aspectRatio: true, + photoCaption: true + } + } + } }, _count: { - select: { photos: true } + select: { + media: true + } } + }, + orderBy: [{ date: 'desc' }, { createdAt: 'desc' }], + skip: offset, + take: limit + }) + + // Get total count for pagination + const totalCount = await prisma.album.count({ + where: isAdmin + ? {} + : { + status: 'published' + } + }) + + // Transform albums for response + const transformedAlbums = albums.map((album) => ({ + id: album.id, + slug: album.slug, + title: album.title, + description: album.description, + date: album.date, + location: album.location, + photoCount: album._count.media, + coverPhoto: album.media[0]?.media + ? { + id: album.media[0].media.id, + url: album.media[0].media.url, + thumbnailUrl: album.media[0].media.thumbnailUrl, + width: album.media[0].media.width, + height: album.media[0].media.height, + dominantColor: album.media[0].media.dominantColor, + colors: album.media[0].media.colors, + aspectRatio: album.media[0].media.aspectRatio, + caption: album.media[0].media.photoCaption + } + : null, + hasContent: !!album.content, // Indicates if album has composed content + // Include additional fields for admin + ...(isAdmin + ? { + status: album.status, + showInUniverse: album.showInUniverse, + publishedAt: album.publishedAt, + createdAt: album.createdAt, + updatedAt: album.updatedAt, + coverPhotoId: album.coverPhotoId, + photos: album.media.map((m) => ({ + id: m.media.id, + url: m.media.url, + thumbnailUrl: m.media.thumbnailUrl, + caption: m.media.photoCaption + })), + _count: album._count + } + : {}) + })) + + const response = { + albums: transformedAlbums, + pagination: { + total: totalCount, + limit, + offset, + hasMore: offset + limit < totalCount } - }) + } - const pagination = getPaginationMeta(total, page, limit) - - logger.info('Albums list retrieved', { total, page, limit }) - - return jsonResponse({ - albums, - pagination - }) + return jsonResponse(response) } catch (error) { - logger.error('Failed to retrieve albums', error as Error) - return errorResponse('Failed to retrieve albums', 500) + logger.error('Failed to fetch albums', error as Error) + return errorResponse('Failed to fetch albums', 500) } } -// POST /api/albums - Create a new album +// POST /api/albums - Create a new album (admin only) export const POST: RequestHandler = async (event) => { - // Check authentication + // Check admin auth if (!checkAdminAuth(event)) { return errorResponse('Unauthorized', 401) } try { - const body = await parseRequestBody<{ - slug: string - title: string - description?: string - date?: string - location?: string - coverPhotoId?: number - isPhotography?: boolean - status?: string - showInUniverse?: boolean - }>(event.request) + const body = await event.request.json() - if (!body || !body.slug || !body.title) { - return errorResponse('Missing required fields: slug, title', 400) + // Validate required fields + if (!body.title || !body.slug) { + return errorResponse('Title and slug are required', 400) } - // Check if slug already exists - const existing = await prisma.album.findUnique({ - where: { slug: body.slug } - }) - - if (existing) { - return errorResponse('Album with this slug already exists', 409) - } - - // Create album + // Create the album const album = await prisma.album.create({ data: { - slug: body.slug, title: body.title, - description: body.description, + slug: body.slug, + description: body.description || null, date: body.date ? new Date(body.date) : null, - location: body.location, - coverPhotoId: body.coverPhotoId, - isPhotography: body.isPhotography ?? false, - status: body.status ?? 'draft', - showInUniverse: body.showInUniverse ?? false + location: body.location || null, + showInUniverse: body.showInUniverse ?? false, + status: body.status || 'draft', + content: body.content || null, + publishedAt: body.status === 'published' ? new Date() : null } }) - logger.info('Album created', { id: album.id, slug: album.slug }) - return jsonResponse(album, 201) } catch (error) { logger.error('Failed to create album', error as Error) + + // Check for unique constraint violation + if (error instanceof Error && error.message.includes('Unique constraint')) { + return errorResponse('An album with this slug already exists', 409) + } + return errorResponse('Failed to create album', 500) } } diff --git a/src/routes/api/albums/[id]/+server.ts b/src/routes/api/albums/[id]/+server.ts index d2f62bc..b65921e 100644 --- a/src/routes/api/albums/[id]/+server.ts +++ b/src/routes/api/albums/[id]/+server.ts @@ -19,14 +19,14 @@ export const GET: RequestHandler = async (event) => { const album = await prisma.album.findUnique({ where: { id }, include: { - photos: { + media: { orderBy: { displayOrder: 'asc' }, include: { - media: true // Include media relation for each photo + media: true // Include media relation for each AlbumMedia } }, _count: { - select: { photos: true } + select: { media: true } } } }) @@ -35,27 +35,32 @@ export const GET: RequestHandler = async (event) => { return errorResponse('Album not found', 404) } - // Enrich photos with media information from the included relation - const photosWithMedia = album.photos.map((photo) => { - const media = photo.media - - return { - ...photo, - // Add media properties for backward compatibility - altText: media?.altText || '', - description: media?.description || photo.caption || '', - isPhotography: media?.isPhotography || false, - mimeType: media?.mimeType || 'image/jpeg', - size: media?.size || 0 - } - }) - - const albumWithEnrichedPhotos = { + // Transform the media relation to maintain backward compatibility + // The frontend may expect a 'photos' array, so we'll provide both 'media' and 'photos' + const albumWithEnrichedData = { ...album, - photos: photosWithMedia + // Keep the media relation as is + media: album.media, + // Also provide a photos array for backward compatibility if needed + photos: album.media.map((albumMedia) => ({ + id: albumMedia.media.id, + mediaId: albumMedia.media.id, + displayOrder: albumMedia.displayOrder, + filename: albumMedia.media.filename, + url: albumMedia.media.url, + thumbnailUrl: albumMedia.media.thumbnailUrl, + width: albumMedia.media.width, + height: albumMedia.media.height, + description: albumMedia.media.description || '', + isPhotography: albumMedia.media.isPhotography || false, + mimeType: albumMedia.media.mimeType || 'image/jpeg', + size: albumMedia.media.size || 0, + dominantColor: albumMedia.media.dominantColor, + aspectRatio: albumMedia.media.aspectRatio + })) } - return jsonResponse(albumWithEnrichedPhotos) + return jsonResponse(albumWithEnrichedData) } catch (error) { logger.error('Failed to retrieve album', error as Error) return errorResponse('Failed to retrieve album', 500) @@ -82,9 +87,9 @@ export const PUT: RequestHandler = async (event) => { date?: string location?: string coverPhotoId?: number - isPhotography?: boolean status?: string showInUniverse?: boolean + content?: any }>(event.request) if (!body) { @@ -121,11 +126,10 @@ export const PUT: RequestHandler = async (event) => { date: body.date !== undefined ? (body.date ? new Date(body.date) : null) : existing.date, location: body.location !== undefined ? body.location : existing.location, coverPhotoId: body.coverPhotoId !== undefined ? body.coverPhotoId : existing.coverPhotoId, - isPhotography: - body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography, status: body.status !== undefined ? body.status : existing.status, showInUniverse: - body.showInUniverse !== undefined ? body.showInUniverse : existing.showInUniverse + body.showInUniverse !== undefined ? body.showInUniverse : existing.showInUniverse, + content: body.content !== undefined ? body.content : existing.content } }) @@ -156,7 +160,7 @@ export const DELETE: RequestHandler = async (event) => { where: { id }, include: { _count: { - select: { photos: true } + select: { media: true } } } }) @@ -167,13 +171,12 @@ export const DELETE: RequestHandler = async (event) => { // Use a transaction to ensure both operations succeed or fail together await prisma.$transaction(async (tx) => { - // First, unlink all photos from this album (set albumId to null) - if (album._count.photos > 0) { - await tx.photo.updateMany({ - where: { albumId: id }, - data: { albumId: null } + // First, delete all AlbumMedia relationships for this album + if (album._count.media > 0) { + await tx.albumMedia.deleteMany({ + where: { albumId: id } }) - logger.info('Unlinked photos from album', { albumId: id, photoCount: album._count.photos }) + logger.info('Unlinked media from album', { albumId: id, mediaCount: album._count.media }) } // Then delete the album @@ -182,7 +185,7 @@ export const DELETE: RequestHandler = async (event) => { }) }) - logger.info('Album deleted', { id, slug: album.slug, photosUnlinked: album._count.photos }) + logger.info('Album deleted', { id, slug: album.slug, mediaUnlinked: album._count.media }) return new Response(null, { status: 204 }) } catch (error) { diff --git a/src/routes/api/albums/[id]/media/+server.ts b/src/routes/api/albums/[id]/media/+server.ts new file mode 100644 index 0000000..b1cbfb6 --- /dev/null +++ b/src/routes/api/albums/[id]/media/+server.ts @@ -0,0 +1,135 @@ +import type { RequestHandler } from './$types' +import { prisma } from '$lib/server/database' +import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils' +import { logger } from '$lib/server/logger' + +// POST /api/albums/[id]/media - Add media to album (bulk operation) +export const POST: RequestHandler = async (event) => { + // Check admin auth + if (!checkAdminAuth(event)) { + return errorResponse('Unauthorized', 401) + } + + try { + const albumId = parseInt(event.params.id) + const body = await event.request.json() + const { mediaIds } = body + + if (!Array.isArray(mediaIds) || mediaIds.length === 0) { + return errorResponse('Media IDs are required', 400) + } + + // Check if album exists + const album = await prisma.album.findUnique({ + where: { id: albumId } + }) + + if (!album) { + return errorResponse('Album not found', 404) + } + + // Get current max display order + const maxOrderResult = await prisma.albumMedia.findFirst({ + where: { albumId }, + orderBy: { displayOrder: 'desc' }, + select: { displayOrder: true } + }) + + let currentOrder = maxOrderResult?.displayOrder || 0 + + // Create album-media associations + const albumMediaData = mediaIds.map((mediaId: number) => ({ + albumId, + mediaId, + displayOrder: ++currentOrder + })) + + // Use createMany with skipDuplicates to avoid errors if media already in album + await prisma.albumMedia.createMany({ + data: albumMediaData, + skipDuplicates: true + }) + + // Get updated count + const updatedCount = await prisma.albumMedia.count({ + where: { albumId } + }) + + return jsonResponse({ + message: 'Media added to album successfully', + mediaCount: updatedCount + }) + } catch (error) { + logger.error('Failed to add media to album', error as Error) + return errorResponse('Failed to add media to album', 500) + } +} + +// DELETE /api/albums/[id]/media - Remove media from album (bulk operation) +export const DELETE: RequestHandler = async (event) => { + // Check admin auth + if (!checkAdminAuth(event)) { + return errorResponse('Unauthorized', 401) + } + + try { + const albumId = parseInt(event.params.id) + const body = await event.request.json() + const { mediaIds } = body + + if (!Array.isArray(mediaIds) || mediaIds.length === 0) { + return errorResponse('Media IDs are required', 400) + } + + // Check if album exists + const album = await prisma.album.findUnique({ + where: { id: albumId } + }) + + if (!album) { + return errorResponse('Album not found', 404) + } + + // Delete album-media associations + await prisma.albumMedia.deleteMany({ + where: { + albumId, + mediaId: { in: mediaIds } + } + }) + + // Get updated count + const updatedCount = await prisma.albumMedia.count({ + where: { albumId } + }) + + // Reorder remaining media to fill gaps + const remainingMedia = await prisma.albumMedia.findMany({ + where: { albumId }, + orderBy: { displayOrder: 'asc' } + }) + + // Update display order to remove gaps + for (let i = 0; i < remainingMedia.length; i++) { + if (remainingMedia[i].displayOrder !== i + 1) { + await prisma.albumMedia.update({ + where: { + albumId_mediaId: { + albumId: remainingMedia[i].albumId, + mediaId: remainingMedia[i].mediaId + } + }, + data: { displayOrder: i + 1 } + }) + } + } + + return jsonResponse({ + message: 'Media removed from album successfully', + mediaCount: updatedCount + }) + } catch (error) { + logger.error('Failed to remove media from album', error as Error) + return errorResponse('Failed to remove media from album', 500) + } +} diff --git a/src/routes/api/albums/by-slug/[slug]/+server.ts b/src/routes/api/albums/by-slug/[slug]/+server.ts index 0546763..fcc5b95 100644 --- a/src/routes/api/albums/by-slug/[slug]/+server.ts +++ b/src/routes/api/albums/by-slug/[slug]/+server.ts @@ -38,6 +38,9 @@ export const GET: RequestHandler = async (event) => { } } }, + geoLocations: { + orderBy: { order: 'asc' } + }, _count: { select: { media: true diff --git a/src/routes/api/media/[id]/albums/+server.ts b/src/routes/api/media/[id]/albums/+server.ts new file mode 100644 index 0000000..abb6827 --- /dev/null +++ b/src/routes/api/media/[id]/albums/+server.ts @@ -0,0 +1,127 @@ +import type { RequestHandler } from './$types' +import { prisma } from '$lib/server/database' +import { checkAdminAuth, errorResponse, jsonResponse } from '$lib/server/api-utils' + +export const GET: RequestHandler = async (event) => { + try { + const mediaId = parseInt(event.params.id) + + // Check if this is an admin request + const authCheck = await checkAdminAuth(event) + const isAdmin = authCheck.isAuthenticated + + // Get all albums associated with this media item + const albumMedia = await prisma.albumMedia.findMany({ + where: { + mediaId: mediaId + }, + include: { + album: { + select: { + id: true, + slug: true, + title: true, + description: true, + date: true, + location: true, + status: true, + showInUniverse: true, + coverPhotoId: true, + publishedAt: true + } + } + }, + orderBy: { + album: { + date: 'desc' + } + } + }) + + // Extract just the album data + let albums = albumMedia.map((am) => am.album) + + // Only filter by status if not admin + if (!isAdmin) { + albums = albums.filter((album) => album.status === 'published') + } + + return jsonResponse({ albums }) + } catch (error) { + console.error('Error fetching albums for media:', error) + return errorResponse('Failed to fetch albums', 500) + } +} + +export const PUT: RequestHandler = async (event) => { + // Check authentication + const authCheck = await checkAdminAuth(event) + if (!authCheck.isAuthenticated) { + return errorResponse('Unauthorized', 401) + } + + try { + const mediaId = parseInt(event.params.id) + const { albumIds } = await event.request.json() + + if (!Array.isArray(albumIds)) { + return errorResponse('albumIds must be an array', 400) + } + + // Start a transaction to update album associations + await prisma.$transaction(async (tx) => { + // First, remove all existing album associations + await tx.albumMedia.deleteMany({ + where: { + mediaId: mediaId + } + }) + + // Then, create new associations + if (albumIds.length > 0) { + // Get the max display order for each album + const albumOrders = await Promise.all( + albumIds.map(async (albumId) => { + const maxOrder = await tx.albumMedia.aggregate({ + where: { albumId: albumId }, + _max: { displayOrder: true } + }) + return { + albumId: albumId, + displayOrder: (maxOrder._max.displayOrder || 0) + 1 + } + }) + ) + + // Create new associations + await tx.albumMedia.createMany({ + data: albumOrders.map(({ albumId, displayOrder }) => ({ + albumId: albumId, + mediaId: mediaId, + displayOrder: displayOrder + })) + }) + } + }) + + // Fetch the updated albums + const updatedAlbumMedia = await prisma.albumMedia.findMany({ + where: { + mediaId: mediaId + }, + include: { + album: true + } + }) + + const albums = updatedAlbumMedia.map((am) => am.album) + + return jsonResponse({ + success: true, + albums: albums + }) + } catch (error) { + console.error('Error updating media albums:', error) + return errorResponse('Failed to update albums', 500) + } +}