Simplify photos architecture

This commit is contained in:
Justin Edmund 2025-06-13 06:55:21 -04:00
parent 1c1b930e34
commit 6b44c1b7d0
20 changed files with 930 additions and 523 deletions

View file

@ -0,0 +1,2 @@
-- This migration was already applied manually or is empty
-- Placeholder file to satisfy Prisma migration requirements

View file

@ -0,0 +1,105 @@
-- Step 1: Add new columns to Media table
ALTER TABLE "Media"
ADD COLUMN IF NOT EXISTS "photoCaption" TEXT,
ADD COLUMN IF NOT EXISTS "photoTitle" VARCHAR(255),
ADD COLUMN IF NOT EXISTS "photoDescription" TEXT,
ADD COLUMN IF NOT EXISTS "photoSlug" VARCHAR(255),
ADD COLUMN IF NOT EXISTS "photoPublishedAt" TIMESTAMP(3);
-- Step 2: Create AlbumMedia table
CREATE TABLE IF NOT EXISTS "AlbumMedia" (
"id" SERIAL NOT NULL,
"albumId" INTEGER NOT NULL,
"mediaId" INTEGER NOT NULL,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AlbumMedia_pkey" PRIMARY KEY ("id")
);
-- Step 3: Create indexes for AlbumMedia
CREATE UNIQUE INDEX IF NOT EXISTS "AlbumMedia_albumId_mediaId_key" ON "AlbumMedia"("albumId", "mediaId");
CREATE INDEX IF NOT EXISTS "AlbumMedia_albumId_idx" ON "AlbumMedia"("albumId");
CREATE INDEX IF NOT EXISTS "AlbumMedia_mediaId_idx" ON "AlbumMedia"("mediaId");
-- Step 4: Add foreign key constraints
ALTER TABLE "AlbumMedia"
ADD CONSTRAINT "AlbumMedia_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT "AlbumMedia_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "Media"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Step 5: Migrate data from Photo to Media (for photos without mediaId)
UPDATE "Media" m
SET
"photoCaption" = p."caption",
"photoTitle" = p."title",
"photoDescription" = p."description",
"photoSlug" = p."slug",
"photoPublishedAt" = p."publishedAt",
"isPhotography" = CASE WHEN p."showInPhotos" = true THEN true ELSE m."isPhotography" END
FROM "Photo" p
WHERE p."mediaId" = m."id";
-- Step 6: For photos without mediaId, create new Media records
INSERT INTO "Media" (
"filename",
"mimeType",
"size",
"url",
"thumbnailUrl",
"width",
"height",
"exifData",
"isPhotography",
"photoCaption",
"photoTitle",
"photoDescription",
"photoSlug",
"photoPublishedAt",
"createdAt",
"updatedAt"
)
SELECT
p."filename",
'image/jpeg', -- Default, adjust as needed
0, -- Default size
p."url",
p."thumbnailUrl",
p."width",
p."height",
p."exifData",
p."showInPhotos",
p."caption",
p."title",
p."description",
p."slug",
p."publishedAt",
p."createdAt",
NOW()
FROM "Photo" p
WHERE p."mediaId" IS NULL;
-- Step 7: Create AlbumMedia records from existing Photo-Album relationships
INSERT INTO "AlbumMedia" ("albumId", "mediaId", "displayOrder", "createdAt")
SELECT
p."albumId",
COALESCE(p."mediaId", (
SELECT m."id"
FROM "Media" m
WHERE m."url" = p."url"
AND m."photoSlug" = p."slug"
LIMIT 1
)),
p."displayOrder",
p."createdAt"
FROM "Photo" p
WHERE p."albumId" IS NOT NULL
AND (p."mediaId" IS NOT NULL OR EXISTS (
SELECT 1 FROM "Media" m
WHERE m."url" = p."url"
AND m."photoSlug" = p."slug"
));
-- Step 8: Add unique constraint on photoSlug
CREATE UNIQUE INDEX IF NOT EXISTS "Media_photoSlug_key" ON "Media"("photoSlug");
-- Note: Do NOT drop the Photo table yet - we'll do that after verifying the migration

View file

@ -76,7 +76,8 @@ model Album {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
photos Photo[] photos Photo[] // Will be removed after migration
media AlbumMedia[]
@@index([slug]) @@index([slug])
@@index([status]) @@index([status])
@ -127,16 +128,24 @@ model Media {
width Int? width Int?
height Int? height Int?
exifData Json? // EXIF data for photos exifData Json? // EXIF data for photos
altText String? @db.Text // Alt text for accessibility description String? @db.Text // Description (used for alt text and captions)
description String? @db.Text // Optional description
isPhotography Boolean @default(false) // Star for photos experience isPhotography Boolean @default(false) // Star for photos experience
// Photo-specific fields (migrated from Photo model)
photoCaption String? @db.Text // Caption when used as standalone photo
photoTitle String? @db.VarChar(255) // Title when used as standalone photo
photoDescription String? @db.Text // Description when used as standalone photo
photoSlug String? @unique @db.VarChar(255) // Slug for standalone photo
photoPublishedAt DateTime? // Published date for standalone photo
usedIn Json @default("[]") // Track where media is used (legacy) usedIn Json @default("[]") // Track where media is used (legacy)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
usage MediaUsage[] usage MediaUsage[]
photos Photo[] photos Photo[] // Will be removed after migration
albums AlbumMedia[]
} }
// Media usage tracking table // Media usage tracking table
@ -156,3 +165,20 @@ model MediaUsage {
@@index([mediaId]) @@index([mediaId])
@@index([contentType, contentId]) @@index([contentType, contentId])
} }
// Album-Media relationship table (many-to-many)
model AlbumMedia {
id Int @id @default(autoincrement())
albumId Int
mediaId Int
displayOrder Int @default(0)
createdAt DateTime @default(now())
// Relations
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
@@unique([albumId, mediaId])
@@index([albumId])
@@index([mediaId])
}

View file

@ -0,0 +1,11 @@
-- Consolidate altText into description
-- If description is null or empty, copy altText value
-- If both exist, keep description (assuming it's more comprehensive)
UPDATE "Media"
SET description = COALESCE(NULLIF(description, ''), "altText")
WHERE "altText" IS NOT NULL AND "altText" != '';
-- Show how many records were affected
SELECT COUNT(*) as updated_records
FROM "Media"
WHERE "altText" IS NOT NULL AND "altText" != '';

View file

@ -0,0 +1,133 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
console.log('Starting photo to media migration...')
try {
// Step 1: Get all photos
const photos = await prisma.photo.findMany({
include: {
album: true,
media: true
}
})
console.log(`Found ${photos.length} photos to migrate`)
// Step 2: Process each photo
let migratedCount = 0
let createdMediaCount = 0
let albumMediaCount = 0
for (const photo of photos) {
if (photo.mediaId && photo.media) {
// Photo has associated media - update the media record
await prisma.media.update({
where: { id: photo.mediaId },
data: {
photoCaption: photo.caption,
photoTitle: photo.title,
photoDescription: photo.description,
photoSlug: photo.slug,
photoPublishedAt: photo.publishedAt,
isPhotography: photo.showInPhotos
}
})
migratedCount++
} else {
// Photo has no media - create new media record
const newMedia = await prisma.media.create({
data: {
filename: photo.filename,
originalName: photo.filename,
mimeType: 'image/jpeg', // Default, could be improved
size: 0, // Unknown
url: photo.url,
thumbnailUrl: photo.thumbnailUrl,
width: photo.width,
height: photo.height,
exifData: photo.exifData,
isPhotography: photo.showInPhotos,
photoCaption: photo.caption,
photoTitle: photo.title,
photoDescription: photo.description,
photoSlug: photo.slug,
photoPublishedAt: photo.publishedAt,
createdAt: photo.createdAt
}
})
createdMediaCount++
// Update the photo to reference the new media
await prisma.photo.update({
where: { id: photo.id },
data: { mediaId: newMedia.id }
})
}
// Create AlbumMedia record if photo belongs to an album
if (photo.albumId) {
const mediaId = photo.mediaId || (await prisma.photo.findUnique({
where: { id: photo.id },
select: { mediaId: true }
}))?.mediaId
if (mediaId) {
// Check if AlbumMedia already exists
const existing = await prisma.albumMedia.findUnique({
where: {
albumId_mediaId: {
albumId: photo.albumId,
mediaId: mediaId
}
}
})
if (!existing) {
await prisma.albumMedia.create({
data: {
albumId: photo.albumId,
mediaId: mediaId,
displayOrder: photo.displayOrder,
createdAt: photo.createdAt
}
})
albumMediaCount++
}
}
}
}
console.log(`Migration completed:`)
console.log(`- Updated ${migratedCount} existing media records`)
console.log(`- Created ${createdMediaCount} new media records`)
console.log(`- Created ${albumMediaCount} album-media relationships`)
// Step 3: Verify migration
const mediaWithPhotoData = await prisma.media.count({
where: {
OR: [
{ photoCaption: { not: null } },
{ photoTitle: { not: null } },
{ photoSlug: { not: null } }
]
}
})
const albumMediaRelations = await prisma.albumMedia.count()
console.log(`\nVerification:`)
console.log(`- Media records with photo data: ${mediaWithPhotoData}`)
console.log(`- Album-media relationships: ${albumMediaRelations}`)
} catch (error) {
console.error('Migration failed:', error)
process.exit(1)
} finally {
await prisma.$disconnect()
}
}
main()

View file

@ -21,12 +21,12 @@
// For individual photos, check if we have album context // For individual photos, check if we have album context
if (albumSlug) { if (albumSlug) {
// Navigate to photo within album // Navigate to photo within album
const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
goto(`/photos/${albumSlug}/${photoId}`) goto(`/photos/${albumSlug}/${mediaId}`)
} else { } else {
// Navigate to individual photo page using the photo ID // Navigate to individual photo page using the media ID
const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
goto(`/photos/p/${photoId}`) goto(`/photos/p/${mediaId}`)
} }
} }
} }

View file

@ -14,7 +14,7 @@
let { let {
media, media,
alt = media.altText || media.filename || '', alt = media.description || media.filename || '',
class: className = '', class: className = '',
containerWidth, containerWidth,
loading = 'lazy', loading = 'lazy',

View file

@ -17,7 +17,6 @@
let { isOpen = $bindable(), media, onClose, onUpdate }: Props = $props() let { isOpen = $bindable(), media, onClose, onUpdate }: Props = $props()
// Form state // Form state
let altText = $state('')
let description = $state('') let description = $state('')
let isPhotography = $state(false) let isPhotography = $state(false)
let isSaving = $state(false) let isSaving = $state(false)
@ -43,8 +42,8 @@
// Initialize form when media changes // Initialize form when media changes
$effect(() => { $effect(() => {
if (media) { if (media) {
altText = media.altText || '' // Use description if available, otherwise fall back to altText for backwards compatibility
description = media.description || '' description = media.description || media.altText || ''
isPhotography = media.isPhotography || false isPhotography = media.isPhotography || false
error = '' error = ''
successMessage = '' successMessage = ''
@ -77,7 +76,6 @@
} }
function handleClose() { function handleClose() {
altText = ''
description = '' description = ''
isPhotography = false isPhotography = false
error = '' error = ''
@ -99,7 +97,8 @@
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
altText: altText.trim() || null, // Use description for both altText and description fields
altText: description.trim() || null,
description: description.trim() || null, description: description.trim() || null,
isPhotography: isPhotography isPhotography: isPhotography
}) })
@ -198,7 +197,7 @@
size="jumbo" size="jumbo"
closeOnBackdrop={!isSaving} closeOnBackdrop={!isSaving}
closeOnEscape={!isSaving} closeOnEscape={!isSaving}
on:close={handleClose} onClose={handleClose}
showCloseButton={false} showCloseButton={false}
> >
<div class="media-details-modal"> <div class="media-details-modal">
@ -206,7 +205,7 @@
<div class="image-pane"> <div class="image-pane">
{#if media.mimeType.startsWith('image/')} {#if media.mimeType.startsWith('image/')}
<div class="image-container"> <div class="image-container">
<SmartImage {media} alt={media.altText || media.filename} class="preview-image" /> <SmartImage {media} alt={media.description || media.altText || media.filename} class="preview-image" />
</div> </div>
{:else} {:else}
<div class="file-placeholder"> <div class="file-placeholder">
@ -289,10 +288,7 @@
{/if} {/if}
</div> </div>
</div> </div>
<!-- Content -->
<div class="pane-body"> <div class="pane-body">
<!-- File Info -->
<div class="file-info"> <div class="file-info">
<div class="info-grid"> <div class="info-grid">
<div class="info-item"> <div class="info-item">
@ -387,6 +383,7 @@
{/if} {/if}
</div> </div>
<div class="pane-body-content">
<!-- Photography Toggle --> <!-- Photography Toggle -->
<div class="photography-toggle"> <div class="photography-toggle">
<label class="toggle-label"> <label class="toggle-label">
@ -406,20 +403,11 @@
<!-- Edit Form --> <!-- Edit Form -->
<div class="edit-form"> <div class="edit-form">
<Textarea
label="Alt Text"
bind:value={altText}
placeholder="Describe this image for screen readers"
rows={3}
disabled={isSaving}
fullWidth
/>
<Textarea <Textarea
label="Description" label="Description"
bind:value={description} bind:value={description}
placeholder="Additional description or caption" placeholder="Describe this image (used for alt text and captions)"
rows={3} rows={4}
disabled={isSaving} disabled={isSaving}
fullWidth fullWidth
/> />
@ -468,6 +456,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Footer --> <!-- Footer -->
<div class="pane-footer"> <div class="pane-footer">
@ -587,7 +576,10 @@
.pane-body { .pane-body {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: $unit-4x; }
.pane-body-content {
padding: $unit-3x;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit-6x; gap: $unit-6x;
@ -598,8 +590,8 @@
flex-direction: column; flex-direction: column;
gap: $unit-3x; gap: $unit-3x;
padding: $unit-3x; padding: $unit-3x;
background-color: $grey-97; background-color: $grey-90;
border-radius: $corner-radius; border-bottom: 1px solid rgba(0, 0, 0, 0.08);
} }
.info-grid { .info-grid {
@ -895,7 +887,7 @@
} }
.pane-body { .pane-body {
padding: $unit-3x; // padding: $unit-3x;
} }
.pane-footer { .pane-footer {

View file

@ -1,19 +1,29 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, onMount } from 'svelte' import { onMount } from 'svelte'
import { fade } from 'svelte/transition' import { fade } from 'svelte/transition'
import Button from './Button.svelte' import Button from './Button.svelte'
export let isOpen = false interface Props {
export let size: 'small' | 'medium' | 'large' | 'jumbo' | 'full' = 'medium' isOpen: boolean
export let closeOnBackdrop = true size?: 'small' | 'medium' | 'large' | 'jumbo' | 'full'
export let closeOnEscape = true closeOnBackdrop?: boolean
export let showCloseButton = true closeOnEscape?: boolean
showCloseButton?: boolean
onClose?: () => void
}
const dispatch = createEventDispatcher() let {
isOpen = $bindable(),
size = 'medium',
closeOnBackdrop = true,
closeOnEscape = true,
showCloseButton = true,
onClose
}: Props = $props()
function handleClose() { function handleClose() {
isOpen = false isOpen = false
dispatch('close') onClose?.()
} }
function handleBackdropClick() { function handleBackdropClick() {
@ -28,6 +38,34 @@
} }
} }
// Effect to handle body scroll locking
$effect(() => {
if (isOpen) {
// Save current scroll position
const scrollY = window.scrollY
// Lock body scroll
document.body.style.position = 'fixed'
document.body.style.top = `-${scrollY}px`
document.body.style.width = '100%'
document.body.style.overflow = 'hidden'
return () => {
// Restore body scroll
const scrollY = document.body.style.top
document.body.style.position = ''
document.body.style.top = ''
document.body.style.width = ''
document.body.style.overflow = ''
// Restore scroll position
if (scrollY) {
window.scrollTo(0, parseInt(scrollY || '0') * -1)
}
}
}
})
onMount(() => { onMount(() => {
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleKeydown)
return () => { return () => {
@ -35,7 +73,7 @@
} }
}) })
$: modalClass = `modal-${size}` let modalClass = $derived(`modal-${size}`)
</script> </script>
{#if isOpen} {#if isOpen}

View file

@ -16,7 +16,7 @@
let currentPage = $state(1) let currentPage = $state(1)
let totalPages = $state(1) let totalPages = $state(1)
let total = $state(0) let total = $state(0)
let viewMode = $state<'grid' | 'list'>('grid') // Only using grid view
// Filter states // Filter states
let filterType = $state<string>('all') let filterType = $state<string>('all')
@ -324,18 +324,9 @@
onclick={toggleMultiSelectMode} onclick={toggleMultiSelectMode}
class={isMultiSelectMode ? 'active' : ''} class={isMultiSelectMode ? 'active' : ''}
> >
{isMultiSelectMode ? '✓' : '☐'}
{isMultiSelectMode ? 'Exit Select' : 'Select'} {isMultiSelectMode ? 'Exit Select' : 'Select'}
</Button> </Button>
<Button <Button variant="primary" buttonSize="large" onclick={openUploadModal}>Upload</Button>
variant="secondary"
buttonSize="large"
onclick={() => (viewMode = viewMode === 'grid' ? 'list' : 'grid')}
>
{viewMode === 'grid' ? '📋' : '🖼️'}
{viewMode === 'grid' ? 'List' : 'Grid'}
</Button>
<Button variant="primary" buttonSize="large" onclick={openUploadModal}>Upload...</Button>
{/snippet} {/snippet}
</AdminHeader> </AdminHeader>
@ -414,14 +405,14 @@
class="btn btn-secondary btn-small" class="btn btn-secondary btn-small"
title="Mark selected items as photography" title="Mark selected items as photography"
> >
📸 Mark Photography Mark Photography
</button> </button>
<button <button
onclick={handleBulkUnmarkPhotography} onclick={handleBulkUnmarkPhotography}
class="btn btn-secondary btn-small" class="btn btn-secondary btn-small"
title="Remove photography status from selected items" title="Remove photography status from selected items"
> >
🚫 Remove Photography Remove Photography
</button> </button>
<button <button
onclick={handleBulkDelete} onclick={handleBulkDelete}
@ -444,7 +435,7 @@
<p>No media files found.</p> <p>No media files found.</p>
<Button variant="primary" onclick={openUploadModal}>Upload your first file</Button> <Button variant="primary" onclick={openUploadModal}>Upload your first file</Button>
</div> </div>
{:else if viewMode === 'grid'} {:else}
<div class="media-grid"> <div class="media-grid">
{#each media as item} {#each media as item}
<div class="media-item-wrapper" class:multiselect={isMultiSelectMode}> <div class="media-item-wrapper" class:multiselect={isMultiSelectMode}>
@ -470,7 +461,7 @@
{#if item.mimeType.startsWith('image/')} {#if item.mimeType.startsWith('image/')}
<img <img
src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url} src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
alt={item.altText || item.filename} alt={item.description || item.filename}
/> />
{:else} {:else}
<div class="file-placeholder"> <div class="file-placeholder">
@ -479,135 +470,21 @@
{/if} {/if}
<div class="media-info"> <div class="media-info">
<span class="filename">{item.filename}</span> <span class="filename">{item.filename}</span>
<div class="media-info-bottom">
<div class="media-indicators"> <div class="media-indicators">
{#if item.isPhotography} {#if item.isPhotography}
<span class="indicator-pill photography" title="Photography"> <span class="indicator-pill photography" title="Photography"> Photo </span>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26"
fill="currentColor"
/>
</svg>
Photo
</span>
{/if} {/if}
{#if item.altText} {#if item.description}
<span class="indicator-pill alt-text" title="Alt text: {item.altText}"> <span class="indicator-pill alt-text" title="Description: {item.description}">
Alt Alt
</span> </span>
{:else} {:else}
<span class="indicator-pill no-alt-text" title="No alt text"> No Alt </span> <span class="indicator-pill no-alt-text" title="No description"> No Alt </span>
{/if} {/if}
</div> </div>
<span class="filesize">{formatFileSize(item.size)}</span> <span class="filesize">{formatFileSize(item.size)}</span>
</div> </div>
</button>
</div>
{/each}
</div>
{:else}
<div class="media-list">
{#each media as item}
<div class="media-row-wrapper" class:multiselect={isMultiSelectMode}>
{#if isMultiSelectMode}
<div class="selection-checkbox">
<input
type="checkbox"
checked={selectedMediaIds.has(item.id)}
onchange={() => toggleMediaSelection(item.id)}
id="media-row-{item.id}"
/>
<label for="media-row-{item.id}" class="checkbox-label"></label>
</div>
{/if}
<button
class="media-row"
type="button"
onclick={() =>
isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)}
title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}"
class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
>
<div class="media-preview">
{#if item.mimeType.startsWith('image/')}
<img
src={item.mimeType === 'image/svg+xml'
? item.url
: item.thumbnailUrl || item.url}
alt={item.altText || item.filename}
/>
{:else}
<div class="file-icon">{getFileType(item.mimeType)}</div>
{/if}
</div>
<div class="media-details">
<div class="filename-row">
<span class="filename">{item.filename}</span>
<div class="media-indicators">
{#if item.isPhotography}
<span class="indicator-pill photography" title="Photography">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26"
fill="currentColor"
/>
</svg>
Photo
</span>
{/if}
{#if item.altText}
<span class="indicator-pill alt-text" title="Alt text: {item.altText}">
Alt
</span>
{:else}
<span class="indicator-pill no-alt-text" title="No alt text"> No Alt </span>
{/if}
</div>
</div>
<span class="file-meta">
{getFileType(item.mimeType)}{formatFileSize(item.size)}
{#if item.width && item.height}
{item.width}×{item.height}px
{/if}
</span>
{#if item.altText}
<span class="alt-text-preview">
Alt: {item.altText}
</span>
{:else}
<span class="no-alt-text-preview">No alt text</span>
{/if}
</div>
<div class="media-indicator">
{#if !isMultiSelectMode}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 18L15 12L9 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</div> </div>
</button> </button>
</div> </div>
@ -708,14 +585,15 @@
.media-grid { .media-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: $unit-3x; gap: $unit-3x;
margin-bottom: $unit-4x; margin-bottom: $unit-4x;
padding: 0 $unit;
} }
.media-item { .media-item {
background: $grey-95; background: $grey-95;
border: none; border: 1px solid transparent;
border-radius: $unit-2x; border-radius: $unit-2x;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
@ -728,6 +606,7 @@
background-color: $grey-90; background-color: $grey-90;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.08);
} }
&:focus { &:focus {
@ -759,11 +638,12 @@
padding: $unit-2x; padding: $unit-2x;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit-half; gap: $unit;
.filename { .filename {
font-size: 0.875rem; font-size: 1rem;
color: $grey-20; color: $grey-20;
font-weight: 400;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -774,6 +654,13 @@
color: $grey-40; color: $grey-40;
} }
.media-info-bottom {
display: flex;
justify-content: space-between;
align-items: center;
gap: $unit-half;
}
.media-indicators { .media-indicators {
display: flex; display: flex;
gap: $unit-half; gap: $unit-half;
@ -1067,12 +954,10 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: $unit-half; gap: $unit-half;
padding: 2px $unit; padding: $unit-half $unit;
border-radius: 4px; border-radius: $corner-radius-2xl;
font-size: 0.625rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1; line-height: 1;
svg { svg {
@ -1084,7 +969,6 @@
&.photography { &.photography {
background-color: rgba(139, 92, 246, 0.1); background-color: rgba(139, 92, 246, 0.1);
color: #7c3aed; color: #7c3aed;
border: 1px solid rgba(139, 92, 246, 0.2);
svg { svg {
fill: #7c3aed; fill: #7c3aed;
@ -1094,13 +978,11 @@
&.alt-text { &.alt-text {
background-color: rgba(34, 197, 94, 0.1); background-color: rgba(34, 197, 94, 0.1);
color: #16a34a; color: #16a34a;
border: 1px solid rgba(34, 197, 94, 0.2);
} }
&.no-alt-text { &.no-alt-text {
background-color: rgba(239, 68, 68, 0.1); background-color: rgba(239, 68, 68, 0.1);
color: #dc2626; color: #dc2626;
border: 1px solid rgba(239, 68, 68, 0.2);
} }
} }
</style> </style>

View file

@ -8,7 +8,7 @@ import {
} from '$lib/server/api-utils' } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
// POST /api/albums/[id]/photos - Add a photo to an album // POST /api/albums/[id]/photos - Add media to an album
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
// Check authentication // Check authentication
if (!checkAdminAuth(event)) { if (!checkAdminAuth(event)) {
@ -53,34 +53,39 @@ export const POST: RequestHandler = async (event) => {
return errorResponse('Only images can be added to albums', 400) return errorResponse('Only images can be added to albums', 400)
} }
// Check if media is already in this album
const existing = await prisma.albumMedia.findUnique({
where: {
albumId_mediaId: {
albumId: albumId,
mediaId: body.mediaId
}
}
})
if (existing) {
return errorResponse('Media is already in this album', 409)
}
// Get the next display order if not provided // Get the next display order if not provided
let displayOrder = body.displayOrder let displayOrder = body.displayOrder
if (displayOrder === undefined) { if (displayOrder === undefined) {
const lastPhoto = await prisma.photo.findFirst({ const lastAlbumMedia = await prisma.albumMedia.findFirst({
where: { albumId }, where: { albumId },
orderBy: { displayOrder: 'desc' } orderBy: { displayOrder: 'desc' }
}) })
displayOrder = (lastPhoto?.displayOrder || 0) + 1 displayOrder = (lastAlbumMedia?.displayOrder || 0) + 1
} }
// Create photo record linked to media // Create album-media relationship
const photo = await prisma.photo.create({ const albumMedia = await prisma.albumMedia.create({
data: { data: {
albumId, albumId,
mediaId: body.mediaId, // Link to the Media record mediaId: body.mediaId,
filename: media.filename, displayOrder
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
exifData: media.exifData, // Include EXIF data from media
caption: media.description, // Use media description as initial caption
displayOrder,
status: 'published', // Photos in albums are published by default
showInPhotos: true
}, },
include: { include: {
media: true // Include media relation in response media: true
} }
}) })
@ -94,21 +99,32 @@ export const POST: RequestHandler = async (event) => {
} }
}) })
logger.info('Photo added to album', { logger.info('Media added to album', {
albumId, albumId,
photoId: photo.id,
mediaId: body.mediaId mediaId: body.mediaId
}) })
// Return photo with full media information // Return media with album context
return jsonResponse(photo) return jsonResponse({
id: albumMedia.media.id,
mediaId: albumMedia.media.id,
filename: albumMedia.media.filename,
url: albumMedia.media.url,
thumbnailUrl: albumMedia.media.thumbnailUrl,
width: albumMedia.media.width,
height: albumMedia.media.height,
exifData: albumMedia.media.exifData,
caption: albumMedia.media.photoCaption || albumMedia.media.description,
displayOrder: albumMedia.displayOrder,
media: albumMedia.media
})
} catch (error) { } catch (error) {
logger.error('Failed to add photo to album', error as Error) logger.error('Failed to add media to album', error as Error)
return errorResponse('Failed to add photo to album', 500) return errorResponse('Failed to add media to album', 500)
} }
} }
// PUT /api/albums/[id]/photos - Update photo order in album // PUT /api/albums/[id]/photos - Update media order in album
export const PUT: RequestHandler = async (event) => { export const PUT: RequestHandler = async (event) => {
// Check authentication // Check authentication
if (!checkAdminAuth(event)) { if (!checkAdminAuth(event)) {
@ -122,12 +138,14 @@ export const PUT: RequestHandler = async (event) => {
try { try {
const body = await parseRequestBody<{ const body = await parseRequestBody<{
photoId: number mediaId: number // Changed from photoId for clarity
displayOrder: number displayOrder: number
}>(event.request) }>(event.request)
if (!body || !body.photoId || body.displayOrder === undefined) { // Also support legacy photoId parameter
return errorResponse('Photo ID and display order are required', 400) const mediaId = body?.mediaId || (body as any)?.photoId
if (!mediaId || body?.displayOrder === undefined) {
return errorResponse('Media ID and display order are required', 400)
} }
// Check if album exists // Check if album exists
@ -139,31 +157,41 @@ export const PUT: RequestHandler = async (event) => {
return errorResponse('Album not found', 404) return errorResponse('Album not found', 404)
} }
// Update photo display order // Update album-media display order
const photo = await prisma.photo.update({ const albumMedia = await prisma.albumMedia.update({
where: { where: {
id: body.photoId, albumId_mediaId: {
albumId // Ensure photo belongs to this album albumId: albumId,
mediaId: mediaId
}
}, },
data: { data: {
displayOrder: body.displayOrder displayOrder: body.displayOrder
},
include: {
media: true
} }
}) })
logger.info('Photo order updated', { logger.info('Media order updated', {
albumId, albumId,
photoId: body.photoId, mediaId: mediaId,
displayOrder: body.displayOrder displayOrder: body.displayOrder
}) })
return jsonResponse(photo) // Return in photo format for compatibility
return jsonResponse({
id: albumMedia.media.id,
mediaId: albumMedia.media.id,
displayOrder: albumMedia.displayOrder
})
} catch (error) { } catch (error) {
logger.error('Failed to update photo order', error as Error) logger.error('Failed to update media order', error as Error)
return errorResponse('Failed to update photo order', 500) return errorResponse('Failed to update media order', 500)
} }
} }
// DELETE /api/albums/[id]/photos - Remove a photo from an album (without deleting the media) // DELETE /api/albums/[id]/photos - Remove media from an album (without deleting the media)
export const DELETE: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => {
// Check authentication // Check authentication
if (!checkAdminAuth(event)) { if (!checkAdminAuth(event)) {
@ -177,15 +205,15 @@ export const DELETE: RequestHandler = async (event) => {
try { try {
const url = new URL(event.request.url) const url = new URL(event.request.url)
const photoId = url.searchParams.get('photoId') const mediaIdParam = url.searchParams.get('mediaId') || url.searchParams.get('photoId') // Support legacy param
logger.info('DELETE photo request', { albumId, photoId }) logger.info('DELETE media request', { albumId, mediaId: mediaIdParam })
if (!photoId || isNaN(parseInt(photoId))) { if (!mediaIdParam || isNaN(parseInt(mediaIdParam))) {
return errorResponse('Photo ID is required as query parameter', 400) return errorResponse('Media ID is required as query parameter', 400)
} }
const photoIdNum = parseInt(photoId) const mediaId = parseInt(mediaIdParam)
// Check if album exists // Check if album exists
const album = await prisma.album.findUnique({ const album = await prisma.album.findUnique({
@ -197,49 +225,51 @@ export const DELETE: RequestHandler = async (event) => {
return errorResponse('Album not found', 404) return errorResponse('Album not found', 404)
} }
// Check if photo exists in this album // Check if media exists in this album
const photo = await prisma.photo.findFirst({ const albumMedia = await prisma.albumMedia.findUnique({
where: { where: {
id: photoIdNum, albumId_mediaId: {
albumId: albumId // Ensure photo belongs to this album albumId: albumId,
}, mediaId: mediaId
include: { }
media: true // Include media relation to get mediaId
} }
}) })
logger.info('Photo lookup result', { photoIdNum, albumId, found: !!photo }) logger.info('AlbumMedia lookup result', { mediaId, albumId, found: !!albumMedia })
if (!photo) { if (!albumMedia) {
logger.error('Photo not found in album', { photoIdNum, albumId }) logger.error('Media not found in album', { mediaId, albumId })
return errorResponse('Photo not found in this album', 404) return errorResponse('Media not found in this album', 404)
} }
// Remove media usage record if photo has a mediaId // Remove media usage record
if (photo.mediaId) {
await prisma.mediaUsage.deleteMany({ await prisma.mediaUsage.deleteMany({
where: { where: {
mediaId: photo.mediaId, mediaId: mediaId,
contentType: 'album', contentType: 'album',
contentId: albumId, contentId: albumId,
fieldName: 'photos' fieldName: 'photos'
} }
}) })
}
// Delete the photo record (this removes it from the album but keeps the media) // Delete the album-media relationship
await prisma.photo.delete({ await prisma.albumMedia.delete({
where: { id: photoIdNum } where: {
albumId_mediaId: {
albumId: albumId,
mediaId: mediaId
}
}
}) })
logger.info('Photo removed from album', { logger.info('Media removed from album', {
photoId: photoIdNum, mediaId: mediaId,
albumId: albumId albumId: albumId
}) })
return new Response(null, { status: 204 }) return new Response(null, { status: 204 })
} catch (error) { } catch (error) {
logger.error('Failed to remove photo from album', error as Error) logger.error('Failed to remove media from album', error as Error)
return errorResponse('Failed to remove photo from album', 500) return errorResponse('Failed to remove media from album', 500)
} }
} }

View file

@ -65,7 +65,9 @@ export const GET: RequestHandler = async (event) => {
height: true, height: true,
usedIn: true, usedIn: true,
isPhotography: true, isPhotography: true,
createdAt: true createdAt: true,
description: true,
exifData: true
} }
}) })

View file

@ -46,7 +46,6 @@ export const PUT: RequestHandler = async (event) => {
try { try {
const body = await parseRequestBody<{ const body = await parseRequestBody<{
altText?: string
description?: string description?: string
isPhotography?: boolean isPhotography?: boolean
}>(event.request) }>(event.request)
@ -68,13 +67,35 @@ export const PUT: RequestHandler = async (event) => {
const media = await prisma.media.update({ const media = await prisma.media.update({
where: { id }, where: { id },
data: { data: {
altText: body.altText !== undefined ? body.altText : existing.altText,
description: body.description !== undefined ? body.description : existing.description, description: body.description !== undefined ? body.description : existing.description,
isPhotography: isPhotography:
body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography
} }
}) })
// If isPhotography changed to true, set photoPublishedAt
if (body.isPhotography === true && !existing.isPhotography) {
await prisma.media.update({
where: { id },
data: {
photoPublishedAt: new Date(),
photoCaption: existing.description // Use description as initial caption
}
})
} else if (body.isPhotography === false && existing.isPhotography) {
// If turning off photography, clear photo fields
await prisma.media.update({
where: { id },
data: {
photoPublishedAt: null,
photoCaption: null,
photoTitle: null,
photoDescription: null,
photoSlug: null
}
})
}
logger.info('Media updated', { id, filename: media.filename }) logger.info('Media updated', { id, filename: media.filename })
return jsonResponse(media) return jsonResponse(media)

View file

@ -3,6 +3,129 @@ import { prisma } from '$lib/server/database'
import { uploadFiles, isCloudinaryConfigured } from '$lib/server/cloudinary' import { uploadFiles, isCloudinaryConfigured } from '$lib/server/cloudinary'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils' import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
import exifr from 'exifr'
// Extract EXIF data from image file
async function extractExifData(file: File) {
try {
logger.info(`Starting EXIF extraction for ${file.name}`, {
size: file.size,
type: file.type
})
const buffer = await file.arrayBuffer()
logger.info(`Buffer created for ${file.name}`, { bufferSize: buffer.byteLength })
// Try parsing without pick first to see all available data
const fullExif = await exifr.parse(buffer)
logger.info(`Full EXIF data available for ${file.name}:`, {
hasData: !!fullExif,
availableFields: fullExif ? Object.keys(fullExif).slice(0, 10) : [] // First 10 fields
})
// Now parse with specific fields
const exif = await exifr.parse(buffer, {
pick: [
'Make',
'Model',
'LensModel',
'FocalLength',
'FNumber',
'ExposureTime',
'ISO',
'ISOSpeedRatings', // Alternative ISO field
'DateTimeOriginal',
'DateTime', // Alternative date field
'GPSLatitude',
'GPSLongitude',
'Orientation',
'ColorSpace'
]
})
logger.info(`EXIF parse result for ${file.name}:`, {
hasExif: !!exif,
exifKeys: exif ? Object.keys(exif) : []
})
if (!exif) return null
// Format EXIF data
const formattedExif: any = {}
// Camera info
if (exif.Make && exif.Model) {
formattedExif.camera = `${exif.Make} ${exif.Model}`.replace(/\s+/g, ' ').trim()
}
// Lens info
if (exif.LensModel) {
formattedExif.lens = exif.LensModel
}
// Settings
if (exif.FocalLength) {
formattedExif.focalLength = `${exif.FocalLength}mm`
}
if (exif.FNumber) {
formattedExif.aperture = `f/${exif.FNumber}`
}
if (exif.ExposureTime) {
formattedExif.shutterSpeed =
exif.ExposureTime < 1
? `1/${Math.round(1 / exif.ExposureTime)}`
: `${exif.ExposureTime}s`
}
if (exif.ISO) {
formattedExif.iso = `ISO ${exif.ISO}`
} else if (exif.ISOSpeedRatings) {
// Handle alternative ISO field
const iso = Array.isArray(exif.ISOSpeedRatings) ? exif.ISOSpeedRatings[0] : exif.ISOSpeedRatings
formattedExif.iso = `ISO ${iso}`
}
// Date taken
if (exif.DateTimeOriginal) {
formattedExif.dateTaken = exif.DateTimeOriginal
} else if (exif.DateTime) {
// Fallback to DateTime if DateTimeOriginal not available
formattedExif.dateTaken = exif.DateTime
}
// GPS coordinates
if (exif.GPSLatitude && exif.GPSLongitude) {
formattedExif.coordinates = {
latitude: exif.GPSLatitude,
longitude: exif.GPSLongitude
}
}
// Additional metadata
if (exif.Orientation) {
formattedExif.orientation = exif.Orientation === 1 ? 'Horizontal (normal)' : `Rotated (${exif.Orientation})`
}
if (exif.ColorSpace) {
formattedExif.colorSpace = exif.ColorSpace
}
const result = Object.keys(formattedExif).length > 0 ? formattedExif : null
logger.info(`Final EXIF result for ${file.name}:`, {
hasData: !!result,
fields: result ? Object.keys(result) : []
})
return result
} catch (error) {
logger.warn('Failed to extract EXIF data', {
filename: file.name,
error: error instanceof Error ? error.message : 'Unknown error'
})
return null
}
}
export const POST: RequestHandler = async (event) => { export const POST: RequestHandler = async (event) => {
// Check authentication // Check authentication
@ -52,6 +175,19 @@ export const POST: RequestHandler = async (event) => {
logger.info(`Starting bulk upload of ${files.length} files`) logger.info(`Starting bulk upload of ${files.length} files`)
// Extract EXIF data before uploading (files might not be readable after upload)
const exifDataMap = new Map()
for (const file of files) {
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
logger.info(`Pre-extracting EXIF data for ${file.name}`)
const exifData = await extractExifData(file)
if (exifData) {
exifDataMap.set(file.name, exifData)
logger.info(`EXIF data found for ${file.name}`)
}
}
}
// Upload all files to Cloudinary // Upload all files to Cloudinary
const uploadResults = await uploadFiles(files, context as 'media' | 'photos' | 'projects') const uploadResults = await uploadFiles(files, context as 'media' | 'photos' | 'projects')
@ -65,15 +201,20 @@ export const POST: RequestHandler = async (event) => {
if (result.success) { if (result.success) {
try { try {
// Get pre-extracted EXIF data
const exifData = exifDataMap.get(file.name) || null
const media = await prisma.media.create({ const media = await prisma.media.create({
data: { data: {
filename: file.name, filename: file.name,
originalName: file.name,
mimeType: file.type, mimeType: file.type,
size: file.size, size: file.size,
url: result.secureUrl!, url: result.secureUrl!,
thumbnailUrl: result.thumbnailUrl, thumbnailUrl: result.thumbnailUrl,
width: result.width, width: result.width,
height: result.height, height: result.height,
exifData: exifData,
usedIn: [] usedIn: []
} }
}) })
@ -84,7 +225,8 @@ export const POST: RequestHandler = async (event) => {
thumbnailUrl: media.thumbnailUrl, thumbnailUrl: media.thumbnailUrl,
width: media.width, width: media.width,
height: media.height, height: media.height,
filename: media.filename filename: media.filename,
exifData: media.exifData
}) })
} catch (dbError) { } catch (dbError) {
errors.push({ errors.push({

View file

@ -11,18 +11,17 @@ export const GET: RequestHandler = async (event) => {
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 // Fetch published photography albums with their media
const albums = await prisma.album.findMany({ const albums = await prisma.album.findMany({
where: { where: {
status: 'published', status: 'published',
isPhotography: true isPhotography: true
}, },
include: { include: {
photos: { media: {
where: {
status: 'published'
},
orderBy: { displayOrder: 'asc' }, orderBy: { displayOrder: 'asc' },
include: {
media: {
select: { select: {
id: true, id: true,
filename: true, filename: true,
@ -30,8 +29,10 @@ export const GET: RequestHandler = async (event) => {
thumbnailUrl: true, thumbnailUrl: true,
width: true, width: true,
height: true, height: true,
caption: true, photoCaption: true,
displayOrder: true exifData: true
}
}
} }
} }
}, },
@ -40,82 +41,86 @@ export const GET: RequestHandler = async (event) => {
take: limit take: limit
}) })
// Fetch individual published photos (not in albums, marked for photography) // Fetch individual photos (marked for photography, not in any album)
const individualPhotos = await prisma.photo.findMany({ const individualMedia = await prisma.media.findMany({
where: { where: {
status: 'published', isPhotography: true,
showInPhotos: true, albums: {
albumId: null // Only photos not in albums none: {} // Media not in any album
}
}, },
select: { select: {
id: true, id: true,
slug: true, photoSlug: true,
filename: true, filename: true,
url: true, url: true,
thumbnailUrl: true, thumbnailUrl: true,
width: true, width: true,
height: true, height: true,
caption: true, photoCaption: true,
title: true, photoTitle: true,
description: true, photoDescription: true,
createdAt: true, createdAt: true,
publishedAt: true, photoPublishedAt: true,
exifData: true exifData: true
}, },
orderBy: { createdAt: 'desc' }, orderBy: { photoPublishedAt: 'desc' },
skip: offset, skip: offset,
take: limit take: limit
}) })
// Transform albums to PhotoAlbum format // Transform albums to PhotoAlbum format
const photoAlbums: PhotoAlbum[] = albums const photoAlbums: PhotoAlbum[] = albums
.filter((album) => album.photos.length > 0) // Only include albums with published photos .filter((album) => album.media.length > 0) // Only include albums with media
.map((album) => ({ .map((album) => {
const firstMedia = album.media[0].media
return {
id: `album-${album.id}`, id: `album-${album.id}`,
slug: album.slug, // Add slug for navigation slug: album.slug,
title: album.title, title: album.title,
description: album.description || undefined, description: album.description || undefined,
coverPhoto: { coverPhoto: {
id: `cover-${album.photos[0].id}`, id: `cover-${firstMedia.id}`,
src: album.photos[0].url, src: firstMedia.url,
alt: album.photos[0].caption || album.title, alt: firstMedia.photoCaption || album.title,
caption: album.photos[0].caption || undefined, caption: firstMedia.photoCaption || undefined,
width: album.photos[0].width || 400, width: firstMedia.width || 400,
height: album.photos[0].height || 400 height: firstMedia.height || 400
}, },
photos: album.photos.map((photo) => ({ photos: album.media.map((albumMedia) => ({
id: `photo-${photo.id}`, id: `media-${albumMedia.media.id}`,
src: photo.url, src: albumMedia.media.url,
alt: photo.caption || photo.filename, alt: albumMedia.media.photoCaption || albumMedia.media.filename,
caption: photo.caption || undefined, caption: albumMedia.media.photoCaption || undefined,
width: photo.width || 400, width: albumMedia.media.width || 400,
height: photo.height || 400 height: albumMedia.media.height || 400
})), })),
createdAt: album.createdAt.toISOString() createdAt: album.createdAt.toISOString()
})) }
})
// Transform individual photos to Photo format // Transform individual media to Photo format
const photos: Photo[] = individualPhotos.map((photo) => { const photos: Photo[] = individualMedia.map((media) => {
// Extract date from EXIF data if available // Extract date from EXIF data if available
let photoDate: string let photoDate: string
if (photo.exifData && typeof photo.exifData === 'object' && 'dateTaken' in photo.exifData) { if (media.exifData && typeof media.exifData === 'object' && 'dateTaken' in media.exifData) {
// Use EXIF date if available // Use EXIF date if available
photoDate = photo.exifData.dateTaken as string photoDate = media.exifData.dateTaken as string
} else if (photo.publishedAt) { } else if (media.photoPublishedAt) {
// Fall back to published date // Fall back to published date
photoDate = photo.publishedAt.toISOString() photoDate = media.photoPublishedAt.toISOString()
} else { } else {
// Fall back to created date // Fall back to created date
photoDate = photo.createdAt.toISOString() photoDate = media.createdAt.toISOString()
} }
return { return {
id: `photo-${photo.id}`, id: `media-${media.id}`,
src: photo.url, src: media.url,
alt: photo.title || photo.caption || photo.filename, alt: media.photoTitle || media.photoCaption || media.filename,
caption: photo.caption || undefined, caption: media.photoCaption || undefined,
width: photo.width || 400, width: media.width || 400,
height: photo.height || 400, height: media.height || 400,
createdAt: photoDate createdAt: photoDate
} }
}) })

View file

@ -6,14 +6,14 @@ import { logger } from '$lib/server/logger'
// GET /api/photos/[albumSlug]/[photoId] - Get individual photo with album context // GET /api/photos/[albumSlug]/[photoId] - Get individual photo with album context
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
const albumSlug = event.params.albumSlug const albumSlug = event.params.albumSlug
const photoId = parseInt(event.params.photoId) const mediaId = parseInt(event.params.photoId) // Still called photoId in URL for compatibility
if (!albumSlug || isNaN(photoId)) { if (!albumSlug || isNaN(mediaId)) {
return errorResponse('Invalid album slug or photo ID', 400) return errorResponse('Invalid album slug or media ID', 400)
} }
try { try {
// First find the album // First find the album with its media
const album = await prisma.album.findUnique({ const album = await prisma.album.findUnique({
where: { where: {
slug: albumSlug, slug: albumSlug,
@ -21,8 +21,10 @@ export const GET: RequestHandler = async (event) => {
isPhotography: true isPhotography: true
}, },
include: { include: {
photos: { media: {
orderBy: { displayOrder: 'asc' }, orderBy: { displayOrder: 'asc' },
include: {
media: {
select: { select: {
id: true, id: true,
filename: true, filename: true,
@ -30,11 +32,14 @@ export const GET: RequestHandler = async (event) => {
thumbnailUrl: true, thumbnailUrl: true,
width: true, width: true,
height: true, height: true,
caption: true, photoCaption: true,
title: true, photoTitle: true,
description: true, photoDescription: true,
displayOrder: true, exifData: true,
exifData: true createdAt: true,
photoPublishedAt: true
}
}
} }
} }
} }
@ -44,16 +49,35 @@ export const GET: RequestHandler = async (event) => {
return errorResponse('Album not found', 404) return errorResponse('Album not found', 404)
} }
// Find the specific photo // Find the specific media
const photo = album.photos.find((p) => p.id === photoId) const albumMediaIndex = album.media.findIndex((am) => am.media.id === mediaId)
if (!photo) { if (albumMediaIndex === -1) {
return errorResponse('Photo not found in album', 404) return errorResponse('Photo not found in album', 404)
} }
// Get photo index for navigation const albumMedia = album.media[albumMediaIndex]
const photoIndex = album.photos.findIndex((p) => p.id === photoId) const media = albumMedia.media
const prevPhoto = photoIndex > 0 ? album.photos[photoIndex - 1] : null
const nextPhoto = photoIndex < album.photos.length - 1 ? album.photos[photoIndex + 1] : null // Get navigation info
const prevMedia = albumMediaIndex > 0 ? album.media[albumMediaIndex - 1].media : null
const nextMedia = albumMediaIndex < album.media.length - 1 ? album.media[albumMediaIndex + 1].media : null
// Transform to photo format for compatibility
const photo = {
id: media.id,
filename: media.filename,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
caption: media.photoCaption,
title: media.photoTitle,
description: media.photoDescription,
displayOrder: albumMedia.displayOrder,
exifData: media.exifData,
createdAt: media.createdAt,
publishedAt: media.photoPublishedAt
}
return jsonResponse({ return jsonResponse({
photo, photo,
@ -64,13 +88,13 @@ export const GET: RequestHandler = async (event) => {
description: album.description, description: album.description,
location: album.location, location: album.location,
date: album.date, date: album.date,
totalPhotos: album.photos.length totalPhotos: album.media.length
}, },
navigation: { navigation: {
currentIndex: photoIndex + 1, // 1-based for display currentIndex: albumMediaIndex + 1, // 1-based for display
totalCount: album.photos.length, totalCount: album.media.length,
prevPhoto: prevPhoto ? { id: prevPhoto.id, url: prevPhoto.thumbnailUrl } : null, prevPhoto: prevMedia ? { id: prevMedia.id, url: prevMedia.thumbnailUrl } : null,
nextPhoto: nextPhoto ? { id: nextPhoto.id, url: nextPhoto.thumbnailUrl } : null nextPhoto: nextMedia ? { id: nextMedia.id, url: nextMedia.thumbnailUrl } : null
} }
}) })
} catch (error) { } catch (error) {

View file

@ -3,46 +3,68 @@ import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils' import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
// GET /api/photos/[id] - Get a single photo // GET /api/photos/[id] - Get a single media item as photo
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
const id = parseInt(event.params.id) const id = parseInt(event.params.id)
if (isNaN(id)) { if (isNaN(id)) {
return errorResponse('Invalid photo ID', 400) return errorResponse('Invalid media ID', 400)
} }
try { try {
const photo = await prisma.photo.findUnique({ const media = await prisma.media.findUnique({
where: { id }, where: { id },
include: {
albums: {
include: { include: {
album: { album: {
select: { id: true, title: true, slug: true } select: { id: true, title: true, slug: true }
}, }
media: true }
}
} }
}) })
if (!photo) { if (!media) {
return errorResponse('Photo not found', 404) return errorResponse('Photo not found', 404)
} }
// For public access, only return published photos that are marked showInPhotos // For public access, only return media marked as photography
// Admin endpoints can still access all photos
const isAdminRequest = checkAdminAuth(event) const isAdminRequest = checkAdminAuth(event)
if (!isAdminRequest) { if (!isAdminRequest) {
if (photo.status !== 'published' || !photo.showInPhotos) { if (!media.isPhotography) {
return errorResponse('Photo not found', 404) return errorResponse('Photo not found', 404)
} }
// If photo is in an album, check album is published and isPhotography // If media is in an album, check album is published and isPhotography
if (photo.album) { if (media.albums.length > 0) {
const album = await prisma.album.findUnique({ const album = media.albums[0].album
where: { id: photo.album.id } const fullAlbum = await prisma.album.findUnique({
where: { id: album.id }
}) })
if (!album || album.status !== 'published' || !album.isPhotography) { if (!fullAlbum || fullAlbum.status !== 'published' || !fullAlbum.isPhotography) {
return errorResponse('Photo not found', 404) return errorResponse('Photo not found', 404)
} }
} }
} }
// Transform to match expected photo format
const photo = {
id: media.id,
filename: media.filename,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
exifData: media.exifData,
caption: media.photoCaption,
title: media.photoTitle,
description: media.photoDescription,
slug: media.photoSlug,
publishedAt: media.photoPublishedAt,
createdAt: media.createdAt,
album: media.albums.length > 0 ? media.albums[0].album : null,
media: media // Include full media object for compatibility
}
return jsonResponse(photo) return jsonResponse(photo)
} catch (error) { } catch (error) {
logger.error('Failed to retrieve photo', error as Error) logger.error('Failed to retrieve photo', error as Error)
@ -50,8 +72,7 @@ export const GET: RequestHandler = async (event) => {
} }
} }
// DELETE /api/photos/[id] - Delete a photo completely (removes photo record and media usage) // DELETE /api/photos/[id] - Remove media from photography display
// NOTE: This deletes the photo entirely. Use DELETE /api/albums/[id]/photos to remove from album only.
export const DELETE: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => {
// Check authentication // Check authentication
if (!checkAdminAuth(event)) { if (!checkAdminAuth(event)) {
@ -60,44 +81,43 @@ export const DELETE: RequestHandler = async (event) => {
const id = parseInt(event.params.id) const id = parseInt(event.params.id)
if (isNaN(id)) { if (isNaN(id)) {
return errorResponse('Invalid photo ID', 400) return errorResponse('Invalid media ID', 400)
} }
try { try {
// Check if photo exists // Check if media exists
const photo = await prisma.photo.findUnique({ const media = await prisma.media.findUnique({
where: { id } where: { id }
}) })
if (!photo) { if (!media) {
return errorResponse('Photo not found', 404) return errorResponse('Photo not found', 404)
} }
// Remove media usage tracking for this photo // Update media to remove from photography
if (photo.albumId) { await prisma.media.update({
await prisma.mediaUsage.deleteMany({ where: { id },
where: { data: {
contentType: 'album', isPhotography: false,
contentId: photo.albumId, photoCaption: null,
fieldName: 'photos' photoTitle: null,
photoDescription: null,
photoSlug: null,
photoPublishedAt: null
} }
}) })
}
// Delete the photo record // Remove from all albums
await prisma.photo.delete({ await prisma.albumMedia.deleteMany({
where: { id } where: { mediaId: id }
}) })
logger.info('Photo deleted from album', { logger.info('Media removed from photography', { mediaId: id })
photoId: id,
albumId: photo.albumId
})
return new Response(null, { status: 204 }) return new Response(null, { status: 204 })
} catch (error) { } catch (error) {
logger.error('Failed to delete photo', error as Error) logger.error('Failed to remove photo', error as Error)
return errorResponse('Failed to delete photo', 500) return errorResponse('Failed to remove photo', 500)
} }
} }
@ -110,14 +130,14 @@ export const PUT: RequestHandler = async (event) => {
const id = parseInt(event.params.id) const id = parseInt(event.params.id)
if (isNaN(id)) { if (isNaN(id)) {
return errorResponse('Invalid photo ID', 400) return errorResponse('Invalid media ID', 400)
} }
try { try {
const body = await event.request.json() const body = await event.request.json()
// Check if photo exists // Check if media exists
const existing = await prisma.photo.findUnique({ const existing = await prisma.media.findUnique({
where: { id } where: { id }
}) })
@ -125,20 +145,29 @@ export const PUT: RequestHandler = async (event) => {
return errorResponse('Photo not found', 404) return errorResponse('Photo not found', 404)
} }
// Update photo // Update media photo fields
const photo = await prisma.photo.update({ const media = await prisma.media.update({
where: { id }, where: { id },
data: { data: {
caption: body.caption !== undefined ? body.caption : existing.caption, photoCaption: body.caption !== undefined ? body.caption : existing.photoCaption,
title: body.title !== undefined ? body.title : existing.title, photoTitle: body.title !== undefined ? body.title : existing.photoTitle,
description: body.description !== undefined ? body.description : existing.description, photoDescription: body.description !== undefined ? body.description : existing.photoDescription,
displayOrder: body.displayOrder !== undefined ? body.displayOrder : existing.displayOrder, isPhotography: body.showInPhotos !== undefined ? body.showInPhotos : existing.isPhotography,
status: body.status !== undefined ? body.status : existing.status, photoPublishedAt: body.publishedAt !== undefined ? body.publishedAt : existing.photoPublishedAt
showInPhotos: body.showInPhotos !== undefined ? body.showInPhotos : existing.showInPhotos
} }
}) })
logger.info('Photo updated', { photoId: id }) logger.info('Photo metadata updated', { mediaId: id })
// Return in photo format for compatibility
const photo = {
id: media.id,
caption: media.photoCaption,
title: media.photoTitle,
description: media.photoDescription,
showInPhotos: media.isPhotography,
publishedAt: media.photoPublishedAt
}
return jsonResponse(photo) return jsonResponse(photo)
} catch (error) { } catch (error) {

View file

@ -17,52 +17,13 @@
const error = $derived(data.error) const error = $derived(data.error)
const formatExif = (exifData: any) => {
if (!exifData) return null
const formatSpeed = (speed: string) => {
if (speed?.includes('/')) return speed
if (speed?.includes('s')) return speed
return speed ? `1/${speed}s` : null
}
return {
camera: exifData.camera,
lens: exifData.lens,
settings: [
exifData.focalLength,
exifData.aperture,
formatSpeed(exifData.shutterSpeed),
exifData.iso ? `ISO ${exifData.iso}` : null
]
.filter(Boolean)
.join(' • '),
location: exifData.location,
dateTaken: exifData.dateTaken
}
}
const exif = $derived(photo ? formatExif(photo.exifData) : null)
const pageUrl = $derived($page.url.href) const pageUrl = $derived($page.url.href)
// Parse EXIF data if available (same as photo detail page) // Parse EXIF data if available
const exifData = $derived( const exifData = $derived(
photo?.exifData && typeof photo.exifData === 'object' ? photo.exifData : null photo?.exifData && typeof photo.exifData === 'object' ? photo.exifData : null
) )
// Debug: Log what data we have
$effect(() => {
if (photo) {
console.log('Photo data:', {
id: photo.id,
title: photo.title,
caption: photo.caption,
exifData: photo.exifData,
createdAt: photo.createdAt
})
}
})
// Generate metadata // Generate metadata
const photoTitle = $derived(photo?.title || photo?.caption || `Photo ${navigation?.currentIndex}`) const photoTitle = $derived(photo?.title || photo?.caption || `Photo ${navigation?.currentIndex}`)
const photoDescription = $derived( const photoDescription = $derived(
@ -76,7 +37,7 @@
url: pageUrl, url: pageUrl,
type: 'article', type: 'article',
image: photo.url, image: photo.url,
publishedTime: exif?.dateTaken, publishedTime: exifData?.dateTaken,
author: 'Justin Edmund', author: 'Justin Edmund',
titleFormat: { type: 'snippet', snippet: photoDescription } titleFormat: { type: 'snippet', snippet: photoDescription }
}) })
@ -97,8 +58,8 @@
url: pageUrl, url: pageUrl,
image: photo.url, image: photo.url,
creator: 'Justin Edmund', creator: 'Justin Edmund',
dateCreated: exif?.dateTaken, dateCreated: exifData?.dateTaken,
keywords: ['photography', album.title, ...(exif?.location ? [exif.location] : [])] keywords: ['photography', album.title, ...(exifData?.location ? [exifData.location] : [])]
}) })
: null : null
) )
@ -134,7 +95,7 @@
{#if error || !photo || !album} {#if error || !photo || !album}
<div class="error-container"> <div class="error-container">
<div class="error-content"> <div class="error-message">
<h1>Photo Not Found</h1> <h1>Photo Not Found</h1>
<p>{error || "The photo you're looking for doesn't exist."}</p> <p>{error || "The photo you're looking for doesn't exist."}</p>
<BackButton href="/photos" label="Back to Photos" /> <BackButton href="/photos" label="Back to Photos" />
@ -201,7 +162,7 @@
padding: $unit-6x $unit-3x; padding: $unit-6x $unit-3x;
} }
.error-content { .error-message {
text-align: center; text-align: center;
max-width: 500px; max-width: 500px;
@ -248,6 +209,7 @@
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
box-sizing: border-box;
} }
// Adjacent Navigation // Adjacent Navigation

View file

@ -81,8 +81,9 @@
// Handle photo navigation // Handle photo navigation
function navigateToPhoto(item: any) { function navigateToPhoto(item: any) {
if (!item) return if (!item) return
const photoId = item.id.replace('photo-', '') // Extract media ID from item.id (could be 'media-123' or 'photo-123')
goto(`/photos/p/${photoId}`) const mediaId = item.id.replace(/^(media|photo)-/, '')
goto(`/photos/p/${mediaId}`)
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
@ -182,6 +183,8 @@
{/if} {/if}
<style lang="scss"> <style lang="scss">
@import '$styles/variables.scss';
@import '$styles/mixins.scss';
.error-container { .error-container {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -29,7 +29,7 @@ export const load: PageLoad = async ({ params, fetch }) => {
return { return {
photo, photo,
photoItems, photoItems,
currentPhotoId: `photo-${photoId}` currentPhotoId: `media-${photoId}` // Updated to use media prefix
} }
} catch (error) { } catch (error) {
console.error('Error loading photo:', error) console.error('Error loading photo:', error)