diff --git a/prd/PRD-dominant-color-extraction.md b/prd/PRD-dominant-color-extraction.md index cef28c5..7b482f1 100644 --- a/prd/PRD-dominant-color-extraction.md +++ b/prd/PRD-dominant-color-extraction.md @@ -16,11 +16,13 @@ This PRD outlines the implementation of automatic dominant color extraction for ### Color Extraction Library Options 1. **node-vibrant** (Recommended) + - Pros: Lightweight, fast, good algorithm, actively maintained - Cons: Node.js only (server-side processing) - NPM: `node-vibrant` 2. **color-thief-node** + - Pros: Simple API, battle-tested algorithm - Cons: Less feature-rich than vibrant - NPM: `colorthief` @@ -38,18 +40,19 @@ import Vibrant from 'node-vibrant' // Extract colors from uploaded image const palette = await Vibrant.from(buffer).getPalette() const dominantColors = { - vibrant: palette.Vibrant?.hex, - darkVibrant: palette.DarkVibrant?.hex, - lightVibrant: palette.LightVibrant?.hex, - muted: palette.Muted?.hex, - darkMuted: palette.DarkMuted?.hex, - lightMuted: palette.LightMuted?.hex + vibrant: palette.Vibrant?.hex, + darkVibrant: palette.DarkVibrant?.hex, + lightVibrant: palette.LightVibrant?.hex, + muted: palette.Muted?.hex, + darkMuted: palette.DarkMuted?.hex, + lightMuted: palette.LightMuted?.hex } ``` ## Database Schema Changes ### Option 1: Add to Existing exifData JSON (Recommended) + ```prisma model Media { // ... existing fields @@ -58,6 +61,7 @@ model Media { ``` ### Option 2: Separate Colors Field + ```prisma model Media { // ... existing fields @@ -74,19 +78,19 @@ Update the upload handler to extract colors: ```typescript // After successful upload to Cloudinary if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') { - const buffer = await file.arrayBuffer() - - // Extract EXIF data (existing) - const exifData = await extractExifData(file) - - // Extract dominant colors (new) - const colorData = await extractDominantColors(buffer) - - // Combine data - const metadata = { - ...exifData, - colors: colorData - } + const buffer = await file.arrayBuffer() + + // Extract EXIF data (existing) + const exifData = await extractExifData(file) + + // Extract dominant colors (new) + const colorData = await extractDominantColors(buffer) + + // Combine data + const metadata = { + ...exifData, + colors: colorData + } } ``` @@ -94,36 +98,40 @@ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') { ```json { - "id": 123, - "url": "...", - "dominantColors": { - "vibrant": "#4285f4", - "darkVibrant": "#1a73e8", - "lightVibrant": "#8ab4f8", - "muted": "#5f6368", - "darkMuted": "#3c4043", - "lightMuted": "#e8eaed" - } + "id": 123, + "url": "...", + "dominantColors": { + "vibrant": "#4285f4", + "darkVibrant": "#1a73e8", + "lightVibrant": "#8ab4f8", + "muted": "#5f6368", + "darkMuted": "#3c4043", + "lightMuted": "#e8eaed" + } } ``` ## UI/UX Considerations ### 1. Media Library Display + - Show color swatches on hover/focus - Optional: Color-based filtering or sorting ### 2. Gallery Image Modal + - Display color palette in metadata section - Show hex values for each color - Copy-to-clipboard functionality for colors ### 3. Album/Gallery Views + - Use dominant color for background accents - Create dynamic gradients from extracted colors - Enhance loading states with color placeholders ### 4. Potential Future Features + - Color-based search ("find blue images") - Automatic theme generation for albums - Color harmony analysis for galleries @@ -131,6 +139,7 @@ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') { ## Implementation Plan ### Phase 1: Backend Implementation (1 day) + 1. Install and configure node-vibrant 2. Create color extraction utility function 3. Integrate into upload pipeline @@ -138,16 +147,19 @@ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') { 5. Update API responses ### Phase 2: Basic Frontend Display (0.5 day) + 1. Update Media type definitions 2. Display colors in GalleryImageModal 3. Add color swatches to media details ### Phase 3: Enhanced UI Features (1 day) + 1. Implement color-based backgrounds 2. Add loading placeholders with colors 3. Create color palette component ### Phase 4: Testing & Optimization (0.5 day) + 1. Test with various image types 2. Optimize for performance 3. Handle edge cases (B&W images, etc.) @@ -184,4 +196,4 @@ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') { - Total effort: 2-3 days - Can be implemented incrementally -- No breaking changes to existing functionality \ No newline at end of file +- No breaking changes to existing functionality diff --git a/prd/PRD-og-image-generation.md b/prd/PRD-og-image-generation.md index a7fde8b..1d939ef 100644 --- a/prd/PRD-og-image-generation.md +++ b/prd/PRD-og-image-generation.md @@ -7,12 +7,14 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati ## Problem Statement ### Current State + - Most pages use a static default OG image - Dynamic content (projects, essays, photos) doesn't have representative imagery when shared - No visual differentiation between content types in social previews - Missed opportunity for branding and engagement ### Impact + - Poor social media engagement rates - Generic appearance when content is shared - Lost opportunity to showcase project visuals and branding @@ -31,8 +33,9 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati ### Content Type Requirements #### 1. Work Projects + - **Format**: [Avatar] + [Logo] (with "+" symbol) centered on brand background color -- **Data needed**: +- **Data needed**: - Project logo URL (`logoUrl`) - Brand background color (`backgroundColor`) - Avatar image (use existing `src/assets/illos/jedmund.svg`) @@ -41,9 +44,10 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati - **Font**: cstd Regular for any text #### 2. Essays (Universe) + - **Format**: Universe icon + "Universe" label above essay title - **Layout**: Left-aligned, vertically centered content block -- **Styling**: +- **Styling**: - 32px padding on all edges - Universe icon (24x24) + 8px gap + "Universe" label (smaller font) - Essay title below (larger font, max 2 lines with ellipsis) @@ -54,9 +58,10 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati - **Font**: cstd Regular for all text #### 3. Labs Projects + - **Format**: Labs icon + "Labs" label above project title - **Layout**: Same as Essays - left-aligned, vertically centered -- **Styling**: +- **Styling**: - 32px padding on all edges - Labs icon (24x24) + 8px gap + "Labs" label (smaller font) - Project title below (larger font, max 2 lines with ellipsis) @@ -67,16 +72,18 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati - **Font**: cstd Regular for all text #### 4. Photos + - **Format**: The photo itself, fitted within frame -- **Styling**: +- **Styling**: - Photo scaled to fit within 1200x630 bounds - Avatar (48x48) in bottom right corner - **Data needed**: Photo URL #### 5. Albums + - **Format**: First photo (blurred) as background + Photos format overlay - **Layout**: Same as Essays/Labs - left-aligned, vertically centered -- **Styling**: +- **Styling**: - First photo as blurred background (using CSS filter or canvas blur) - 32px padding on all edges - Photos icon (24x24) + 8px gap + "Photos" label (smaller font) @@ -86,6 +93,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati - **Font**: cstd Regular for all text #### 6. Root Pages (Homepage, Universe, Photos, Labs, About) + - **No change**: Continue using existing static OG image ### Technical Requirements @@ -141,6 +149,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati ### Implementation Details #### 1. API Endpoint Structure + ```typescript /api/og-image?type=work&title=Project&logo=url&bg=color /api/og-image?type=essay&title=Essay+Title @@ -150,6 +159,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati ``` #### 2. Hybrid Template System + - SVG templates for text-based layouts (work, essays, labs, photos) - Canvas/Sharp for blur effects (albums) - Use template literals for dynamic content injection @@ -157,6 +167,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati - All text rendered in cstd Regular font #### 3. Asset Management + - Avatar: Use existing SVG at src/assets/illos/jedmund.svg, convert to base64 - Icons: Convert Universe, Labs, Photos icons to base64 - Fonts: Embed cstd Regular font for consistent rendering @@ -167,17 +178,20 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati ##### Multi-Level Caching Architecture **Level 1: Cloudinary CDN (Permanent Storage)** + - Upload generated images to `jedmund/og-images/` folder - Use content-based public IDs: `og-{type}-{contentHash}` - Leverage Cloudinary's global CDN for distribution - Automatic format optimization and responsive delivery **Level 2: Redis Cache (Fast Lookups)** + - Cache mapping: content ID → Cloudinary public ID - TTL: 24 hours for quick access - Key structure: `og:{type}:{id}:{version}` → `cloudinary_public_id` **Level 3: Browser Cache (Client-side)** + - Set long cache headers on Cloudinary URLs - Immutable URLs with content-based versioning @@ -185,18 +199,18 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati ```typescript function generateOgImageId(type: string, data: any): string { - const content = { - type, - // Include only content that affects the image - ...(type === 'work' && { title: data.title, logo: data.logoUrl, bg: data.backgroundColor }), - ...(type === 'essay' && { title: data.title }), - ...(type === 'labs' && { title: data.title }), - ...(type === 'photo' && { url: data.url }), - ...(type === 'album' && { title: data.title, firstPhoto: data.photos[0].src }) - } - - const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex').slice(0, 8) - return `og-${type}-${hash}` + const content = { + type, + // Include only content that affects the image + ...(type === 'work' && { title: data.title, logo: data.logoUrl, bg: data.backgroundColor }), + ...(type === 'essay' && { title: data.title }), + ...(type === 'labs' && { title: data.title }), + ...(type === 'photo' && { url: data.url }), + ...(type === 'album' && { title: data.title, firstPhoto: data.photos[0].src }) + } + + const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex').slice(0, 8) + return `og-${type}-${hash}` } ``` @@ -242,6 +256,7 @@ src/ ## Implementation Plan ### Phase 1: Foundation (Day 1) + - [ ] Install dependencies (sharp for image processing) - [ ] Create API endpoint structure - [ ] Set up Cloudinary integration for og-images folder @@ -249,6 +264,7 @@ src/ - [ ] Implement basic SVG to PNG conversion ### Phase 2: Asset Preparation (Day 2) + - [ ] Load Avatar SVG from src/assets/illos/jedmund.svg - [ ] Convert Avatar SVG to base64 for embedding - [ ] Convert Universe, Labs, Photos icons to base64 @@ -257,6 +273,7 @@ src/ - [ ] Test asset embedding in SVGs ### Phase 3: Template Development (Days 3-4) + - [ ] Create Work project template - [ ] Create Essay/Universe template - [ ] Create Labs template (reuse Essay structure) @@ -264,6 +281,7 @@ src/ - [ ] Create Album template ### Phase 4: Integration (Day 5) + - [ ] Update metadata utils to generate OG image URLs - [ ] Implement Cloudinary upload pipeline - [ ] Set up Redis caching for Cloudinary URLs @@ -272,6 +290,7 @@ src/ - [ ] Test all content types ### Phase 5: Optimization (Day 6) + - [ ] Performance testing - [ ] Add rate limiting - [ ] Optimize SVG generation @@ -280,38 +299,48 @@ src/ ## Potential Pitfalls & Mitigations ### 1. Performance Issues + **Risk**: SVG to PNG conversion could be slow, especially with blur effects -**Mitigation**: +**Mitigation**: + - Pre-generate common images - Use efficient SVG structures for text-based layouts - Use Sharp's built-in blur capabilities for album backgrounds - Implement request coalescing ### 2. Memory Usage + **Risk**: Image processing could consume significant memory **Mitigation**: + - Stream processing where possible - Implement memory limits - Use worker threads if needed ### 3. Font Rendering + **Risk**: cstd Regular font may not render consistently **Mitigation**: + - Embed cstd Regular font as base64 in SVG - Use font subsetting to reduce size - Test rendering across different platforms - Fallback to similar web-safe fonts if needed ### 4. Asset Loading + **Risk**: External assets could fail to load **Mitigation**: + - Embed all assets as base64 - No external dependencies - Graceful fallbacks ### 5. Cache Invalidation + **Risk**: Updated content shows old OG images **Mitigation**: + - Include version/timestamp in URL params - Use content-based cache keys - Provide manual cache purge option @@ -337,16 +366,19 @@ src/ ### Admin UI for OG Image Management 1. **OG Image Viewer** + - Display current OG image for each content type - Show Cloudinary URL and metadata - Preview how it appears on social platforms 2. **Manual Regeneration** + - "Regenerate OG Image" button per content item - Preview new image before confirming - Bulk regeneration tools for content types 3. **Analytics Dashboard** + - Track generation frequency - Monitor cache hit rates - Show most viewed OG images @@ -359,6 +391,7 @@ src/ ## Task Checklist ### High Priority + - [ ] Set up API endpoint with proper routing - [ ] Install sharp and @resvg/resvg-js for image processing - [ ] Configure Cloudinary og-images folder @@ -377,6 +410,7 @@ src/ - [ ] Test end-to-end caching flow ### Medium Priority + - [ ] Add comprehensive error handling - [ ] Implement rate limiting - [ ] Add request logging @@ -384,12 +418,14 @@ src/ - [ ] Performance optimization ### Low Priority + - [ ] Add monitoring dashboard - [ ] Create manual regeneration endpoint - [ ] Add A/B testing capability - [ ] Documentation ### Stretch Goals + - [ ] Admin UI: OG image viewer - [ ] Admin UI: Manual regeneration button - [ ] Admin UI: Bulk regeneration tools @@ -400,6 +436,7 @@ src/ ## Dependencies ### Required Packages + - `sharp`: For SVG to PNG conversion and blur effects - `@resvg/resvg-js`: Alternative high-quality SVG to PNG converter - `cloudinary`: Already installed, for image storage and CDN @@ -407,18 +444,21 @@ src/ - Built-in Node.js modules for base64 encoding ### External Assets Needed + - Avatar SVG (existing at src/assets/illos/jedmund.svg) - Universe icon SVG -- Labs icon SVG +- Labs icon SVG - Photos icon SVG - cstd Regular font file ### API Requirements + - Access to project data (logo, colors) - Access to photo URLs - Access to content titles and descriptions ### Infrastructure Requirements + - Cloudinary account with og-images folder configured - Redis instance for caching (already available) -- Railway deployment (no local disk storage) \ No newline at end of file +- Railway deployment (no local disk storage) diff --git a/scripts/check-photos-display.ts b/scripts/check-photos-display.ts index 8c5c519..9514e8c 100644 --- a/scripts/check-photos-display.ts +++ b/scripts/check-photos-display.ts @@ -22,7 +22,7 @@ async function checkPhotosDisplay() { }) console.log(`Found ${photographyAlbums.length} published photography albums:`) - photographyAlbums.forEach(album => { + photographyAlbums.forEach((album) => { console.log(`- "${album.title}" (${album.slug}): ${album.photos.length} published photos`) }) @@ -36,7 +36,7 @@ async function checkPhotosDisplay() { }) console.log(`\nFound ${individualPhotos.length} individual photos marked to show in Photos`) - individualPhotos.forEach(photo => { + individualPhotos.forEach((photo) => { console.log(`- Photo ID ${photo.id}: ${photo.filename}`) }) @@ -52,12 +52,17 @@ async function checkPhotosDisplay() { } }) - console.log(`\nFound ${photosInAlbums.length} published photos in albums with showInPhotos=true`) - const albumGroups = photosInAlbums.reduce((acc, photo) => { - const albumTitle = photo.album?.title || 'Unknown' - acc[albumTitle] = (acc[albumTitle] || 0) + 1 - return acc - }, {} as Record) + console.log( + `\nFound ${photosInAlbums.length} published photos in albums with showInPhotos=true` + ) + const albumGroups = photosInAlbums.reduce( + (acc, photo) => { + const albumTitle = photo.album?.title || 'Unknown' + acc[albumTitle] = (acc[albumTitle] || 0) + 1 + return acc + }, + {} as Record + ) Object.entries(albumGroups).forEach(([album, count]) => { console.log(`- Album "${album}": ${count} photos`) @@ -80,10 +85,13 @@ async function checkPhotosDisplay() { }) console.log(`\nTotal photos in database: ${allPhotos.length}`) - const statusCounts = allPhotos.reduce((acc, photo) => { - acc[photo.status] = (acc[photo.status] || 0) + 1 - return acc - }, {} as Record) + const statusCounts = allPhotos.reduce( + (acc, photo) => { + acc[photo.status] = (acc[photo.status] || 0) + 1 + return acc + }, + {} as Record + ) Object.entries(statusCounts).forEach(([status, count]) => { console.log(`- Status "${status}": ${count} photos`) @@ -99,10 +107,11 @@ async function checkPhotosDisplay() { }) console.log(`\nTotal albums in database: ${allAlbums.length}`) - allAlbums.forEach(album => { - console.log(`- "${album.title}" (${album.slug}): status=${album.status}, isPhotography=${album.isPhotography}, photos=${album._count.photos}`) + allAlbums.forEach((album) => { + console.log( + `- "${album.title}" (${album.slug}): status=${album.status}, isPhotography=${album.isPhotography}, photos=${album._count.photos}` + ) }) - } catch (error) { console.error('Error checking photos:', error) } finally { @@ -110,4 +119,4 @@ async function checkPhotosDisplay() { } } -checkPhotosDisplay() \ No newline at end of file +checkPhotosDisplay() diff --git a/scripts/debug-photos.md b/scripts/debug-photos.md index 83693e2..74ecf0c 100644 --- a/scripts/debug-photos.md +++ b/scripts/debug-photos.md @@ -5,11 +5,13 @@ This directory contains tools to debug why photos aren't appearing on the photos ## API Test Endpoint Visit the following URL in your browser while the dev server is running: + ``` http://localhost:5173/api/test-photos ``` This endpoint will return detailed information about: + - All photos with showInPhotos=true and albumId=null - Status distribution of these photos - Raw SQL query results @@ -18,11 +20,13 @@ This endpoint will return detailed information about: ## Database Query Script Run the following command to query the database directly: + ```bash npx tsx scripts/test-photos-query.ts ``` This script will show: + - Total photos in the database - Photos matching the criteria (showInPhotos=true, albumId=null) - Status distribution @@ -39,4 +43,4 @@ This script will show: - Photos might be in 'draft' status instead of 'published' - Photos might have showInPhotos=false -- Photos might be associated with an album (albumId is not null) \ No newline at end of file +- Photos might be associated with an album (albumId is not null) diff --git a/scripts/migrate-photos-to-media.ts b/scripts/migrate-photos-to-media.ts index f5cc73b..474687e 100644 --- a/scripts/migrate-photos-to-media.ts +++ b/scripts/migrate-photos-to-media.ts @@ -59,7 +59,7 @@ async function main() { } }) createdMediaCount++ - + // Update the photo to reference the new media await prisma.photo.update({ where: { id: photo.id }, @@ -69,10 +69,14 @@ async function main() { // 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 + const mediaId = + photo.mediaId || + ( + await prisma.photo.findUnique({ + where: { id: photo.id }, + select: { mediaId: true } + }) + )?.mediaId if (mediaId) { // Check if AlbumMedia already exists @@ -121,7 +125,6 @@ async function main() { 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) @@ -130,4 +133,4 @@ async function main() { } } -main() \ No newline at end of file +main() diff --git a/scripts/test-media-sharing.ts b/scripts/test-media-sharing.ts index 93f2d67..6caf9e9 100755 --- a/scripts/test-media-sharing.ts +++ b/scripts/test-media-sharing.ts @@ -133,7 +133,7 @@ async function testMediaSharing() { console.log(`✓ Album 1 has ${verifyAlbum1?.photos.length} photo(s)`) console.log(` - Photo mediaId: ${verifyAlbum1?.photos[0]?.mediaId}`) console.log(` - Media filename: ${verifyAlbum1?.photos[0]?.media?.filename}`) - + console.log(`✓ Album 2 has ${verifyAlbum2?.photos.length} photo(s)`) console.log(` - Photo mediaId: ${verifyAlbum2?.photos[0]?.mediaId}`) console.log(` - Media filename: ${verifyAlbum2?.photos[0]?.media?.filename}\n`) @@ -186,7 +186,6 @@ async function testMediaSharing() { where: { id: media.id } }) console.log('✓ Test data cleaned up') - } catch (error) { console.error('\n❌ ERROR:', error) process.exit(1) @@ -196,4 +195,4 @@ async function testMediaSharing() { } // Run the test -testMediaSharing() \ No newline at end of file +testMediaSharing() diff --git a/scripts/test-photos-query.ts b/scripts/test-photos-query.ts index b713405..f229105 100644 --- a/scripts/test-photos-query.ts +++ b/scripts/test-photos-query.ts @@ -30,8 +30,10 @@ async function testPhotoQueries() { }) console.log(`\nPhotos with showInPhotos=true and albumId=null: ${photosForDisplay.length}`) - photosForDisplay.forEach(photo => { - console.log(` - ID: ${photo.id}, Status: ${photo.status}, Slug: ${photo.slug || 'none'}, File: ${photo.filename}`) + photosForDisplay.forEach((photo) => { + console.log( + ` - ID: ${photo.id}, Status: ${photo.status}, Slug: ${photo.slug || 'none'}, File: ${photo.filename}` + ) }) // Query 3: Check status distribution @@ -60,8 +62,10 @@ async function testPhotoQueries() { } }) - console.log(`\nPublished photos (status='published', showInPhotos=true, albumId=null): ${publishedPhotos.length}`) - publishedPhotos.forEach(photo => { + console.log( + `\nPublished photos (status='published', showInPhotos=true, albumId=null): ${publishedPhotos.length}` + ) + publishedPhotos.forEach((photo) => { console.log(` - ID: ${photo.id}, File: ${photo.filename}, Published: ${photo.publishedAt}`) }) @@ -76,7 +80,7 @@ async function testPhotoQueries() { if (draftPhotos.length > 0) { console.log(`\n⚠️ Found ${draftPhotos.length} draft photos with showInPhotos=true:`) - draftPhotos.forEach(photo => { + draftPhotos.forEach((photo) => { console.log(` - ID: ${photo.id}, File: ${photo.filename}`) }) console.log('These photos need to be published to appear in the photos page!') @@ -94,7 +98,6 @@ async function testPhotoQueries() { uniqueStatuses.forEach(({ status }) => { console.log(` - "${status}"`) }) - } catch (error) { console.error('Error running queries:', error) } finally { @@ -103,4 +106,4 @@ async function testPhotoQueries() { } // Run the test -testPhotoQueries() \ No newline at end of file +testPhotoQueries() diff --git a/src/lib/components/PhotoView.svelte b/src/lib/components/PhotoView.svelte index b377113..e22996d 100644 --- a/src/lib/components/PhotoView.svelte +++ b/src/lib/components/PhotoView.svelte @@ -52,4 +52,4 @@ :global([data-smiz-btn-unzoom]) { display: none !important; } - \ No newline at end of file + diff --git a/src/lib/components/admin/AlbumForm.svelte b/src/lib/components/admin/AlbumForm.svelte index f73b955..9af4d65 100644 --- a/src/lib/components/admin/AlbumForm.svelte +++ b/src/lib/components/admin/AlbumForm.svelte @@ -167,7 +167,6 @@ } } - function handleCancel() { if (hasChanges() && !confirm('Are you sure you want to cancel? Your changes will be lost.')) { return diff --git a/src/lib/components/admin/CaseStudyEditor.svelte b/src/lib/components/admin/CaseStudyEditor.svelte new file mode 100644 index 0000000..09aab5a --- /dev/null +++ b/src/lib/components/admin/CaseStudyEditor.svelte @@ -0,0 +1,162 @@ + + +
+ +
+ + diff --git a/src/lib/components/admin/Editor.svelte b/src/lib/components/admin/Editor.svelte index 18e7d34..d829141 100644 --- a/src/lib/components/admin/Editor.svelte +++ b/src/lib/components/admin/Editor.svelte @@ -131,10 +131,12 @@ } :global(.editor-content .editor-toolbar) { - border-radius: $card-corner-radius; + border-radius: $corner-radius-full; + border: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 0 16px rgba(0, 0, 0, 0.12); box-sizing: border-box; background: $grey-95; - padding: $unit-2x; + padding: $unit $unit-2x; position: sticky; z-index: 10; overflow-x: auto; diff --git a/src/lib/components/admin/EditorWithUpload.svelte b/src/lib/components/admin/EditorWithUpload.svelte index 554580c..6dc0074 100644 --- a/src/lib/components/admin/EditorWithUpload.svelte +++ b/src/lib/components/admin/EditorWithUpload.svelte @@ -251,7 +251,11 @@ const editorInstance = (view as any).editor if (editorInstance) { // Use pasteHTML to let Tiptap process the HTML and apply configured extensions - editorInstance.chain().focus().insertContent(htmlData, { parseOptions: { preserveWhitespace: false } }).run() + editorInstance + .chain() + .focus() + .insertContent(htmlData, { parseOptions: { preserveWhitespace: false } }) + .run() } else { // Fallback to plain text if editor instance not available const { state, dispatch } = view @@ -506,6 +510,7 @@ } }} class="edra-editor" + class:with-toolbar={showToolbar} > @@ -724,7 +729,7 @@ {/if} - diff --git a/src/lib/components/admin/SimplePostForm.svelte b/src/lib/components/admin/SimplePostForm.svelte index 833f7b1..2b1c0a5 100644 --- a/src/lib/components/admin/SimplePostForm.svelte +++ b/src/lib/components/admin/SimplePostForm.svelte @@ -336,7 +336,7 @@ .title-input { width: 100%; - padding: $unit-3x; + padding: $unit-4x; border: none; background: transparent; font-size: 1rem; diff --git a/src/lib/components/admin/StatusDropdown.svelte b/src/lib/components/admin/StatusDropdown.svelte index 366a09d..34f9129 100644 --- a/src/lib/components/admin/StatusDropdown.svelte +++ b/src/lib/components/admin/StatusDropdown.svelte @@ -109,7 +109,12 @@ {#if availableActions.length > 0} {/if} - + View on site {/if} diff --git a/src/lib/components/admin/UniverseComposer.svelte b/src/lib/components/admin/UniverseComposer.svelte index 19a9fa0..4ad302b 100644 --- a/src/lib/components/admin/UniverseComposer.svelte +++ b/src/lib/components/admin/UniverseComposer.svelte @@ -2,7 +2,7 @@ import { createEventDispatcher } from 'svelte' import { goto } from '$app/navigation' import Modal from './Modal.svelte' - import Editor from './Editor.svelte' + import CaseStudyEditor from './CaseStudyEditor.svelte' import AdminSegmentedControl from './AdminSegmentedControl.svelte' import FormFieldWrapper from './FormFieldWrapper.svelte' import Button from './Button.svelte' @@ -288,7 +288,7 @@
- { @@ -296,11 +296,10 @@ characterCount = getTextFromContent(newContent) }} placeholder="What's on your mind?" - simpleMode={true} - autofocus={true} minHeight={80} + autofocus={true} + mode="inline" showToolbar={false} - class="composer-editor" /> {#if attachedPhotos.length > 0} @@ -440,7 +439,7 @@
{:else}
- { @@ -448,9 +447,9 @@ characterCount = getTextFromContent(newContent) }} placeholder="Start writing your essay..." - simpleMode={false} - autofocus={true} minHeight={500} + autofocus={true} + mode="default" />
{/if} @@ -484,7 +483,7 @@
- { @@ -492,11 +491,10 @@ characterCount = getTextFromContent(newContent) }} placeholder="What's on your mind?" - simpleMode={true} - autofocus={true} minHeight={120} + autofocus={true} + mode="inline" showToolbar={false} - class="inline-composer-editor" /> {#if attachedPhotos.length > 0} @@ -651,47 +649,6 @@ .composer-body { display: flex; flex-direction: column; - - :global(.edra-editor) { - padding: 0; - } - } - - :global(.composer-editor) { - border: none !important; - box-shadow: none !important; - - :global(.editor-container) { - padding: 0 $unit-3x; - } - - :global(.editor-content) { - padding: 0; - min-height: 80px; - font-size: 15px; - line-height: 1.5; - } - - :global(.ProseMirror) { - padding: 0; - min-height: 80px; - - &:focus { - outline: none; - } - - p { - margin: 0; - } - - &.ProseMirror-focused .is-editor-empty:first-child::before { - color: $grey-40; - content: attr(data-placeholder); - float: left; - pointer-events: none; - height: 0; - } - } } .link-fields { @@ -790,10 +747,6 @@ .composer-body { display: flex; flex-direction: column; - - :global(.edra-editor) { - padding: 0; - } } } @@ -811,44 +764,6 @@ } } - :global(.inline-composer-editor) { - border: none !important; - box-shadow: none !important; - background: transparent !important; - - :global(.editor-container) { - padding: $unit * 1.5 $unit-3x 0; - } - - :global(.editor-content) { - padding: 0; - min-height: 120px; - font-size: 15px; - line-height: 1.5; - } - - :global(.ProseMirror) { - padding: 0; - min-height: 120px; - - &:focus { - outline: none; - } - - p { - margin: 0; - } - - &.ProseMirror-focused .is-editor-empty:first-child::before { - color: $grey-40; - content: attr(data-placeholder); - float: left; - pointer-events: none; - height: 0; - } - } - } - .inline-composer .link-fields { padding: 0 $unit-3x; display: flex; diff --git a/src/lib/stores/mouse.ts b/src/lib/stores/mouse.ts index 32bc69f..6d906a0 100644 --- a/src/lib/stores/mouse.ts +++ b/src/lib/stores/mouse.ts @@ -9,7 +9,7 @@ if (typeof window !== 'undefined') { window.addEventListener('mousemove', (e) => { mousePosition.set({ x: e.clientX, y: e.clientY }) }) - + // Also capture initial position if mouse is already over window document.addEventListener('DOMContentLoaded', () => { // Force a mouse event to get initial position @@ -18,7 +18,7 @@ if (typeof window !== 'undefined') { clientY: 0, bubbles: true }) - + // If the mouse is already over the document, this will update document.dispatchEvent(event) }) @@ -27,4 +27,4 @@ if (typeof window !== 'undefined') { // Helper function to get current mouse position export function getCurrentMousePosition() { return get(mousePosition) -} \ No newline at end of file +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3d6cf30..db484b2 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -7,17 +7,19 @@ const isAdminRoute = $derived($page.url.pathname.startsWith('/admin')) // Generate person structured data for the site - const personJsonLd = $derived(generatePersonJsonLd({ - name: 'Justin Edmund', - jobTitle: 'Software Designer', - description: 'Software designer based in San Francisco', - url: 'https://jedmund.com', - sameAs: [ - 'https://twitter.com/jedmund', - 'https://github.com/jedmund', - 'https://www.linkedin.com/in/jedmund' - ] - })) + const personJsonLd = $derived( + generatePersonJsonLd({ + name: 'Justin Edmund', + jobTitle: 'Software Designer', + description: 'Software designer based in San Francisco', + url: 'https://jedmund.com', + sameAs: [ + 'https://twitter.com/jedmund', + 'https://github.com/jedmund', + 'https://www.linkedin.com/in/jedmund' + ] + }) + ) diff --git a/src/routes/admin/albums/[id]/edit/+page.svelte b/src/routes/admin/albums/[id]/edit/+page.svelte index 492b5ca..8c389bb 100644 --- a/src/routes/admin/albums/[id]/edit/+page.svelte +++ b/src/routes/admin/albums/[id]/edit/+page.svelte @@ -394,22 +394,22 @@ try { console.log('[Album Edit] handlePhotoReorder called:', { reorderedCount: reorderedPhotos.length, - photos: reorderedPhotos.map((p, i) => ({ + photos: reorderedPhotos.map((p, i) => ({ index: i, - id: p.id, - mediaId: p.mediaId, - filename: p.filename + id: p.id, + mediaId: p.mediaId, + filename: p.filename })) }) - + // Prevent concurrent reordering if (isManagingPhotos) { console.warn('[Album Edit] Skipping reorder - another operation in progress') return } - + isManagingPhotos = true - + const auth = localStorage.getItem('admin_auth') if (!auth) { goto('/admin/login') @@ -435,7 +435,7 @@ // Update local state only after successful API calls albumPhotos = [...reorderedPhotos] - + console.log('[Album Edit] Reorder completed successfully') } catch (err) { error = err instanceof Error ? err.message : 'Failed to reorder photos' @@ -495,15 +495,15 @@ try { console.log('[Album Edit] handleGalleryAdd called:', { newPhotosCount: newPhotos.length, - newPhotos: newPhotos.map(p => ({ - id: p.id, - mediaId: p.mediaId, + newPhotos: newPhotos.map((p) => ({ + id: p.id, + mediaId: p.mediaId, filename: p.filename, - isFile: p instanceof File + isFile: p instanceof File })), currentPhotosCount: albumPhotos.length }) - + if (newPhotos.length > 0) { // All items from GalleryUploader should be media objects, not Files // They either come from uploads (already processed to Media) or library selections @@ -696,7 +696,9 @@ onRemove={handleGalleryRemove} showBrowseLibrary={true} placeholder="Add photos to this album by uploading or selecting from your media library" - helpText={isManagingPhotos ? "Processing photos..." : "Drag photos to reorder them. Click on photos to edit metadata."} + helpText={isManagingPhotos + ? 'Processing photos...' + : 'Drag photos to reorder them. Click on photos to edit metadata.'} disabled={isManagingPhotos} />
@@ -818,7 +820,6 @@ } } - .loading-container { display: flex; justify-content: center; diff --git a/src/routes/admin/media/+page.svelte b/src/routes/admin/media/+page.svelte index 95e3518..ae00783 100644 --- a/src/routes/admin/media/+page.svelte +++ b/src/routes/admin/media/+page.svelte @@ -480,7 +480,9 @@ Alt {:else} - No Alt + + No Alt + {/if} {formatFileSize(item.size)} diff --git a/src/routes/admin/media/upload/+page.svelte b/src/routes/admin/media/upload/+page.svelte index 48848e4..0f6df9e 100644 --- a/src/routes/admin/media/upload/+page.svelte +++ b/src/routes/admin/media/upload/+page.svelte @@ -146,7 +146,6 @@
- {#if files.length > 0}
@@ -160,7 +159,9 @@ disabled={isUploading || files.length === 0} loading={isUploading} > - {isUploading ? 'Uploading...' : `Upload ${files.length} File${files.length !== 1 ? 's' : ''}`} + {isUploading + ? 'Uploading...' + : `Upload ${files.length} File${files.length !== 1 ? 's' : ''}`}
@@ -490,7 +489,6 @@ } } - .dropdown-menu { position: absolute; top: calc(100% + $unit); @@ -533,13 +531,13 @@ .main-content { display: flex; flex-direction: column; - gap: $unit-3x; + gap: $unit-2x; min-width: 0; } .title-input { width: 100%; - padding: 0 $unit-2x; + padding: 0 $unit-4x; border: none; font-size: 2.5rem; font-weight: 700; diff --git a/src/routes/admin/posts/new/+page.svelte b/src/routes/admin/posts/new/+page.svelte index e940a0b..7159677 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 Editor from '$lib/components/admin/Editor.svelte' + import CaseStudyEditor from '$lib/components/admin/CaseStudyEditor.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' @@ -195,7 +195,7 @@ {#if config?.showContent}
- +
{/if}
@@ -314,13 +314,13 @@ .main-content { display: flex; flex-direction: column; - gap: $unit-3x; + gap: $unit-2x; min-width: 0; } .title-input { width: 100%; - padding: 0 $unit-2x; + padding: 0 $unit-4x; border: none; font-size: 2.5rem; font-weight: 700; diff --git a/src/routes/api/media/bulk-upload/+server.ts b/src/routes/api/media/bulk-upload/+server.ts index c49d78b..f521e5b 100644 --- a/src/routes/api/media/bulk-upload/+server.ts +++ b/src/routes/api/media/bulk-upload/+server.ts @@ -12,17 +12,17 @@ async function extractExifData(file: File) { 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: [ @@ -43,7 +43,7 @@ async function extractExifData(file: File) { ] }) - logger.info(`EXIF parse result for ${file.name}:`, { + logger.info(`EXIF parse result for ${file.name}:`, { hasExif: !!exif, exifKeys: exif ? Object.keys(exif) : [] }) @@ -74,16 +74,16 @@ async function extractExifData(file: File) { if (exif.ExposureTime) { formattedExif.shutterSpeed = - exif.ExposureTime < 1 - ? `1/${Math.round(1 / exif.ExposureTime)}` - : `${exif.ExposureTime}s` + 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 + const iso = Array.isArray(exif.ISOSpeedRatings) + ? exif.ISOSpeedRatings[0] + : exif.ISOSpeedRatings formattedExif.iso = `ISO ${iso}` } @@ -105,7 +105,8 @@ async function extractExifData(file: File) { // Additional metadata if (exif.Orientation) { - formattedExif.orientation = exif.Orientation === 1 ? 'Horizontal (normal)' : `Rotated (${exif.Orientation})` + formattedExif.orientation = + exif.Orientation === 1 ? 'Horizontal (normal)' : `Rotated (${exif.Orientation})` } if (exif.ColorSpace) { diff --git a/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts b/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts index 8fde588..81a3c9f 100644 --- a/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts +++ b/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts @@ -60,7 +60,8 @@ export const GET: RequestHandler = async (event) => { // 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 + const nextMedia = + albumMediaIndex < album.media.length - 1 ? album.media[albumMediaIndex + 1].media : null // Transform to photo format for compatibility const photo = { diff --git a/src/routes/api/photos/[id]/+server.ts b/src/routes/api/photos/[id]/+server.ts index 41affc7..4667e5b 100644 --- a/src/routes/api/photos/[id]/+server.ts +++ b/src/routes/api/photos/[id]/+server.ts @@ -12,7 +12,7 @@ export const GET: RequestHandler = async (event) => { try { logger.info('Fetching photo', { mediaId: id }) - + const media = await prisma.media.findUnique({ where: { id }, include: { @@ -30,9 +30,9 @@ export const GET: RequestHandler = async (event) => { logger.warn('Media not found', { mediaId: id }) return errorResponse('Photo not found', 404) } - - logger.info('Media found', { - mediaId: id, + + logger.info('Media found', { + mediaId: id, isPhotography: media.isPhotography, albumCount: media.albums.length }) @@ -40,7 +40,7 @@ export const GET: RequestHandler = async (event) => { // For public access, only return media marked as photography const isAdminRequest = checkAdminAuth(event) logger.info('Authorization check', { isAdmin: isAdminRequest }) - + if (!isAdminRequest) { if (!media.isPhotography) { logger.warn('Media not marked as photography', { mediaId: id }) @@ -52,7 +52,7 @@ export const GET: RequestHandler = async (event) => { const fullAlbum = await prisma.album.findUnique({ where: { id: album.id } }) - logger.info('Album check', { + logger.info('Album check', { albumId: album.id, status: fullAlbum?.status, isPhotography: fullAlbum?.isPhotography @@ -169,9 +169,11 @@ export const PUT: RequestHandler = async (event) => { data: { photoCaption: body.caption !== undefined ? body.caption : existing.photoCaption, photoTitle: body.title !== undefined ? body.title : existing.photoTitle, - photoDescription: body.description !== undefined ? body.description : existing.photoDescription, + photoDescription: + body.description !== undefined ? body.description : existing.photoDescription, isPhotography: body.showInPhotos !== undefined ? body.showInPhotos : existing.isPhotography, - photoPublishedAt: body.publishedAt !== undefined ? body.publishedAt : existing.photoPublishedAt + photoPublishedAt: + body.publishedAt !== undefined ? body.publishedAt : existing.photoPublishedAt } }) diff --git a/src/routes/api/test-photos/+server.ts b/src/routes/api/test-photos/+server.ts index 8f0b620..8870ae4 100644 --- a/src/routes/api/test-photos/+server.ts +++ b/src/routes/api/test-photos/+server.ts @@ -172,47 +172,55 @@ export const GET: RequestHandler = async () => { totalPublishedPhotos: publishedPhotos.length, totalPhotosNoAlbum: allPhotosNoAlbum.length, totalPhotosInDatabase: allPhotos.length, - photosByStatus: photosByStatus.map(item => ({ + photosByStatus: photosByStatus.map((item) => ({ status: item.status, count: item._count.id })), - photosWithShowInPhotosFlag: allPhotos.filter(p => p.showInPhotos).length, - photosByFilename: allPhotos.filter(p => p.filename?.includes('B0000057')).map(p => ({ - filename: p.filename, - showInPhotos: p.showInPhotos, - status: p.status, - albumId: p.albumId, - albumTitle: p.album?.title - })) + photosWithShowInPhotosFlag: allPhotos.filter((p) => p.showInPhotos).length, + photosByFilename: allPhotos + .filter((p) => p.filename?.includes('B0000057')) + .map((p) => ({ + filename: p.filename, + showInPhotos: p.showInPhotos, + status: p.status, + albumId: p.albumId, + albumTitle: p.album?.title + })) }, albums: { totalAlbums: allAlbums.length, - photographyAlbums: allAlbums.filter(a => a.isPhotography).map(a => ({ - id: a.id, - title: a.title, - slug: a.slug, - isPhotography: a.isPhotography, - status: a.status, - photoCount: a._count.photos - })), - nonPhotographyAlbums: allAlbums.filter(a => !a.isPhotography).map(a => ({ - id: a.id, - title: a.title, - slug: a.slug, - isPhotography: a.isPhotography, - status: a.status, - photoCount: a._count.photos - })), - albumFive: albumFive ? { - id: albumFive.id, - title: albumFive.title, - slug: albumFive.slug, - isPhotography: albumFive.isPhotography, - status: albumFive.status, - publishedAt: albumFive.publishedAt, - photoCount: albumFive.photos.length, - photos: albumFive.photos - } : null, + photographyAlbums: allAlbums + .filter((a) => a.isPhotography) + .map((a) => ({ + id: a.id, + title: a.title, + slug: a.slug, + isPhotography: a.isPhotography, + status: a.status, + photoCount: a._count.photos + })), + nonPhotographyAlbums: allAlbums + .filter((a) => !a.isPhotography) + .map((a) => ({ + id: a.id, + title: a.title, + slug: a.slug, + isPhotography: a.isPhotography, + status: a.status, + photoCount: a._count.photos + })), + albumFive: albumFive + ? { + id: albumFive.id, + title: albumFive.title, + slug: albumFive.slug, + isPhotography: albumFive.isPhotography, + status: albumFive.status, + publishedAt: albumFive.publishedAt, + photoCount: albumFive.photos.length, + photos: albumFive.photos + } + : null, photosFromPhotographyAlbums: photosFromPhotographyAlbums.length, photosFromPhotographyAlbumsSample: photosFromPhotographyAlbums.slice(0, 5) }, @@ -227,7 +235,9 @@ export const GET: RequestHandler = async () => { debug: { expectedQuery: 'WHERE status = "published" AND showInPhotos = true AND albumId = null', actualPhotosEndpointQuery: '/api/photos uses this exact query', - albumsWithPhotographyFlagTrue: allAlbums.filter(a => a.isPhotography).map(a => `${a.id}: ${a.title}`) + albumsWithPhotographyFlagTrue: allAlbums + .filter((a) => a.isPhotography) + .map((a) => `${a.id}: ${a.title}`) } } @@ -236,6 +246,9 @@ export const GET: RequestHandler = async () => { return jsonResponse(response) } catch (error) { logger.error('Failed to run test photos query', error as Error) - return errorResponse(`Failed to run test query: ${error instanceof Error ? error.message : 'Unknown error'}`, 500) + return errorResponse( + `Failed to run test query: ${error instanceof Error ? error.message : 'Unknown error'}`, + 500 + ) } -} \ No newline at end of file +} diff --git a/src/routes/photos/[albumSlug]/[photoId]/+page.svelte b/src/routes/photos/[albumSlug]/[photoId]/+page.svelte index c5c37fa..ca87807 100644 --- a/src/routes/photos/[albumSlug]/[photoId]/+page.svelte +++ b/src/routes/photos/[albumSlug]/[photoId]/+page.svelte @@ -21,25 +21,30 @@ // Hover tracking for arrow buttons let isHoveringLeft = $state(false) let isHoveringRight = $state(false) - + // Spring stores for smooth button movement - const leftButtonCoords = spring({ x: 0, y: 0 }, { - stiffness: 0.3, - damping: 0.8 - }) - - const rightButtonCoords = spring({ x: 0, y: 0 }, { - stiffness: 0.3, - damping: 0.8 - }) - + const leftButtonCoords = spring( + { x: 0, y: 0 }, + { + stiffness: 0.3, + damping: 0.8 + } + ) + + const rightButtonCoords = spring( + { x: 0, y: 0 }, + { + stiffness: 0.3, + damping: 0.8 + } + ) + // Default button positions (will be set once photo loads) let defaultLeftX = 0 let defaultRightX = 0 - const pageUrl = $derived($page.url.href) - + // Parse EXIF data if available const exifData = $derived( photo?.exifData && typeof photo.exifData === 'object' ? photo.exifData : null @@ -88,26 +93,26 @@ // Set default button positions when component mounts $effect(() => { if (!photo) return - + // Wait for DOM to update and image to load const checkAndSetPositions = () => { const pageContainer = document.querySelector('.photo-page') as HTMLElement const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement - + if (photoImage && photoImage.complete) { const imageRect = photoImage.getBoundingClientRect() const pageRect = pageContainer.getBoundingClientRect() - + // Calculate default positions relative to the image // Add 24px (half button width) since we're using translate(-50%, -50%) - defaultLeftX = (imageRect.left - pageRect.left) - 24 - 16 // half button width + gap - defaultRightX = (imageRect.right - pageRect.left) + 24 + 16 // half button width + gap - + defaultLeftX = imageRect.left - pageRect.left - 24 - 16 // half button width + gap + defaultRightX = imageRect.right - pageRect.left + 24 + 16 // half button width + gap + // Set initial positions at the vertical center of the image - const centerY = (imageRect.top - pageRect.top) + (imageRect.height / 2) + const centerY = imageRect.top - pageRect.top + imageRect.height / 2 leftButtonCoords.set({ x: defaultLeftX, y: centerY }, { hard: true }) rightButtonCoords.set({ x: defaultRightX, y: centerY }, { hard: true }) - + // Check if mouse is already in a hover zone // Small delay to ensure mouse store is initialized setTimeout(() => { @@ -118,15 +123,19 @@ setTimeout(checkAndSetPositions, 50) } } - + checkAndSetPositions() }) - + // Check mouse position on load - function checkInitialMousePosition(pageContainer: HTMLElement, imageRect: DOMRect, pageRect: DOMRect) { + function checkInitialMousePosition( + pageContainer: HTMLElement, + imageRect: DOMRect, + pageRect: DOMRect + ) { // Get current mouse position from store const currentPos = getCurrentMousePosition() - + // If no mouse position tracked yet, try to trigger one if (currentPos.x === 0 && currentPos.y === 0) { // Set up a one-time listener for the first mouse move @@ -134,7 +143,7 @@ const x = e.clientX const mouseX = e.clientX - pageRect.left const mouseY = e.clientY - pageRect.top - + // Check if mouse is in hover zones if (x < imageRect.left) { isHoveringLeft = true @@ -143,24 +152,24 @@ isHoveringRight = true rightButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true }) } - + // Remove the listener window.removeEventListener('mousemove', handleFirstMove) } - + window.addEventListener('mousemove', handleFirstMove) return } - + // We have a mouse position, check if it's in a hover zone const x = currentPos.x const mouseX = currentPos.x - pageRect.left const mouseY = currentPos.y - pageRect.top - + // Store client coordinates for scroll updates lastClientX = currentPos.x lastClientY = currentPos.y - + // Check if mouse is in hover zones if (x < imageRect.left) { isHoveringLeft = true @@ -174,62 +183,62 @@ // Store last mouse client position for scroll updates let lastClientX = 0 let lastClientY = 0 - + // Update button positions during scroll function handleScroll() { if (!isHoveringLeft && !isHoveringRight) return - + const pageContainer = document.querySelector('.photo-page') as HTMLElement if (!pageContainer) return - + // Use last known mouse position (which is viewport-relative) // and recalculate relative to the page container's new position const pageRect = pageContainer.getBoundingClientRect() const mouseX = lastClientX - pageRect.left const mouseY = lastClientY - pageRect.top - + // Update button positions if (isHoveringLeft) { leftButtonCoords.set({ x: mouseX, y: mouseY }) } - + if (isHoveringRight) { rightButtonCoords.set({ x: mouseX, y: mouseY }) } } - + // Mouse tracking for hover areas function handleMouseMove(event: MouseEvent) { const pageContainer = event.currentTarget as HTMLElement const photoWrapper = pageContainer.querySelector('.photo-content-wrapper') as HTMLElement - + if (!photoWrapper) return - + // Get the actual image element inside PhotoView const photoImage = photoWrapper.querySelector('img') as HTMLElement if (!photoImage) return - + const pageRect = pageContainer.getBoundingClientRect() const photoRect = photoImage.getBoundingClientRect() - + const x = event.clientX const mouseX = event.clientX - pageRect.left const mouseY = event.clientY - pageRect.top - + // Store last mouse position for scroll updates lastClientX = event.clientX lastClientY = event.clientY - + // Check if mouse is in the left or right margin (outside the photo) const wasHoveringLeft = isHoveringLeft const wasHoveringRight = isHoveringRight - + isHoveringLeft = x < photoRect.left isHoveringRight = x > photoRect.right - + // Calculate image center Y position - const imageCenterY = (photoRect.top - pageRect.top) + (photoRect.height / 2) - + const imageCenterY = photoRect.top - pageRect.top + photoRect.height / 2 + // Update button positions if (isHoveringLeft) { leftButtonCoords.set({ x: mouseX, y: mouseY }) @@ -237,7 +246,7 @@ // Reset left button to default leftButtonCoords.set({ x: defaultLeftX, y: imageCenterY }) } - + if (isHoveringRight) { rightButtonCoords.set({ x: mouseX, y: mouseY }) } else if (wasHoveringRight && !isHoveringRight) { @@ -249,16 +258,16 @@ function handleMouseLeave() { isHoveringLeft = false isHoveringRight = false - + // Reset buttons to default positions const pageContainer = document.querySelector('.photo-page') as HTMLElement const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement - + if (photoImage && pageContainer) { const imageRect = photoImage.getBoundingClientRect() const pageRect = pageContainer.getBoundingClientRect() - const centerY = (imageRect.top - pageRect.top) + (imageRect.height / 2) - + const centerY = imageRect.top - pageRect.top + imageRect.height / 2 + leftButtonCoords.set({ x: defaultLeftX, y: centerY }) rightButtonCoords.set({ x: defaultRightX, y: centerY }) } @@ -277,7 +286,7 @@ $effect(() => { window.addEventListener('keydown', handleKeydown) window.addEventListener('scroll', handleScroll) - + return () => { window.removeEventListener('keydown', handleKeydown) window.removeEventListener('scroll', handleScroll) @@ -322,18 +331,9 @@ {:else} -
+
- +
@@ -480,15 +480,17 @@ display: flex; align-items: center; justify-content: center; - transition: background 0.2s ease, box-shadow 0.2s ease; + transition: + background 0.2s ease, + box-shadow 0.2s ease; &:hover { background: $grey-95; } - + &.hovering { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - + &:hover { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); } diff --git a/src/routes/photos/[albumSlug]/[photoId]/+page.ts b/src/routes/photos/[albumSlug]/[photoId]/+page.ts index 4e8a8de..f129ca1 100644 --- a/src/routes/photos/[albumSlug]/[photoId]/+page.ts +++ b/src/routes/photos/[albumSlug]/[photoId]/+page.ts @@ -4,14 +4,14 @@ 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') @@ -35,4 +35,4 @@ export const load: PageLoad = async ({ params, fetch }) => { error: error instanceof Error ? error.message : 'Failed to load photo' } } -} \ No newline at end of file +} diff --git a/src/routes/photos/[slug]/+page.ts b/src/routes/photos/[slug]/+page.ts index 4245f3d..d72ba79 100644 --- a/src/routes/photos/[slug]/+page.ts +++ b/src/routes/photos/[slug]/+page.ts @@ -6,7 +6,7 @@ export const load: PageLoad = async ({ params, fetch }) => { const albumResponse = await fetch(`/api/albums/by-slug/${params.slug}`) if (albumResponse.ok) { const album = await albumResponse.json() - + // Check if this is a photography album and published if (album.isPhotography && album.status === 'published') { return { diff --git a/src/routes/photos/p/[id]/+page.svelte b/src/routes/photos/p/[id]/+page.svelte index 488e55a..719bcbb 100644 --- a/src/routes/photos/p/[id]/+page.svelte +++ b/src/routes/photos/p/[id]/+page.svelte @@ -23,18 +23,24 @@ // Hover tracking for arrow buttons let isHoveringLeft = $state(false) let isHoveringRight = $state(false) - + // Spring stores for smooth button movement - const leftButtonCoords = spring({ x: 0, y: 0 }, { - stiffness: 0.3, - damping: 0.8 - }) - - const rightButtonCoords = spring({ x: 0, y: 0 }, { - stiffness: 0.3, - damping: 0.8 - }) - + const leftButtonCoords = spring( + { x: 0, y: 0 }, + { + stiffness: 0.3, + damping: 0.8 + } + ) + + const rightButtonCoords = spring( + { x: 0, y: 0 }, + { + stiffness: 0.3, + damping: 0.8 + } + ) + // Default button positions (will be set once photo loads) let defaultLeftX = 0 let defaultRightX = 0 @@ -119,26 +125,26 @@ // Set default button positions when component mounts $effect(() => { if (!photo) return - + // Wait for DOM to update and image to load const checkAndSetPositions = () => { const pageContainer = document.querySelector('.photo-page') as HTMLElement const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement - + if (photoImage && photoImage.complete) { const imageRect = photoImage.getBoundingClientRect() const pageRect = pageContainer.getBoundingClientRect() - + // Calculate default positions relative to the image // Add 24px (half button width) since we're using translate(-50%, -50%) - defaultLeftX = (imageRect.left - pageRect.left) - 24 - 16 // half button width + gap - defaultRightX = (imageRect.right - pageRect.left) + 24 + 16 // half button width + gap - + defaultLeftX = imageRect.left - pageRect.left - 24 - 16 // half button width + gap + defaultRightX = imageRect.right - pageRect.left + 24 + 16 // half button width + gap + // Set initial positions at the vertical center of the image - const centerY = (imageRect.top - pageRect.top) + (imageRect.height / 2) + const centerY = imageRect.top - pageRect.top + imageRect.height / 2 leftButtonCoords.set({ x: defaultLeftX, y: centerY }, { hard: true }) rightButtonCoords.set({ x: defaultRightX, y: centerY }, { hard: true }) - + // Check if mouse is already in a hover zone // Small delay to ensure mouse store is initialized setTimeout(() => { @@ -149,15 +155,19 @@ setTimeout(checkAndSetPositions, 50) } } - + checkAndSetPositions() }) - + // Check mouse position on load - function checkInitialMousePosition(pageContainer: HTMLElement, imageRect: DOMRect, pageRect: DOMRect) { + function checkInitialMousePosition( + pageContainer: HTMLElement, + imageRect: DOMRect, + pageRect: DOMRect + ) { // Get current mouse position from store const currentPos = getCurrentMousePosition() - + // If no mouse position tracked yet, try to trigger one if (currentPos.x === 0 && currentPos.y === 0) { // Set up a one-time listener for the first mouse move @@ -165,7 +175,7 @@ const x = e.clientX const mouseX = e.clientX - pageRect.left const mouseY = e.clientY - pageRect.top - + // Check if mouse is in hover zones if (x < imageRect.left) { isHoveringLeft = true @@ -174,24 +184,24 @@ isHoveringRight = true rightButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true }) } - + // Remove the listener window.removeEventListener('mousemove', handleFirstMove) } - + window.addEventListener('mousemove', handleFirstMove) return } - + // We have a mouse position, check if it's in a hover zone const x = currentPos.x const mouseX = currentPos.x - pageRect.left const mouseY = currentPos.y - pageRect.top - + // Store client coordinates for scroll updates lastClientX = currentPos.x lastClientY = currentPos.y - + // Check if mouse is in hover zones if (x < imageRect.left) { isHoveringLeft = true @@ -205,66 +215,66 @@ // Track last known mouse position for scroll updates let lastMouseX = 0 let lastMouseY = 0 - + // Store last mouse client position for scroll updates let lastClientX = 0 let lastClientY = 0 - + // Update button positions during scroll function handleScroll() { if (!isHoveringLeft && !isHoveringRight) return - + const pageContainer = document.querySelector('.photo-page') as HTMLElement if (!pageContainer) return - + // Use last known mouse position (which is viewport-relative) // and recalculate relative to the page container's new position const pageRect = pageContainer.getBoundingClientRect() const mouseX = lastClientX - pageRect.left const mouseY = lastClientY - pageRect.top - + // Update button positions if (isHoveringLeft) { leftButtonCoords.set({ x: mouseX, y: mouseY }) } - + if (isHoveringRight) { rightButtonCoords.set({ x: mouseX, y: mouseY }) } } - + // Mouse tracking for hover areas function handleMouseMove(event: MouseEvent) { const pageContainer = event.currentTarget as HTMLElement const photoWrapper = pageContainer.querySelector('.photo-content-wrapper') as HTMLElement - + if (!photoWrapper) return - + // Get the actual image element inside PhotoView const photoImage = photoWrapper.querySelector('img') as HTMLElement if (!photoImage) return - + const pageRect = pageContainer.getBoundingClientRect() const photoRect = photoImage.getBoundingClientRect() - + const x = event.clientX const mouseX = event.clientX - pageRect.left const mouseY = event.clientY - pageRect.top - + // Store last mouse position for scroll updates lastClientX = event.clientX lastClientY = event.clientY - + // Check if mouse is in the left or right margin (outside the photo) const wasHoveringLeft = isHoveringLeft const wasHoveringRight = isHoveringRight - + isHoveringLeft = x < photoRect.left isHoveringRight = x > photoRect.right - + // Calculate image center Y position - const imageCenterY = (photoRect.top - pageRect.top) + (photoRect.height / 2) - + const imageCenterY = photoRect.top - pageRect.top + photoRect.height / 2 + // Update button positions if (isHoveringLeft) { leftButtonCoords.set({ x: mouseX, y: mouseY }) @@ -272,7 +282,7 @@ // Reset left button to default leftButtonCoords.set({ x: defaultLeftX, y: imageCenterY }) } - + if (isHoveringRight) { rightButtonCoords.set({ x: mouseX, y: mouseY }) } else if (wasHoveringRight && !isHoveringRight) { @@ -284,16 +294,16 @@ function handleMouseLeave() { isHoveringLeft = false isHoveringRight = false - + // Reset buttons to default positions const pageContainer = document.querySelector('.photo-page') as HTMLElement const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement - + if (photoImage && pageContainer) { const imageRect = photoImage.getBoundingClientRect() const pageRect = pageContainer.getBoundingClientRect() - const centerY = (imageRect.top - pageRect.top) + (imageRect.height / 2) - + const centerY = imageRect.top - pageRect.top + imageRect.height / 2 + leftButtonCoords.set({ x: defaultLeftX, y: centerY }) rightButtonCoords.set({ x: defaultRightX, y: centerY }) } @@ -303,7 +313,7 @@ $effect(() => { window.addEventListener('keydown', handleKeydown) window.addEventListener('scroll', handleScroll) - + return () => { window.removeEventListener('keydown', handleKeydown) window.removeEventListener('scroll', handleScroll) @@ -343,18 +353,9 @@
{:else if photo} -
+
- +
@@ -468,7 +469,6 @@ justify-content: center; } - // Adjacent Navigation .adjacent-navigation { position: absolute; @@ -501,15 +501,17 @@ display: flex; align-items: center; justify-content: center; - transition: background 0.2s ease, box-shadow 0.2s ease; + transition: + background 0.2s ease, + box-shadow 0.2s ease; &:hover { background: $grey-95; } - + &.hovering { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - + &:hover { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); } diff --git a/src/routes/photos/p/[id]/+page.ts b/src/routes/photos/p/[id]/+page.ts index 6daa0ce..33f320a 100644 --- a/src/routes/photos/p/[id]/+page.ts +++ b/src/routes/photos/p/[id]/+page.ts @@ -39,4 +39,4 @@ export const load: PageLoad = async ({ params, fetch }) => { error: error instanceof Error ? error.message : 'Failed to load photo' } } -} \ No newline at end of file +} diff --git a/test-db.ts b/test-db.ts index f08af93..136d6c6 100644 --- a/test-db.ts +++ b/test-db.ts @@ -3,13 +3,13 @@ import { PrismaClient } from '@prisma/client' const prisma = new PrismaClient() async function testDb() { - try { - const count = await prisma.media.count() - console.log('Total media entries:', count) - await prisma.$disconnect() - } catch (error) { - console.error('Database error:', error) - } + try { + const count = await prisma.media.count() + console.log('Total media entries:', count) + await prisma.$disconnect() + } catch (error) { + console.error('Database error:', error) + } } -testDb() \ No newline at end of file +testDb()