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}
+
+
+
+
+
+
+ {#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)
+ }
+}