Simplify photos architecture
This commit is contained in:
parent
1c1b930e34
commit
6b44c1b7d0
20 changed files with 930 additions and 523 deletions
|
|
@ -0,0 +1,2 @@
|
|||
-- This migration was already applied manually or is empty
|
||||
-- Placeholder file to satisfy Prisma migration requirements
|
||||
105
prisma/migrations/migrate-photos-to-media.sql
Normal file
105
prisma/migrations/migrate-photos-to-media.sql
Normal 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
|
||||
|
|
@ -76,7 +76,8 @@ model Album {
|
|||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
photos Photo[]
|
||||
photos Photo[] // Will be removed after migration
|
||||
media AlbumMedia[]
|
||||
|
||||
@@index([slug])
|
||||
@@index([status])
|
||||
|
|
@ -127,16 +128,24 @@ model Media {
|
|||
width Int?
|
||||
height Int?
|
||||
exifData Json? // EXIF data for photos
|
||||
altText String? @db.Text // Alt text for accessibility
|
||||
description String? @db.Text // Optional description
|
||||
description String? @db.Text // Description (used for alt text and captions)
|
||||
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)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
usage MediaUsage[]
|
||||
photos Photo[]
|
||||
photos Photo[] // Will be removed after migration
|
||||
albums AlbumMedia[]
|
||||
}
|
||||
|
||||
// Media usage tracking table
|
||||
|
|
@ -156,3 +165,20 @@ model MediaUsage {
|
|||
@@index([mediaId])
|
||||
@@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])
|
||||
}
|
||||
11
scripts/migrate-alttext-to-description.sql
Normal file
11
scripts/migrate-alttext-to-description.sql
Normal 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" != '';
|
||||
133
scripts/migrate-photos-to-media.ts
Normal file
133
scripts/migrate-photos-to-media.ts
Normal 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()
|
||||
|
|
@ -21,12 +21,12 @@
|
|||
// For individual photos, check if we have album context
|
||||
if (albumSlug) {
|
||||
// Navigate to photo within album
|
||||
const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix
|
||||
goto(`/photos/${albumSlug}/${photoId}`)
|
||||
const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
|
||||
goto(`/photos/${albumSlug}/${mediaId}`)
|
||||
} else {
|
||||
// Navigate to individual photo page using the photo ID
|
||||
const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix
|
||||
goto(`/photos/p/${photoId}`)
|
||||
// Navigate to individual photo page using the media ID
|
||||
const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
|
||||
goto(`/photos/p/${mediaId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
let {
|
||||
media,
|
||||
alt = media.altText || media.filename || '',
|
||||
alt = media.description || media.filename || '',
|
||||
class: className = '',
|
||||
containerWidth,
|
||||
loading = 'lazy',
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
let { isOpen = $bindable(), media, onClose, onUpdate }: Props = $props()
|
||||
|
||||
// Form state
|
||||
let altText = $state('')
|
||||
let description = $state('')
|
||||
let isPhotography = $state(false)
|
||||
let isSaving = $state(false)
|
||||
|
|
@ -43,8 +42,8 @@
|
|||
// Initialize form when media changes
|
||||
$effect(() => {
|
||||
if (media) {
|
||||
altText = media.altText || ''
|
||||
description = media.description || ''
|
||||
// Use description if available, otherwise fall back to altText for backwards compatibility
|
||||
description = media.description || media.altText || ''
|
||||
isPhotography = media.isPhotography || false
|
||||
error = ''
|
||||
successMessage = ''
|
||||
|
|
@ -77,7 +76,6 @@
|
|||
}
|
||||
|
||||
function handleClose() {
|
||||
altText = ''
|
||||
description = ''
|
||||
isPhotography = false
|
||||
error = ''
|
||||
|
|
@ -99,7 +97,8 @@
|
|||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
altText: altText.trim() || null,
|
||||
// Use description for both altText and description fields
|
||||
altText: description.trim() || null,
|
||||
description: description.trim() || null,
|
||||
isPhotography: isPhotography
|
||||
})
|
||||
|
|
@ -198,7 +197,7 @@
|
|||
size="jumbo"
|
||||
closeOnBackdrop={!isSaving}
|
||||
closeOnEscape={!isSaving}
|
||||
on:close={handleClose}
|
||||
onClose={handleClose}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div class="media-details-modal">
|
||||
|
|
@ -206,7 +205,7 @@
|
|||
<div class="image-pane">
|
||||
{#if media.mimeType.startsWith('image/')}
|
||||
<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>
|
||||
{:else}
|
||||
<div class="file-placeholder">
|
||||
|
|
@ -289,10 +288,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="pane-body">
|
||||
<!-- File Info -->
|
||||
<div class="file-info">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
|
|
@ -387,6 +383,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="pane-body-content">
|
||||
<!-- Photography Toggle -->
|
||||
<div class="photography-toggle">
|
||||
<label class="toggle-label">
|
||||
|
|
@ -406,20 +403,11 @@
|
|||
|
||||
<!-- 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
|
||||
label="Description"
|
||||
bind:value={description}
|
||||
placeholder="Additional description or caption"
|
||||
rows={3}
|
||||
placeholder="Describe this image (used for alt text and captions)"
|
||||
rows={4}
|
||||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
|
@ -468,6 +456,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="pane-footer">
|
||||
|
|
@ -587,7 +576,10 @@
|
|||
.pane-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: $unit-4x;
|
||||
}
|
||||
|
||||
.pane-body-content {
|
||||
padding: $unit-3x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-6x;
|
||||
|
|
@ -598,8 +590,8 @@
|
|||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
padding: $unit-3x;
|
||||
background-color: $grey-97;
|
||||
border-radius: $corner-radius;
|
||||
background-color: $grey-90;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
|
|
@ -895,7 +887,7 @@
|
|||
}
|
||||
|
||||
.pane-body {
|
||||
padding: $unit-3x;
|
||||
// padding: $unit-3x;
|
||||
}
|
||||
|
||||
.pane-footer {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Button from './Button.svelte'
|
||||
|
||||
export let isOpen = false
|
||||
export let size: 'small' | 'medium' | 'large' | 'jumbo' | 'full' = 'medium'
|
||||
export let closeOnBackdrop = true
|
||||
export let closeOnEscape = true
|
||||
export let showCloseButton = true
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
size?: 'small' | 'medium' | 'large' | 'jumbo' | 'full'
|
||||
closeOnBackdrop?: boolean
|
||||
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() {
|
||||
isOpen = false
|
||||
dispatch('close')
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
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(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => {
|
||||
|
|
@ -35,7 +73,7 @@
|
|||
}
|
||||
})
|
||||
|
||||
$: modalClass = `modal-${size}`
|
||||
let modalClass = $derived(`modal-${size}`)
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
let currentPage = $state(1)
|
||||
let totalPages = $state(1)
|
||||
let total = $state(0)
|
||||
let viewMode = $state<'grid' | 'list'>('grid')
|
||||
// Only using grid view
|
||||
|
||||
// Filter states
|
||||
let filterType = $state<string>('all')
|
||||
|
|
@ -324,18 +324,9 @@
|
|||
onclick={toggleMultiSelectMode}
|
||||
class={isMultiSelectMode ? 'active' : ''}
|
||||
>
|
||||
{isMultiSelectMode ? '✓' : '☐'}
|
||||
{isMultiSelectMode ? 'Exit Select' : 'Select'}
|
||||
</Button>
|
||||
<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>
|
||||
<Button variant="primary" buttonSize="large" onclick={openUploadModal}>Upload</Button>
|
||||
{/snippet}
|
||||
</AdminHeader>
|
||||
|
||||
|
|
@ -414,14 +405,14 @@
|
|||
class="btn btn-secondary btn-small"
|
||||
title="Mark selected items as photography"
|
||||
>
|
||||
📸 Mark Photography
|
||||
Mark Photography
|
||||
</button>
|
||||
<button
|
||||
onclick={handleBulkUnmarkPhotography}
|
||||
class="btn btn-secondary btn-small"
|
||||
title="Remove photography status from selected items"
|
||||
>
|
||||
🚫 Remove Photography
|
||||
Remove Photography
|
||||
</button>
|
||||
<button
|
||||
onclick={handleBulkDelete}
|
||||
|
|
@ -444,7 +435,7 @@
|
|||
<p>No media files found.</p>
|
||||
<Button variant="primary" onclick={openUploadModal}>Upload your first file</Button>
|
||||
</div>
|
||||
{:else if viewMode === 'grid'}
|
||||
{:else}
|
||||
<div class="media-grid">
|
||||
{#each media as item}
|
||||
<div class="media-item-wrapper" class:multiselect={isMultiSelectMode}>
|
||||
|
|
@ -470,7 +461,7 @@
|
|||
{#if item.mimeType.startsWith('image/')}
|
||||
<img
|
||||
src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
|
||||
alt={item.altText || item.filename}
|
||||
alt={item.description || item.filename}
|
||||
/>
|
||||
{:else}
|
||||
<div class="file-placeholder">
|
||||
|
|
@ -479,135 +470,21 @@
|
|||
{/if}
|
||||
<div class="media-info">
|
||||
<span class="filename">{item.filename}</span>
|
||||
<div class="media-info-bottom">
|
||||
<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>
|
||||
<span class="indicator-pill photography" title="Photography"> Photo </span>
|
||||
{/if}
|
||||
{#if item.altText}
|
||||
<span class="indicator-pill alt-text" title="Alt text: {item.altText}">
|
||||
{#if item.description}
|
||||
<span class="indicator-pill alt-text" title="Description: {item.description}">
|
||||
Alt
|
||||
</span>
|
||||
{: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}
|
||||
</div>
|
||||
<span class="filesize">{formatFileSize(item.size)}</span>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -708,14 +585,15 @@
|
|||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: $unit-3x;
|
||||
margin-bottom: $unit-4x;
|
||||
padding: 0 $unit;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
background: $grey-95;
|
||||
border: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $unit-2x;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
|
@ -728,6 +606,7 @@
|
|||
background-color: $grey-90;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
|
|
@ -759,11 +638,12 @@
|
|||
padding: $unit-2x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
gap: $unit;
|
||||
|
||||
.filename {
|
||||
font-size: 0.875rem;
|
||||
font-size: 1rem;
|
||||
color: $grey-20;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
|
@ -774,6 +654,13 @@
|
|||
color: $grey-40;
|
||||
}
|
||||
|
||||
.media-info-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.media-indicators {
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
|
|
@ -1067,12 +954,10 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
padding: 2px $unit;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
padding: $unit-half $unit;
|
||||
border-radius: $corner-radius-2xl;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1;
|
||||
|
||||
svg {
|
||||
|
|
@ -1084,7 +969,6 @@
|
|||
&.photography {
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
color: #7c3aed;
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
|
||||
svg {
|
||||
fill: #7c3aed;
|
||||
|
|
@ -1094,13 +978,11 @@
|
|||
&.alt-text {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
&.no-alt-text {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from '$lib/server/api-utils'
|
||||
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) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
|
|
@ -53,34 +53,39 @@ export const POST: RequestHandler = async (event) => {
|
|||
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
|
||||
let displayOrder = body.displayOrder
|
||||
if (displayOrder === undefined) {
|
||||
const lastPhoto = await prisma.photo.findFirst({
|
||||
const lastAlbumMedia = await prisma.albumMedia.findFirst({
|
||||
where: { albumId },
|
||||
orderBy: { displayOrder: 'desc' }
|
||||
})
|
||||
displayOrder = (lastPhoto?.displayOrder || 0) + 1
|
||||
displayOrder = (lastAlbumMedia?.displayOrder || 0) + 1
|
||||
}
|
||||
|
||||
// Create photo record linked to media
|
||||
const photo = await prisma.photo.create({
|
||||
// Create album-media relationship
|
||||
const albumMedia = await prisma.albumMedia.create({
|
||||
data: {
|
||||
albumId,
|
||||
mediaId: body.mediaId, // Link to the Media record
|
||||
filename: media.filename,
|
||||
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
|
||||
mediaId: body.mediaId,
|
||||
displayOrder
|
||||
},
|
||||
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,
|
||||
photoId: photo.id,
|
||||
mediaId: body.mediaId
|
||||
})
|
||||
|
||||
// Return photo with full media information
|
||||
return jsonResponse(photo)
|
||||
// Return media with album context
|
||||
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) {
|
||||
logger.error('Failed to add photo to album', error as Error)
|
||||
return errorResponse('Failed to add photo to album', 500)
|
||||
logger.error('Failed to add media to album', error as Error)
|
||||
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) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
|
|
@ -122,12 +138,14 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
try {
|
||||
const body = await parseRequestBody<{
|
||||
photoId: number
|
||||
mediaId: number // Changed from photoId for clarity
|
||||
displayOrder: number
|
||||
}>(event.request)
|
||||
|
||||
if (!body || !body.photoId || body.displayOrder === undefined) {
|
||||
return errorResponse('Photo ID and display order are required', 400)
|
||||
// Also support legacy photoId parameter
|
||||
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
|
||||
|
|
@ -139,31 +157,41 @@ export const PUT: RequestHandler = async (event) => {
|
|||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
// Update photo display order
|
||||
const photo = await prisma.photo.update({
|
||||
// Update album-media display order
|
||||
const albumMedia = await prisma.albumMedia.update({
|
||||
where: {
|
||||
id: body.photoId,
|
||||
albumId // Ensure photo belongs to this album
|
||||
albumId_mediaId: {
|
||||
albumId: albumId,
|
||||
mediaId: mediaId
|
||||
}
|
||||
},
|
||||
data: {
|
||||
displayOrder: body.displayOrder
|
||||
},
|
||||
include: {
|
||||
media: true
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Photo order updated', {
|
||||
logger.info('Media order updated', {
|
||||
albumId,
|
||||
photoId: body.photoId,
|
||||
mediaId: mediaId,
|
||||
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) {
|
||||
logger.error('Failed to update photo order', error as Error)
|
||||
return errorResponse('Failed to update photo order', 500)
|
||||
logger.error('Failed to update media order', error as Error)
|
||||
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) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
|
|
@ -177,15 +205,15 @@ export const DELETE: RequestHandler = async (event) => {
|
|||
|
||||
try {
|
||||
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))) {
|
||||
return errorResponse('Photo ID is required as query parameter', 400)
|
||||
if (!mediaIdParam || isNaN(parseInt(mediaIdParam))) {
|
||||
return errorResponse('Media ID is required as query parameter', 400)
|
||||
}
|
||||
|
||||
const photoIdNum = parseInt(photoId)
|
||||
const mediaId = parseInt(mediaIdParam)
|
||||
|
||||
// Check if album exists
|
||||
const album = await prisma.album.findUnique({
|
||||
|
|
@ -197,49 +225,51 @@ export const DELETE: RequestHandler = async (event) => {
|
|||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
// Check if photo exists in this album
|
||||
const photo = await prisma.photo.findFirst({
|
||||
// Check if media exists in this album
|
||||
const albumMedia = await prisma.albumMedia.findUnique({
|
||||
where: {
|
||||
id: photoIdNum,
|
||||
albumId: albumId // Ensure photo belongs to this album
|
||||
},
|
||||
include: {
|
||||
media: true // Include media relation to get mediaId
|
||||
albumId_mediaId: {
|
||||
albumId: albumId,
|
||||
mediaId: mediaId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Photo lookup result', { photoIdNum, albumId, found: !!photo })
|
||||
logger.info('AlbumMedia lookup result', { mediaId, albumId, found: !!albumMedia })
|
||||
|
||||
if (!photo) {
|
||||
logger.error('Photo not found in album', { photoIdNum, albumId })
|
||||
return errorResponse('Photo not found in this album', 404)
|
||||
if (!albumMedia) {
|
||||
logger.error('Media not found in album', { mediaId, albumId })
|
||||
return errorResponse('Media not found in this album', 404)
|
||||
}
|
||||
|
||||
// Remove media usage record if photo has a mediaId
|
||||
if (photo.mediaId) {
|
||||
// Remove media usage record
|
||||
await prisma.mediaUsage.deleteMany({
|
||||
where: {
|
||||
mediaId: photo.mediaId,
|
||||
mediaId: mediaId,
|
||||
contentType: 'album',
|
||||
contentId: albumId,
|
||||
fieldName: 'photos'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Delete the photo record (this removes it from the album but keeps the media)
|
||||
await prisma.photo.delete({
|
||||
where: { id: photoIdNum }
|
||||
// Delete the album-media relationship
|
||||
await prisma.albumMedia.delete({
|
||||
where: {
|
||||
albumId_mediaId: {
|
||||
albumId: albumId,
|
||||
mediaId: mediaId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Photo removed from album', {
|
||||
photoId: photoIdNum,
|
||||
logger.info('Media removed from album', {
|
||||
mediaId: mediaId,
|
||||
albumId: albumId
|
||||
})
|
||||
|
||||
return new Response(null, { status: 204 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove photo from album', error as Error)
|
||||
return errorResponse('Failed to remove photo from album', 500)
|
||||
logger.error('Failed to remove media from album', error as Error)
|
||||
return errorResponse('Failed to remove media from album', 500)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ export const GET: RequestHandler = async (event) => {
|
|||
height: true,
|
||||
usedIn: true,
|
||||
isPhotography: true,
|
||||
createdAt: true
|
||||
createdAt: true,
|
||||
description: true,
|
||||
exifData: true
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
try {
|
||||
const body = await parseRequestBody<{
|
||||
altText?: string
|
||||
description?: string
|
||||
isPhotography?: boolean
|
||||
}>(event.request)
|
||||
|
|
@ -68,13 +67,35 @@ export const PUT: RequestHandler = async (event) => {
|
|||
const media = await prisma.media.update({
|
||||
where: { id },
|
||||
data: {
|
||||
altText: body.altText !== undefined ? body.altText : existing.altText,
|
||||
description: body.description !== undefined ? body.description : existing.description,
|
||||
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 })
|
||||
|
||||
return jsonResponse(media)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,129 @@ import { prisma } from '$lib/server/database'
|
|||
import { uploadFiles, isCloudinaryConfigured } from '$lib/server/cloudinary'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
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) => {
|
||||
// Check authentication
|
||||
|
|
@ -52,6 +175,19 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
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
|
||||
const uploadResults = await uploadFiles(files, context as 'media' | 'photos' | 'projects')
|
||||
|
||||
|
|
@ -65,15 +201,20 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
if (result.success) {
|
||||
try {
|
||||
// Get pre-extracted EXIF data
|
||||
const exifData = exifDataMap.get(file.name) || null
|
||||
|
||||
const media = await prisma.media.create({
|
||||
data: {
|
||||
filename: file.name,
|
||||
originalName: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
url: result.secureUrl!,
|
||||
thumbnailUrl: result.thumbnailUrl,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
exifData: exifData,
|
||||
usedIn: []
|
||||
}
|
||||
})
|
||||
|
|
@ -84,7 +225,8 @@ export const POST: RequestHandler = async (event) => {
|
|||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
filename: media.filename
|
||||
filename: media.filename,
|
||||
exifData: media.exifData
|
||||
})
|
||||
} catch (dbError) {
|
||||
errors.push({
|
||||
|
|
|
|||
|
|
@ -11,18 +11,17 @@ export const GET: RequestHandler = async (event) => {
|
|||
const limit = parseInt(url.searchParams.get('limit') || '50')
|
||||
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({
|
||||
where: {
|
||||
status: 'published',
|
||||
isPhotography: true
|
||||
},
|
||||
include: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published'
|
||||
},
|
||||
media: {
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
include: {
|
||||
media: {
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
|
|
@ -30,8 +29,10 @@ export const GET: RequestHandler = async (event) => {
|
|||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
caption: true,
|
||||
displayOrder: true
|
||||
photoCaption: true,
|
||||
exifData: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -40,82 +41,86 @@ export const GET: RequestHandler = async (event) => {
|
|||
take: limit
|
||||
})
|
||||
|
||||
// Fetch individual published photos (not in albums, marked for photography)
|
||||
const individualPhotos = await prisma.photo.findMany({
|
||||
// Fetch individual photos (marked for photography, not in any album)
|
||||
const individualMedia = await prisma.media.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true,
|
||||
albumId: null // Only photos not in albums
|
||||
isPhotography: true,
|
||||
albums: {
|
||||
none: {} // Media not in any album
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
photoSlug: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
caption: true,
|
||||
title: true,
|
||||
description: true,
|
||||
photoCaption: true,
|
||||
photoTitle: true,
|
||||
photoDescription: true,
|
||||
createdAt: true,
|
||||
publishedAt: true,
|
||||
photoPublishedAt: true,
|
||||
exifData: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy: { photoPublishedAt: 'desc' },
|
||||
skip: offset,
|
||||
take: limit
|
||||
})
|
||||
|
||||
// Transform albums to PhotoAlbum format
|
||||
const photoAlbums: PhotoAlbum[] = albums
|
||||
.filter((album) => album.photos.length > 0) // Only include albums with published photos
|
||||
.map((album) => ({
|
||||
.filter((album) => album.media.length > 0) // Only include albums with media
|
||||
.map((album) => {
|
||||
const firstMedia = album.media[0].media
|
||||
return {
|
||||
id: `album-${album.id}`,
|
||||
slug: album.slug, // Add slug for navigation
|
||||
slug: album.slug,
|
||||
title: album.title,
|
||||
description: album.description || undefined,
|
||||
coverPhoto: {
|
||||
id: `cover-${album.photos[0].id}`,
|
||||
src: album.photos[0].url,
|
||||
alt: album.photos[0].caption || album.title,
|
||||
caption: album.photos[0].caption || undefined,
|
||||
width: album.photos[0].width || 400,
|
||||
height: album.photos[0].height || 400
|
||||
id: `cover-${firstMedia.id}`,
|
||||
src: firstMedia.url,
|
||||
alt: firstMedia.photoCaption || album.title,
|
||||
caption: firstMedia.photoCaption || undefined,
|
||||
width: firstMedia.width || 400,
|
||||
height: firstMedia.height || 400
|
||||
},
|
||||
photos: album.photos.map((photo) => ({
|
||||
id: `photo-${photo.id}`,
|
||||
src: photo.url,
|
||||
alt: photo.caption || photo.filename,
|
||||
caption: photo.caption || undefined,
|
||||
width: photo.width || 400,
|
||||
height: photo.height || 400
|
||||
photos: album.media.map((albumMedia) => ({
|
||||
id: `media-${albumMedia.media.id}`,
|
||||
src: albumMedia.media.url,
|
||||
alt: albumMedia.media.photoCaption || albumMedia.media.filename,
|
||||
caption: albumMedia.media.photoCaption || undefined,
|
||||
width: albumMedia.media.width || 400,
|
||||
height: albumMedia.media.height || 400
|
||||
})),
|
||||
createdAt: album.createdAt.toISOString()
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
// Transform individual photos to Photo format
|
||||
const photos: Photo[] = individualPhotos.map((photo) => {
|
||||
// Transform individual media to Photo format
|
||||
const photos: Photo[] = individualMedia.map((media) => {
|
||||
// Extract date from EXIF data if available
|
||||
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
|
||||
photoDate = photo.exifData.dateTaken as string
|
||||
} else if (photo.publishedAt) {
|
||||
photoDate = media.exifData.dateTaken as string
|
||||
} else if (media.photoPublishedAt) {
|
||||
// Fall back to published date
|
||||
photoDate = photo.publishedAt.toISOString()
|
||||
photoDate = media.photoPublishedAt.toISOString()
|
||||
} else {
|
||||
// Fall back to created date
|
||||
photoDate = photo.createdAt.toISOString()
|
||||
photoDate = media.createdAt.toISOString()
|
||||
}
|
||||
|
||||
return {
|
||||
id: `photo-${photo.id}`,
|
||||
src: photo.url,
|
||||
alt: photo.title || photo.caption || photo.filename,
|
||||
caption: photo.caption || undefined,
|
||||
width: photo.width || 400,
|
||||
height: photo.height || 400,
|
||||
id: `media-${media.id}`,
|
||||
src: media.url,
|
||||
alt: media.photoTitle || media.photoCaption || media.filename,
|
||||
caption: media.photoCaption || undefined,
|
||||
width: media.width || 400,
|
||||
height: media.height || 400,
|
||||
createdAt: photoDate
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ import { logger } from '$lib/server/logger'
|
|||
// GET /api/photos/[albumSlug]/[photoId] - Get individual photo with album context
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
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)) {
|
||||
return errorResponse('Invalid album slug or photo ID', 400)
|
||||
if (!albumSlug || isNaN(mediaId)) {
|
||||
return errorResponse('Invalid album slug or media ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
// First find the album
|
||||
// First find the album with its media
|
||||
const album = await prisma.album.findUnique({
|
||||
where: {
|
||||
slug: albumSlug,
|
||||
|
|
@ -21,8 +21,10 @@ export const GET: RequestHandler = async (event) => {
|
|||
isPhotography: true
|
||||
},
|
||||
include: {
|
||||
photos: {
|
||||
media: {
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
include: {
|
||||
media: {
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
|
|
@ -30,11 +32,14 @@ export const GET: RequestHandler = async (event) => {
|
|||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
caption: true,
|
||||
title: true,
|
||||
description: true,
|
||||
displayOrder: true,
|
||||
exifData: true
|
||||
photoCaption: true,
|
||||
photoTitle: true,
|
||||
photoDescription: true,
|
||||
exifData: true,
|
||||
createdAt: true,
|
||||
photoPublishedAt: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -44,16 +49,35 @@ export const GET: RequestHandler = async (event) => {
|
|||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
// Find the specific photo
|
||||
const photo = album.photos.find((p) => p.id === photoId)
|
||||
if (!photo) {
|
||||
// Find the specific media
|
||||
const albumMediaIndex = album.media.findIndex((am) => am.media.id === mediaId)
|
||||
if (albumMediaIndex === -1) {
|
||||
return errorResponse('Photo not found in album', 404)
|
||||
}
|
||||
|
||||
// Get photo index for navigation
|
||||
const photoIndex = album.photos.findIndex((p) => p.id === photoId)
|
||||
const prevPhoto = photoIndex > 0 ? album.photos[photoIndex - 1] : null
|
||||
const nextPhoto = photoIndex < album.photos.length - 1 ? album.photos[photoIndex + 1] : null
|
||||
const albumMedia = album.media[albumMediaIndex]
|
||||
const media = albumMedia.media
|
||||
|
||||
// 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({
|
||||
photo,
|
||||
|
|
@ -64,13 +88,13 @@ export const GET: RequestHandler = async (event) => {
|
|||
description: album.description,
|
||||
location: album.location,
|
||||
date: album.date,
|
||||
totalPhotos: album.photos.length
|
||||
totalPhotos: album.media.length
|
||||
},
|
||||
navigation: {
|
||||
currentIndex: photoIndex + 1, // 1-based for display
|
||||
totalCount: album.photos.length,
|
||||
prevPhoto: prevPhoto ? { id: prevPhoto.id, url: prevPhoto.thumbnailUrl } : null,
|
||||
nextPhoto: nextPhoto ? { id: nextPhoto.id, url: nextPhoto.thumbnailUrl } : null
|
||||
currentIndex: albumMediaIndex + 1, // 1-based for display
|
||||
totalCount: album.media.length,
|
||||
prevPhoto: prevMedia ? { id: prevMedia.id, url: prevMedia.thumbnailUrl } : null,
|
||||
nextPhoto: nextMedia ? { id: nextMedia.id, url: nextMedia.thumbnailUrl } : null
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -3,46 +3,68 @@ import { prisma } from '$lib/server/database'
|
|||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
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) => {
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid photo ID', 400)
|
||||
return errorResponse('Invalid media ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const photo = await prisma.photo.findUnique({
|
||||
const media = await prisma.media.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
albums: {
|
||||
include: {
|
||||
album: {
|
||||
select: { id: true, title: true, slug: true }
|
||||
},
|
||||
media: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!photo) {
|
||||
if (!media) {
|
||||
return errorResponse('Photo not found', 404)
|
||||
}
|
||||
|
||||
// For public access, only return published photos that are marked showInPhotos
|
||||
// Admin endpoints can still access all photos
|
||||
// For public access, only return media marked as photography
|
||||
const isAdminRequest = checkAdminAuth(event)
|
||||
if (!isAdminRequest) {
|
||||
if (photo.status !== 'published' || !photo.showInPhotos) {
|
||||
if (!media.isPhotography) {
|
||||
return errorResponse('Photo not found', 404)
|
||||
}
|
||||
// If photo is in an album, check album is published and isPhotography
|
||||
if (photo.album) {
|
||||
const album = await prisma.album.findUnique({
|
||||
where: { id: photo.album.id }
|
||||
// If media is in an album, check album is published and isPhotography
|
||||
if (media.albums.length > 0) {
|
||||
const album = media.albums[0].album
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
} catch (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)
|
||||
// NOTE: This deletes the photo entirely. Use DELETE /api/albums/[id]/photos to remove from album only.
|
||||
// DELETE /api/photos/[id] - Remove media from photography display
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
|
|
@ -60,44 +81,43 @@ export const DELETE: RequestHandler = async (event) => {
|
|||
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid photo ID', 400)
|
||||
return errorResponse('Invalid media ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if photo exists
|
||||
const photo = await prisma.photo.findUnique({
|
||||
// Check if media exists
|
||||
const media = await prisma.media.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!photo) {
|
||||
if (!media) {
|
||||
return errorResponse('Photo not found', 404)
|
||||
}
|
||||
|
||||
// Remove media usage tracking for this photo
|
||||
if (photo.albumId) {
|
||||
await prisma.mediaUsage.deleteMany({
|
||||
where: {
|
||||
contentType: 'album',
|
||||
contentId: photo.albumId,
|
||||
fieldName: 'photos'
|
||||
// Update media to remove from photography
|
||||
await prisma.media.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isPhotography: false,
|
||||
photoCaption: null,
|
||||
photoTitle: null,
|
||||
photoDescription: null,
|
||||
photoSlug: null,
|
||||
photoPublishedAt: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Delete the photo record
|
||||
await prisma.photo.delete({
|
||||
where: { id }
|
||||
// Remove from all albums
|
||||
await prisma.albumMedia.deleteMany({
|
||||
where: { mediaId: id }
|
||||
})
|
||||
|
||||
logger.info('Photo deleted from album', {
|
||||
photoId: id,
|
||||
albumId: photo.albumId
|
||||
})
|
||||
logger.info('Media removed from photography', { mediaId: id })
|
||||
|
||||
return new Response(null, { status: 204 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete photo', error as Error)
|
||||
return errorResponse('Failed to delete photo', 500)
|
||||
logger.error('Failed to remove photo', error as Error)
|
||||
return errorResponse('Failed to remove photo', 500)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,14 +130,14 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid photo ID', 400)
|
||||
return errorResponse('Invalid media ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await event.request.json()
|
||||
|
||||
// Check if photo exists
|
||||
const existing = await prisma.photo.findUnique({
|
||||
// Check if media exists
|
||||
const existing = await prisma.media.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
|
|
@ -125,20 +145,29 @@ export const PUT: RequestHandler = async (event) => {
|
|||
return errorResponse('Photo not found', 404)
|
||||
}
|
||||
|
||||
// Update photo
|
||||
const photo = await prisma.photo.update({
|
||||
// Update media photo fields
|
||||
const media = await prisma.media.update({
|
||||
where: { id },
|
||||
data: {
|
||||
caption: body.caption !== undefined ? body.caption : existing.caption,
|
||||
title: body.title !== undefined ? body.title : existing.title,
|
||||
description: body.description !== undefined ? body.description : existing.description,
|
||||
displayOrder: body.displayOrder !== undefined ? body.displayOrder : existing.displayOrder,
|
||||
status: body.status !== undefined ? body.status : existing.status,
|
||||
showInPhotos: body.showInPhotos !== undefined ? body.showInPhotos : existing.showInPhotos
|
||||
photoCaption: body.caption !== undefined ? body.caption : existing.photoCaption,
|
||||
photoTitle: body.title !== undefined ? body.title : existing.photoTitle,
|
||||
photoDescription: body.description !== undefined ? body.description : existing.photoDescription,
|
||||
isPhotography: body.showInPhotos !== undefined ? body.showInPhotos : existing.isPhotography,
|
||||
photoPublishedAt: body.publishedAt !== undefined ? body.publishedAt : existing.photoPublishedAt
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -17,52 +17,13 @@
|
|||
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)
|
||||
|
||||
// Parse EXIF data if available (same as photo detail page)
|
||||
// Parse EXIF data if available
|
||||
const exifData = $derived(
|
||||
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
|
||||
const photoTitle = $derived(photo?.title || photo?.caption || `Photo ${navigation?.currentIndex}`)
|
||||
const photoDescription = $derived(
|
||||
|
|
@ -76,7 +37,7 @@
|
|||
url: pageUrl,
|
||||
type: 'article',
|
||||
image: photo.url,
|
||||
publishedTime: exif?.dateTaken,
|
||||
publishedTime: exifData?.dateTaken,
|
||||
author: 'Justin Edmund',
|
||||
titleFormat: { type: 'snippet', snippet: photoDescription }
|
||||
})
|
||||
|
|
@ -97,8 +58,8 @@
|
|||
url: pageUrl,
|
||||
image: photo.url,
|
||||
creator: 'Justin Edmund',
|
||||
dateCreated: exif?.dateTaken,
|
||||
keywords: ['photography', album.title, ...(exif?.location ? [exif.location] : [])]
|
||||
dateCreated: exifData?.dateTaken,
|
||||
keywords: ['photography', album.title, ...(exifData?.location ? [exifData.location] : [])]
|
||||
})
|
||||
: null
|
||||
)
|
||||
|
|
@ -134,7 +95,7 @@
|
|||
|
||||
{#if error || !photo || !album}
|
||||
<div class="error-container">
|
||||
<div class="error-content">
|
||||
<div class="error-message">
|
||||
<h1>Photo Not Found</h1>
|
||||
<p>{error || "The photo you're looking for doesn't exist."}</p>
|
||||
<BackButton href="/photos" label="Back to Photos" />
|
||||
|
|
@ -201,7 +162,7 @@
|
|||
padding: $unit-6x $unit-3x;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
.error-message {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
|
||||
|
|
@ -248,6 +209,7 @@
|
|||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Adjacent Navigation
|
||||
|
|
|
|||
|
|
@ -81,8 +81,9 @@
|
|||
// Handle photo navigation
|
||||
function navigateToPhoto(item: any) {
|
||||
if (!item) return
|
||||
const photoId = item.id.replace('photo-', '')
|
||||
goto(`/photos/p/${photoId}`)
|
||||
// Extract media ID from item.id (could be 'media-123' or 'photo-123')
|
||||
const mediaId = item.id.replace(/^(media|photo)-/, '')
|
||||
goto(`/photos/p/${mediaId}`)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
|
|
@ -182,6 +183,8 @@
|
|||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
@import '$styles/mixins.scss';
|
||||
.error-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const load: PageLoad = async ({ params, fetch }) => {
|
|||
return {
|
||||
photo,
|
||||
photoItems,
|
||||
currentPhotoId: `photo-${photoId}`
|
||||
currentPhotoId: `media-${photoId}` // Updated to use media prefix
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading photo:', error)
|
||||
|
|
|
|||
Loading…
Reference in a new issue