diff --git a/README.md b/README.md index 2206ca3..8c4f5ff 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,102 @@ Optional environment variables: - `npm run check` - Type check with svelte-check - `npm run lint` - Check formatting and linting - `npm run format` - Auto-format code with prettier + +## Database Management + +### Quick Start + +Sync remote production database to local development: + +```bash +# This backs up both databases first, then copies remote to local +npm run db:backup:sync +``` + +### Prerequisites + +1. PostgreSQL client tools must be installed (`pg_dump`, `psql`) + ```bash + # macOS + brew install postgresql + + # Ubuntu/Debian + sudo apt-get install postgresql-client + ``` + +2. Set environment variables in `.env` or `.env.local`: + ```bash + # Required for local database operations + DATABASE_URL="postgresql://user:password@localhost:5432/dbname" + + # Required for remote database operations (use one of these) + REMOTE_DATABASE_URL="postgresql://user:password@remote-host:5432/dbname" + DATABASE_URL_PRODUCTION="postgresql://user:password@remote-host:5432/dbname" + ``` + +### Backup Commands + +```bash +# Backup local database +npm run db:backup:local + +# Backup remote database +npm run db:backup:remote + +# Sync remote to local (recommended for daily development) +npm run db:backup:sync + +# List all backups +npm run db:backups +``` + +### Restore Commands + +```bash +# Restore a backup to local database (interactive) +npm run db:restore + +# Restore specific backup to local +npm run db:restore ./backups/backup_file.sql.gz + +# Restore to remote (requires typing "RESTORE REMOTE" for safety) +npm run db:restore ./backups/backup_file.sql.gz remote +``` + +### Common Workflows + +#### Daily Development +Start your day by syncing the production database to local: +```bash +npm run db:backup:sync +``` + +#### Before Deploying Schema Changes +Always backup the remote database: +```bash +npm run db:backup:remote +``` + +#### Recover from Mistakes +```bash +# See available backups +npm run db:backups + +# Restore a specific backup +npm run db:restore ./backups/local_20240615_143022.sql.gz +``` + +### Backup Storage + +All backups are stored in `./backups/` with timestamps: +- Local: `local_YYYYMMDD_HHMMSS.sql.gz` +- Remote: `remote_YYYYMMDD_HHMMSS.sql.gz` + +### Safety Features + +1. **Automatic backups** before sync operations +2. **Confirmation prompts** for all destructive operations +3. **Extra protection** for remote restore (requires typing full phrase) +4. **Compressed storage** with gzip +5. **Timestamped filenames** prevent overwrites +6. **Automatic migrations** after local restore diff --git a/src/lib/components/MasonryPhotoGrid.svelte b/src/lib/components/MasonryPhotoGrid.svelte index 9ed0c32..a6d72c3 100644 --- a/src/lib/components/MasonryPhotoGrid.svelte +++ b/src/lib/components/MasonryPhotoGrid.svelte @@ -4,11 +4,9 @@ import type { PhotoItem as PhotoItemType } from '$lib/types/photos' const { - photoItems, - albumSlug + photoItems }: { photoItems: PhotoItemType[] - albumSlug?: string } = $props() // Responsive column configuration @@ -55,7 +53,7 @@ class="photo-masonry" > {#snippet children({ item })} - + {/snippet} diff --git a/src/lib/components/PhotoItem.svelte b/src/lib/components/PhotoItem.svelte index a3bc512..70b0607 100644 --- a/src/lib/components/PhotoItem.svelte +++ b/src/lib/components/PhotoItem.svelte @@ -4,11 +4,9 @@ import { goto } from '$app/navigation' const { - item, - albumSlug // For when this is used within an album context + item }: { item: PhotoItem - albumSlug?: string } = $props() let imageLoaded = $state(false) @@ -16,20 +14,11 @@ function handleClick() { if (isAlbum(item)) { // Navigate to album page using the slug - goto(`/photos/${item.slug}`) + goto(`/albums/${item.slug}`) } else { - // For individual photos, check if we have album context - if (albumSlug) { - // Navigate to photo within album - const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes - goto(`/photos/${albumSlug}/${mediaId}`) - } else { - // Navigate to individual photo page using the media ID - const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes - // Include the album slug as a 'from' parameter if we're in an album context - const url = albumSlug ? `/photos/p/${mediaId}?from=${albumSlug}` : `/photos/p/${mediaId}` - goto(url) - } + // Navigate to individual photo page using the media ID + const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes + goto(`/photos/${mediaId}`) } } diff --git a/src/lib/components/ThreeColumnPhotoGrid.svelte b/src/lib/components/ThreeColumnPhotoGrid.svelte index 2c9d347..216d8b5 100644 --- a/src/lib/components/ThreeColumnPhotoGrid.svelte +++ b/src/lib/components/ThreeColumnPhotoGrid.svelte @@ -4,11 +4,9 @@ import { goto } from '$app/navigation' const { - photoItems, - albumSlug + photoItems }: { photoItems: PhotoItemType[] - albumSlug?: string } = $props() // Function to determine if an image is ultrawide (aspect ratio > 2:1) @@ -98,18 +96,12 @@ function handleClick(item: PhotoItemType) { if (isAlbum(item)) { // Navigate to album page using the slug - goto(`/photos/${item.slug}`) + goto(`/albums/${item.slug}`) } else { // For individual photos, check if we have album context - if (albumSlug) { - // Navigate to photo within album - const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes - goto(`/photos/${albumSlug}/${mediaId}`) - } else { - // Navigate to individual photo page using the media ID - const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes - goto(`/photos/p/${mediaId}`) - } + // Always navigate to individual photo page using the media ID + const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes + goto(`/photos/${mediaId}`) } } diff --git a/src/lib/components/UniverseAlbumCard.svelte b/src/lib/components/UniverseAlbumCard.svelte index aff89c2..42e1e7a 100644 --- a/src/lib/components/UniverseAlbumCard.svelte +++ b/src/lib/components/UniverseAlbumCard.svelte @@ -37,7 +37,7 @@ showThumbnails={slideshowItems.length > 1} maxThumbnails={6} totalCount={album.photosCount} - showMoreLink="/photos/{album.slug}" + showMoreLink="/albums/{album.slug}" /> {/if} @@ -45,7 +45,7 @@ diff --git a/src/lib/components/admin/EnhancedComposer.svelte b/src/lib/components/admin/EnhancedComposer.svelte index 17cb17d..2c3851e 100644 --- a/src/lib/components/admin/EnhancedComposer.svelte +++ b/src/lib/components/admin/EnhancedComposer.svelte @@ -1,7 +1,7 @@ - +

Single Selection Mode

@@ -110,22 +110,22 @@
- -
diff --git a/src/routes/admin/posts/[id]/edit/+page.svelte b/src/routes/admin/posts/[id]/edit/+page.svelte index 6213178..4ddcc6c 100644 --- a/src/routes/admin/posts/[id]/edit/+page.svelte +++ b/src/routes/admin/posts/[id]/edit/+page.svelte @@ -3,7 +3,7 @@ import { goto } from '$app/navigation' import { onMount } from 'svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte' - import Composer from '$lib/components/admin/Composer.svelte' + import EnhancedComposer from '$lib/components/admin/EnhancedComposer.svelte' import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte' import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte' import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte' @@ -396,7 +396,7 @@ {#if config?.showContent && contentReady}
- +
{/if} diff --git a/src/routes/admin/posts/new/+page.svelte b/src/routes/admin/posts/new/+page.svelte index 996018b..35d161d 100644 --- a/src/routes/admin/posts/new/+page.svelte +++ b/src/routes/admin/posts/new/+page.svelte @@ -3,7 +3,7 @@ import { goto } from '$app/navigation' import { onMount } from 'svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte' - import Composer from '$lib/components/admin/Composer.svelte' + import EnhancedComposer from '$lib/components/admin/EnhancedComposer.svelte' import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte' import Button from '$lib/components/admin/Button.svelte' import PublishDropdown from '$lib/components/admin/PublishDropdown.svelte' @@ -199,7 +199,7 @@ {#if config?.showContent}
- +
{/if} diff --git a/src/routes/photos/[slug]/+page.svelte b/src/routes/albums/[slug]/+page.svelte similarity index 90% rename from src/routes/photos/[slug]/+page.svelte rename to src/routes/albums/[slug]/+page.svelte index a36f9b8..715fb84 100644 --- a/src/routes/photos/[slug]/+page.svelte +++ b/src/routes/albums/[slug]/+page.svelte @@ -9,9 +9,7 @@ let { data }: { data: PageData } = $props() - const type = $derived(data.type) const album = $derived(data.album) - const photo = $derived(data.photo) const error = $derived(data.error) // Transform album data to PhotoItem format for MasonryPhotoGrid @@ -45,7 +43,7 @@ // Generate metadata const metaTags = $derived( - type === 'album' && album + album ? generateMetaTags({ title: album.title, description: album.content @@ -58,20 +56,12 @@ image: album.photos?.[0]?.url, titleFormat: { type: 'by' } }) - : type === 'photo' && photo - ? generateMetaTags({ - title: photo.title || 'Photo', - description: photo.description || photo.caption || 'A photograph', - url: pageUrl, - image: photo.url, - titleFormat: { type: 'by' } - }) - : generateMetaTags({ - title: 'Not Found', - description: 'The content you are looking for could not be found.', - url: pageUrl, - noindex: true - }) + : generateMetaTags({ + title: 'Not Found', + description: 'The album you are looking for could not be found.', + url: pageUrl, + noindex: true + }) ) // Generate enhanced JSON-LD for albums with content @@ -125,20 +115,7 @@ } // Generate image gallery JSON-LD - const galleryJsonLd = $derived( - type === 'album' && album - ? generateAlbumJsonLd(album, pageUrl) - : type === 'photo' && photo - ? { - '@context': 'https://schema.org', - '@type': 'ImageObject', - name: photo.title || 'Photo', - description: photo.description || photo.caption, - contentUrl: photo.url, - url: pageUrl - } - : null - ) + const galleryJsonLd = $derived(album ? generateAlbumJsonLd(album, pageUrl) : null) @@ -174,7 +151,7 @@ -{:else if type === 'album' && album} +{:else if album}
{#snippet header()} @@ -210,7 +187,7 @@ {#if photoItems.length > 0}
- +
{:else}
@@ -434,9 +411,30 @@ } :global(figure img) { - max-width: 100%; + width: 100%; height: auto; border-radius: $card-corner-radius; + display: block; + } + + :global(figure.interactive-figure .photo-link) { + display: block; + text-decoration: none; + color: inherit; + outline: none; + cursor: pointer; + transition: transform 0.2s ease; + border-radius: $card-corner-radius; + overflow: hidden; + } + + :global(figure.interactive-figure .photo-link:hover) { + transform: translateY(-2px); + } + + :global(figure.interactive-figure .photo-link:focus-visible) { + outline: 2px solid $red-60; + outline-offset: 4px; } :global(figure figcaption) { diff --git a/src/routes/albums/[slug]/+page.ts b/src/routes/albums/[slug]/+page.ts new file mode 100644 index 0000000..bf6a9bd --- /dev/null +++ b/src/routes/albums/[slug]/+page.ts @@ -0,0 +1,29 @@ +import type { PageLoad } from './$types' + +export const load: PageLoad = async ({ params, fetch }) => { + try { + // Fetch album by slug + const albumResponse = await fetch(`/api/albums/by-slug/${params.slug}`) + if (albumResponse.ok) { + const album = await albumResponse.json() + + // Check if album is published + if (album.status === 'published') { + return { + album + } + } else { + throw new Error('Album not published') + } + } + + // Album not found + throw new Error('Album not found') + } catch (error) { + console.error('Error loading album:', error) + return { + album: null, + error: error instanceof Error ? error.message : 'Failed to load album' + } + } +} diff --git a/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts b/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts deleted file mode 100644 index 00ed935..0000000 --- a/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { RequestHandler } from './$types' -import { prisma } from '$lib/server/database' -import { jsonResponse, errorResponse } from '$lib/server/api-utils' -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 mediaId = parseInt(event.params.photoId) // Still called photoId in URL for compatibility - - if (!albumSlug || isNaN(mediaId)) { - return errorResponse('Invalid album slug or media ID', 400) - } - - try { - // First find the album with its media - const album = await prisma.album.findUnique({ - where: { - slug: albumSlug, - status: 'published', - isPhotography: true - }, - include: { - media: { - orderBy: { displayOrder: 'asc' }, - include: { - media: { - select: { - id: true, - filename: true, - url: true, - thumbnailUrl: true, - width: true, - height: true, - photoCaption: true, - photoTitle: true, - photoDescription: true, - exifData: true, - createdAt: true, - photoPublishedAt: true - } - } - } - } - } - }) - - if (!album) { - return errorResponse('Album not found', 404) - } - - // 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) - } - - 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 - - // Fetch all albums this photo belongs to - const mediaWithAlbums = await prisma.media.findUnique({ - where: { id: mediaId }, - include: { - albums: { - include: { - album: { - select: { id: true, title: true, slug: true } - } - }, - where: { - album: { - status: 'published', - isPhotography: true - } - } - } - } - }) - - // Transform to photo format for compatibility - const photo = { - id: media.id, - 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, - albums: mediaWithAlbums?.albums.map((am) => am.album) || [] - } - - return jsonResponse({ - photo, - album: { - id: album.id, - slug: album.slug, - title: album.title, - description: album.description, - location: album.location, - date: album.date, - totalPhotos: album.media.length - }, - navigation: { - 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) { - logger.error('Failed to retrieve photo', error as Error) - return errorResponse('Failed to retrieve photo', 500) - } -} diff --git a/src/routes/api/photos/[id]/+server.ts b/src/routes/api/photos/[id]/+server.ts index 6b6d8fd..c0c98ba 100644 --- a/src/routes/api/photos/[id]/+server.ts +++ b/src/routes/api/photos/[id]/+server.ts @@ -54,10 +54,9 @@ export const GET: RequestHandler = async (event) => { }) logger.info('Album check', { albumId: album.id, - status: fullAlbum?.status, - isPhotography: fullAlbum?.isPhotography + status: fullAlbum?.status }) - if (!fullAlbum || fullAlbum.status !== 'published' || !fullAlbum.isPhotography) { + if (!fullAlbum || fullAlbum.status !== 'published') { logger.warn('Album not valid for public access', { albumId: album.id }) return errorResponse('Photo not found', 404) } diff --git a/src/routes/photos/[albumSlug]/[photoId]/+page.svelte b/src/routes/photos/[albumSlug]/[photoId]/+page.svelte deleted file mode 100644 index 9a3da63..0000000 --- a/src/routes/photos/[albumSlug]/[photoId]/+page.svelte +++ /dev/null @@ -1,524 +0,0 @@ - - - - {metaTags.title} - - - - {#each Object.entries(metaTags.openGraph) as [property, content]} - - {/each} - - - {#each Object.entries(metaTags.twitter) as [property, content]} - - {/each} - - - {#if metaTags.other.canonical} - - {/if} - {#if metaTags.other.robots} - - {/if} - - - {#if photoJsonLd} - {@html ``} - {/if} - - -{#if error || !photo || !album} -
-
-

Photo Not Found

-

{error || "The photo you're looking for doesn't exist."}

- -
-
-{:else} -
-
- -
- - -
- {#if navigation.prevPhoto} - - {/if} - - {#if navigation.nextPhoto} - - {/if} -
- - -
-{/if} - - diff --git a/src/routes/photos/[albumSlug]/[photoId]/+page.ts b/src/routes/photos/[albumSlug]/[photoId]/+page.ts deleted file mode 100644 index f129ca1..0000000 --- a/src/routes/photos/[albumSlug]/[photoId]/+page.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { PageLoad } from './$types' - -export const load: PageLoad = async ({ params, fetch }) => { - try { - const { albumSlug, photoId } = params - const mediaId = parseInt(photoId) - - if (isNaN(mediaId)) { - throw new Error('Invalid photo ID') - } - - // Fetch the photo and album data with navigation - const response = await fetch(`/api/photos/${albumSlug}/${mediaId}`) - - if (!response.ok) { - if (response.status === 404) { - throw new Error('Photo or album not found') - } - throw new Error('Failed to fetch photo') - } - - const data = await response.json() - - return { - photo: data.photo, - album: data.album, - navigation: data.navigation - } - } catch (error) { - console.error('Error loading photo:', error) - return { - photo: null, - album: null, - navigation: null, - error: error instanceof Error ? error.message : 'Failed to load photo' - } - } -} diff --git a/src/routes/photos/p/[id]/+page.svelte b/src/routes/photos/[id]/+page.svelte similarity index 99% rename from src/routes/photos/p/[id]/+page.svelte rename to src/routes/photos/[id]/+page.svelte index caa4b93..bc8b8ef 100644 --- a/src/routes/photos/p/[id]/+page.svelte +++ b/src/routes/photos/[id]/+page.svelte @@ -111,7 +111,7 @@ if (!item) return // Extract media ID from item.id (could be 'media-123' or 'photo-123') const mediaId = item.id.replace(/^(media|photo)-/, '') - goto(`/photos/p/${mediaId}`) + goto(`/photos/${mediaId}`) } function handleKeydown(e: KeyboardEvent) { @@ -411,9 +411,9 @@ createdAt={photo.createdAt} albums={photo.albums} backHref={fromAlbum - ? `/photos/${fromAlbum}` + ? `/albums/${fromAlbum}` : photo.album - ? `/photos/${photo.album.slug}` + ? `/albums/${photo.album.slug}` : '/photos'} backLabel={(() => { if (fromAlbum && photo.albums) { diff --git a/src/routes/photos/p/[id]/+page.ts b/src/routes/photos/[id]/+page.ts similarity index 100% rename from src/routes/photos/p/[id]/+page.ts rename to src/routes/photos/[id]/+page.ts diff --git a/src/routes/photos/[slug]/+page.ts b/src/routes/photos/[slug]/+page.ts deleted file mode 100644 index 1ceec70..0000000 --- a/src/routes/photos/[slug]/+page.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { PageLoad } from './$types' - -export const load: PageLoad = async ({ params, fetch }) => { - try { - // First try to fetch as an album - const albumResponse = await fetch(`/api/albums/by-slug/${params.slug}`) - if (albumResponse.ok) { - const album = await albumResponse.json() - - // Check if album is published - if (album.status === 'published') { - return { - type: 'album' as const, - album, - photo: null - } - } - } - - // If not found as album or not a photography album, try as individual photo - const photoResponse = await fetch(`/api/photos/by-slug/${params.slug}`) - if (photoResponse.ok) { - const photo = await photoResponse.json() - return { - type: 'photo' as const, - album: null, - photo - } - } - - // Neither album nor photo found - throw new Error('Content not found') - } catch (error) { - console.error('Error loading content:', error) - return { - type: null, - album: null, - photo: null, - error: error instanceof Error ? error.message : 'Failed to load content' - } - } -}