Unify fullscreen editors

This commit is contained in:
Justin Edmund 2025-06-13 14:17:26 -04:00
parent f1ab953b89
commit f753d5fb8b
36 changed files with 705 additions and 504 deletions

View file

@ -16,11 +16,13 @@ This PRD outlines the implementation of automatic dominant color extraction for
### Color Extraction Library Options ### Color Extraction Library Options
1. **node-vibrant** (Recommended) 1. **node-vibrant** (Recommended)
- Pros: Lightweight, fast, good algorithm, actively maintained - Pros: Lightweight, fast, good algorithm, actively maintained
- Cons: Node.js only (server-side processing) - Cons: Node.js only (server-side processing)
- NPM: `node-vibrant` - NPM: `node-vibrant`
2. **color-thief-node** 2. **color-thief-node**
- Pros: Simple API, battle-tested algorithm - Pros: Simple API, battle-tested algorithm
- Cons: Less feature-rich than vibrant - Cons: Less feature-rich than vibrant
- NPM: `colorthief` - NPM: `colorthief`
@ -38,18 +40,19 @@ import Vibrant from 'node-vibrant'
// Extract colors from uploaded image // Extract colors from uploaded image
const palette = await Vibrant.from(buffer).getPalette() const palette = await Vibrant.from(buffer).getPalette()
const dominantColors = { const dominantColors = {
vibrant: palette.Vibrant?.hex, vibrant: palette.Vibrant?.hex,
darkVibrant: palette.DarkVibrant?.hex, darkVibrant: palette.DarkVibrant?.hex,
lightVibrant: palette.LightVibrant?.hex, lightVibrant: palette.LightVibrant?.hex,
muted: palette.Muted?.hex, muted: palette.Muted?.hex,
darkMuted: palette.DarkMuted?.hex, darkMuted: palette.DarkMuted?.hex,
lightMuted: palette.LightMuted?.hex lightMuted: palette.LightMuted?.hex
} }
``` ```
## Database Schema Changes ## Database Schema Changes
### Option 1: Add to Existing exifData JSON (Recommended) ### Option 1: Add to Existing exifData JSON (Recommended)
```prisma ```prisma
model Media { model Media {
// ... existing fields // ... existing fields
@ -58,6 +61,7 @@ model Media {
``` ```
### Option 2: Separate Colors Field ### Option 2: Separate Colors Field
```prisma ```prisma
model Media { model Media {
// ... existing fields // ... existing fields
@ -74,19 +78,19 @@ Update the upload handler to extract colors:
```typescript ```typescript
// After successful upload to Cloudinary // After successful upload to Cloudinary
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') { if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
const buffer = await file.arrayBuffer() const buffer = await file.arrayBuffer()
// Extract EXIF data (existing) // Extract EXIF data (existing)
const exifData = await extractExifData(file) const exifData = await extractExifData(file)
// Extract dominant colors (new) // Extract dominant colors (new)
const colorData = await extractDominantColors(buffer) const colorData = await extractDominantColors(buffer)
// Combine data // Combine data
const metadata = { const metadata = {
...exifData, ...exifData,
colors: colorData colors: colorData
} }
} }
``` ```
@ -94,36 +98,40 @@ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
```json ```json
{ {
"id": 123, "id": 123,
"url": "...", "url": "...",
"dominantColors": { "dominantColors": {
"vibrant": "#4285f4", "vibrant": "#4285f4",
"darkVibrant": "#1a73e8", "darkVibrant": "#1a73e8",
"lightVibrant": "#8ab4f8", "lightVibrant": "#8ab4f8",
"muted": "#5f6368", "muted": "#5f6368",
"darkMuted": "#3c4043", "darkMuted": "#3c4043",
"lightMuted": "#e8eaed" "lightMuted": "#e8eaed"
} }
} }
``` ```
## UI/UX Considerations ## UI/UX Considerations
### 1. Media Library Display ### 1. Media Library Display
- Show color swatches on hover/focus - Show color swatches on hover/focus
- Optional: Color-based filtering or sorting - Optional: Color-based filtering or sorting
### 2. Gallery Image Modal ### 2. Gallery Image Modal
- Display color palette in metadata section - Display color palette in metadata section
- Show hex values for each color - Show hex values for each color
- Copy-to-clipboard functionality for colors - Copy-to-clipboard functionality for colors
### 3. Album/Gallery Views ### 3. Album/Gallery Views
- Use dominant color for background accents - Use dominant color for background accents
- Create dynamic gradients from extracted colors - Create dynamic gradients from extracted colors
- Enhance loading states with color placeholders - Enhance loading states with color placeholders
### 4. Potential Future Features ### 4. Potential Future Features
- Color-based search ("find blue images") - Color-based search ("find blue images")
- Automatic theme generation for albums - Automatic theme generation for albums
- Color harmony analysis for galleries - Color harmony analysis for galleries
@ -131,6 +139,7 @@ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
## Implementation Plan ## Implementation Plan
### Phase 1: Backend Implementation (1 day) ### Phase 1: Backend Implementation (1 day)
1. Install and configure node-vibrant 1. Install and configure node-vibrant
2. Create color extraction utility function 2. Create color extraction utility function
3. Integrate into upload pipeline 3. Integrate into upload pipeline
@ -138,16 +147,19 @@ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
5. Update API responses 5. Update API responses
### Phase 2: Basic Frontend Display (0.5 day) ### Phase 2: Basic Frontend Display (0.5 day)
1. Update Media type definitions 1. Update Media type definitions
2. Display colors in GalleryImageModal 2. Display colors in GalleryImageModal
3. Add color swatches to media details 3. Add color swatches to media details
### Phase 3: Enhanced UI Features (1 day) ### Phase 3: Enhanced UI Features (1 day)
1. Implement color-based backgrounds 1. Implement color-based backgrounds
2. Add loading placeholders with colors 2. Add loading placeholders with colors
3. Create color palette component 3. Create color palette component
### Phase 4: Testing & Optimization (0.5 day) ### Phase 4: Testing & Optimization (0.5 day)
1. Test with various image types 1. Test with various image types
2. Optimize for performance 2. Optimize for performance
3. Handle edge cases (B&W images, etc.) 3. Handle edge cases (B&W images, etc.)

View file

@ -7,12 +7,14 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
## Problem Statement ## Problem Statement
### Current State ### Current State
- Most pages use a static default OG image - Most pages use a static default OG image
- Dynamic content (projects, essays, photos) doesn't have representative imagery when shared - Dynamic content (projects, essays, photos) doesn't have representative imagery when shared
- No visual differentiation between content types in social previews - No visual differentiation between content types in social previews
- Missed opportunity for branding and engagement - Missed opportunity for branding and engagement
### Impact ### Impact
- Poor social media engagement rates - Poor social media engagement rates
- Generic appearance when content is shared - Generic appearance when content is shared
- Lost opportunity to showcase project visuals and branding - Lost opportunity to showcase project visuals and branding
@ -31,6 +33,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
### Content Type Requirements ### Content Type Requirements
#### 1. Work Projects #### 1. Work Projects
- **Format**: [Avatar] + [Logo] (with "+" symbol) centered on brand background color - **Format**: [Avatar] + [Logo] (with "+" symbol) centered on brand background color
- **Data needed**: - **Data needed**:
- Project logo URL (`logoUrl`) - Project logo URL (`logoUrl`)
@ -41,6 +44,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
- **Font**: cstd Regular for any text - **Font**: cstd Regular for any text
#### 2. Essays (Universe) #### 2. Essays (Universe)
- **Format**: Universe icon + "Universe" label above essay title - **Format**: Universe icon + "Universe" label above essay title
- **Layout**: Left-aligned, vertically centered content block - **Layout**: Left-aligned, vertically centered content block
- **Styling**: - **Styling**:
@ -54,6 +58,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
- **Font**: cstd Regular for all text - **Font**: cstd Regular for all text
#### 3. Labs Projects #### 3. Labs Projects
- **Format**: Labs icon + "Labs" label above project title - **Format**: Labs icon + "Labs" label above project title
- **Layout**: Same as Essays - left-aligned, vertically centered - **Layout**: Same as Essays - left-aligned, vertically centered
- **Styling**: - **Styling**:
@ -67,6 +72,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
- **Font**: cstd Regular for all text - **Font**: cstd Regular for all text
#### 4. Photos #### 4. Photos
- **Format**: The photo itself, fitted within frame - **Format**: The photo itself, fitted within frame
- **Styling**: - **Styling**:
- Photo scaled to fit within 1200x630 bounds - Photo scaled to fit within 1200x630 bounds
@ -74,6 +80,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
- **Data needed**: Photo URL - **Data needed**: Photo URL
#### 5. Albums #### 5. Albums
- **Format**: First photo (blurred) as background + Photos format overlay - **Format**: First photo (blurred) as background + Photos format overlay
- **Layout**: Same as Essays/Labs - left-aligned, vertically centered - **Layout**: Same as Essays/Labs - left-aligned, vertically centered
- **Styling**: - **Styling**:
@ -86,6 +93,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
- **Font**: cstd Regular for all text - **Font**: cstd Regular for all text
#### 6. Root Pages (Homepage, Universe, Photos, Labs, About) #### 6. Root Pages (Homepage, Universe, Photos, Labs, About)
- **No change**: Continue using existing static OG image - **No change**: Continue using existing static OG image
### Technical Requirements ### Technical Requirements
@ -141,6 +149,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
### Implementation Details ### Implementation Details
#### 1. API Endpoint Structure #### 1. API Endpoint Structure
```typescript ```typescript
/api/og-image?type=work&title=Project&logo=url&bg=color /api/og-image?type=work&title=Project&logo=url&bg=color
/api/og-image?type=essay&title=Essay+Title /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 #### 2. Hybrid Template System
- SVG templates for text-based layouts (work, essays, labs, photos) - SVG templates for text-based layouts (work, essays, labs, photos)
- Canvas/Sharp for blur effects (albums) - Canvas/Sharp for blur effects (albums)
- Use template literals for dynamic content injection - 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 - All text rendered in cstd Regular font
#### 3. Asset Management #### 3. Asset Management
- Avatar: Use existing SVG at src/assets/illos/jedmund.svg, convert to base64 - Avatar: Use existing SVG at src/assets/illos/jedmund.svg, convert to base64
- Icons: Convert Universe, Labs, Photos icons to base64 - Icons: Convert Universe, Labs, Photos icons to base64
- Fonts: Embed cstd Regular font for consistent rendering - 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 ##### Multi-Level Caching Architecture
**Level 1: Cloudinary CDN (Permanent Storage)** **Level 1: Cloudinary CDN (Permanent Storage)**
- Upload generated images to `jedmund/og-images/` folder - Upload generated images to `jedmund/og-images/` folder
- Use content-based public IDs: `og-{type}-{contentHash}` - Use content-based public IDs: `og-{type}-{contentHash}`
- Leverage Cloudinary's global CDN for distribution - Leverage Cloudinary's global CDN for distribution
- Automatic format optimization and responsive delivery - Automatic format optimization and responsive delivery
**Level 2: Redis Cache (Fast Lookups)** **Level 2: Redis Cache (Fast Lookups)**
- Cache mapping: content ID → Cloudinary public ID - Cache mapping: content ID → Cloudinary public ID
- TTL: 24 hours for quick access - TTL: 24 hours for quick access
- Key structure: `og:{type}:{id}:{version}``cloudinary_public_id` - Key structure: `og:{type}:{id}:{version}``cloudinary_public_id`
**Level 3: Browser Cache (Client-side)** **Level 3: Browser Cache (Client-side)**
- Set long cache headers on Cloudinary URLs - Set long cache headers on Cloudinary URLs
- Immutable URLs with content-based versioning - Immutable URLs with content-based versioning
@ -185,18 +199,18 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
```typescript ```typescript
function generateOgImageId(type: string, data: any): string { function generateOgImageId(type: string, data: any): string {
const content = { const content = {
type, type,
// Include only content that affects the image // Include only content that affects the image
...(type === 'work' && { title: data.title, logo: data.logoUrl, bg: data.backgroundColor }), ...(type === 'work' && { title: data.title, logo: data.logoUrl, bg: data.backgroundColor }),
...(type === 'essay' && { title: data.title }), ...(type === 'essay' && { title: data.title }),
...(type === 'labs' && { title: data.title }), ...(type === 'labs' && { title: data.title }),
...(type === 'photo' && { url: data.url }), ...(type === 'photo' && { url: data.url }),
...(type === 'album' && { title: data.title, firstPhoto: data.photos[0].src }) ...(type === 'album' && { title: data.title, firstPhoto: data.photos[0].src })
} }
const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex').slice(0, 8) const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex').slice(0, 8)
return `og-${type}-${hash}` return `og-${type}-${hash}`
} }
``` ```
@ -242,6 +256,7 @@ src/
## Implementation Plan ## Implementation Plan
### Phase 1: Foundation (Day 1) ### Phase 1: Foundation (Day 1)
- [ ] Install dependencies (sharp for image processing) - [ ] Install dependencies (sharp for image processing)
- [ ] Create API endpoint structure - [ ] Create API endpoint structure
- [ ] Set up Cloudinary integration for og-images folder - [ ] Set up Cloudinary integration for og-images folder
@ -249,6 +264,7 @@ src/
- [ ] Implement basic SVG to PNG conversion - [ ] Implement basic SVG to PNG conversion
### Phase 2: Asset Preparation (Day 2) ### Phase 2: Asset Preparation (Day 2)
- [ ] Load Avatar SVG from src/assets/illos/jedmund.svg - [ ] Load Avatar SVG from src/assets/illos/jedmund.svg
- [ ] Convert Avatar SVG to base64 for embedding - [ ] Convert Avatar SVG to base64 for embedding
- [ ] Convert Universe, Labs, Photos icons to base64 - [ ] Convert Universe, Labs, Photos icons to base64
@ -257,6 +273,7 @@ src/
- [ ] Test asset embedding in SVGs - [ ] Test asset embedding in SVGs
### Phase 3: Template Development (Days 3-4) ### Phase 3: Template Development (Days 3-4)
- [ ] Create Work project template - [ ] Create Work project template
- [ ] Create Essay/Universe template - [ ] Create Essay/Universe template
- [ ] Create Labs template (reuse Essay structure) - [ ] Create Labs template (reuse Essay structure)
@ -264,6 +281,7 @@ src/
- [ ] Create Album template - [ ] Create Album template
### Phase 4: Integration (Day 5) ### Phase 4: Integration (Day 5)
- [ ] Update metadata utils to generate OG image URLs - [ ] Update metadata utils to generate OG image URLs
- [ ] Implement Cloudinary upload pipeline - [ ] Implement Cloudinary upload pipeline
- [ ] Set up Redis caching for Cloudinary URLs - [ ] Set up Redis caching for Cloudinary URLs
@ -272,6 +290,7 @@ src/
- [ ] Test all content types - [ ] Test all content types
### Phase 5: Optimization (Day 6) ### Phase 5: Optimization (Day 6)
- [ ] Performance testing - [ ] Performance testing
- [ ] Add rate limiting - [ ] Add rate limiting
- [ ] Optimize SVG generation - [ ] Optimize SVG generation
@ -280,38 +299,48 @@ src/
## Potential Pitfalls & Mitigations ## Potential Pitfalls & Mitigations
### 1. Performance Issues ### 1. Performance Issues
**Risk**: SVG to PNG conversion could be slow, especially with blur effects **Risk**: SVG to PNG conversion could be slow, especially with blur effects
**Mitigation**: **Mitigation**:
- Pre-generate common images - Pre-generate common images
- Use efficient SVG structures for text-based layouts - Use efficient SVG structures for text-based layouts
- Use Sharp's built-in blur capabilities for album backgrounds - Use Sharp's built-in blur capabilities for album backgrounds
- Implement request coalescing - Implement request coalescing
### 2. Memory Usage ### 2. Memory Usage
**Risk**: Image processing could consume significant memory **Risk**: Image processing could consume significant memory
**Mitigation**: **Mitigation**:
- Stream processing where possible - Stream processing where possible
- Implement memory limits - Implement memory limits
- Use worker threads if needed - Use worker threads if needed
### 3. Font Rendering ### 3. Font Rendering
**Risk**: cstd Regular font may not render consistently **Risk**: cstd Regular font may not render consistently
**Mitigation**: **Mitigation**:
- Embed cstd Regular font as base64 in SVG - Embed cstd Regular font as base64 in SVG
- Use font subsetting to reduce size - Use font subsetting to reduce size
- Test rendering across different platforms - Test rendering across different platforms
- Fallback to similar web-safe fonts if needed - Fallback to similar web-safe fonts if needed
### 4. Asset Loading ### 4. Asset Loading
**Risk**: External assets could fail to load **Risk**: External assets could fail to load
**Mitigation**: **Mitigation**:
- Embed all assets as base64 - Embed all assets as base64
- No external dependencies - No external dependencies
- Graceful fallbacks - Graceful fallbacks
### 5. Cache Invalidation ### 5. Cache Invalidation
**Risk**: Updated content shows old OG images **Risk**: Updated content shows old OG images
**Mitigation**: **Mitigation**:
- Include version/timestamp in URL params - Include version/timestamp in URL params
- Use content-based cache keys - Use content-based cache keys
- Provide manual cache purge option - Provide manual cache purge option
@ -337,16 +366,19 @@ src/
### Admin UI for OG Image Management ### Admin UI for OG Image Management
1. **OG Image Viewer** 1. **OG Image Viewer**
- Display current OG image for each content type - Display current OG image for each content type
- Show Cloudinary URL and metadata - Show Cloudinary URL and metadata
- Preview how it appears on social platforms - Preview how it appears on social platforms
2. **Manual Regeneration** 2. **Manual Regeneration**
- "Regenerate OG Image" button per content item - "Regenerate OG Image" button per content item
- Preview new image before confirming - Preview new image before confirming
- Bulk regeneration tools for content types - Bulk regeneration tools for content types
3. **Analytics Dashboard** 3. **Analytics Dashboard**
- Track generation frequency - Track generation frequency
- Monitor cache hit rates - Monitor cache hit rates
- Show most viewed OG images - Show most viewed OG images
@ -359,6 +391,7 @@ src/
## Task Checklist ## Task Checklist
### High Priority ### High Priority
- [ ] Set up API endpoint with proper routing - [ ] Set up API endpoint with proper routing
- [ ] Install sharp and @resvg/resvg-js for image processing - [ ] Install sharp and @resvg/resvg-js for image processing
- [ ] Configure Cloudinary og-images folder - [ ] Configure Cloudinary og-images folder
@ -377,6 +410,7 @@ src/
- [ ] Test end-to-end caching flow - [ ] Test end-to-end caching flow
### Medium Priority ### Medium Priority
- [ ] Add comprehensive error handling - [ ] Add comprehensive error handling
- [ ] Implement rate limiting - [ ] Implement rate limiting
- [ ] Add request logging - [ ] Add request logging
@ -384,12 +418,14 @@ src/
- [ ] Performance optimization - [ ] Performance optimization
### Low Priority ### Low Priority
- [ ] Add monitoring dashboard - [ ] Add monitoring dashboard
- [ ] Create manual regeneration endpoint - [ ] Create manual regeneration endpoint
- [ ] Add A/B testing capability - [ ] Add A/B testing capability
- [ ] Documentation - [ ] Documentation
### Stretch Goals ### Stretch Goals
- [ ] Admin UI: OG image viewer - [ ] Admin UI: OG image viewer
- [ ] Admin UI: Manual regeneration button - [ ] Admin UI: Manual regeneration button
- [ ] Admin UI: Bulk regeneration tools - [ ] Admin UI: Bulk regeneration tools
@ -400,6 +436,7 @@ src/
## Dependencies ## Dependencies
### Required Packages ### Required Packages
- `sharp`: For SVG to PNG conversion and blur effects - `sharp`: For SVG to PNG conversion and blur effects
- `@resvg/resvg-js`: Alternative high-quality SVG to PNG converter - `@resvg/resvg-js`: Alternative high-quality SVG to PNG converter
- `cloudinary`: Already installed, for image storage and CDN - `cloudinary`: Already installed, for image storage and CDN
@ -407,6 +444,7 @@ src/
- Built-in Node.js modules for base64 encoding - Built-in Node.js modules for base64 encoding
### External Assets Needed ### External Assets Needed
- Avatar SVG (existing at src/assets/illos/jedmund.svg) - Avatar SVG (existing at src/assets/illos/jedmund.svg)
- Universe icon SVG - Universe icon SVG
- Labs icon SVG - Labs icon SVG
@ -414,11 +452,13 @@ src/
- cstd Regular font file - cstd Regular font file
### API Requirements ### API Requirements
- Access to project data (logo, colors) - Access to project data (logo, colors)
- Access to photo URLs - Access to photo URLs
- Access to content titles and descriptions - Access to content titles and descriptions
### Infrastructure Requirements ### Infrastructure Requirements
- Cloudinary account with og-images folder configured - Cloudinary account with og-images folder configured
- Redis instance for caching (already available) - Redis instance for caching (already available)
- Railway deployment (no local disk storage) - Railway deployment (no local disk storage)

View file

@ -22,7 +22,7 @@ async function checkPhotosDisplay() {
}) })
console.log(`Found ${photographyAlbums.length} published photography albums:`) 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`) 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`) 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}`) 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`) console.log(
const albumGroups = photosInAlbums.reduce((acc, photo) => { `\nFound ${photosInAlbums.length} published photos in albums with showInPhotos=true`
const albumTitle = photo.album?.title || 'Unknown' )
acc[albumTitle] = (acc[albumTitle] || 0) + 1 const albumGroups = photosInAlbums.reduce(
return acc (acc, photo) => {
}, {} as Record<string, number>) const albumTitle = photo.album?.title || 'Unknown'
acc[albumTitle] = (acc[albumTitle] || 0) + 1
return acc
},
{} as Record<string, number>
)
Object.entries(albumGroups).forEach(([album, count]) => { Object.entries(albumGroups).forEach(([album, count]) => {
console.log(`- Album "${album}": ${count} photos`) console.log(`- Album "${album}": ${count} photos`)
@ -80,10 +85,13 @@ async function checkPhotosDisplay() {
}) })
console.log(`\nTotal photos in database: ${allPhotos.length}`) console.log(`\nTotal photos in database: ${allPhotos.length}`)
const statusCounts = allPhotos.reduce((acc, photo) => { const statusCounts = allPhotos.reduce(
acc[photo.status] = (acc[photo.status] || 0) + 1 (acc, photo) => {
return acc acc[photo.status] = (acc[photo.status] || 0) + 1
}, {} as Record<string, number>) return acc
},
{} as Record<string, number>
)
Object.entries(statusCounts).forEach(([status, count]) => { Object.entries(statusCounts).forEach(([status, count]) => {
console.log(`- Status "${status}": ${count} photos`) console.log(`- Status "${status}": ${count} photos`)
@ -99,10 +107,11 @@ async function checkPhotosDisplay() {
}) })
console.log(`\nTotal albums in database: ${allAlbums.length}`) console.log(`\nTotal albums in database: ${allAlbums.length}`)
allAlbums.forEach(album => { allAlbums.forEach((album) => {
console.log(`- "${album.title}" (${album.slug}): status=${album.status}, isPhotography=${album.isPhotography}, photos=${album._count.photos}`) console.log(
`- "${album.title}" (${album.slug}): status=${album.status}, isPhotography=${album.isPhotography}, photos=${album._count.photos}`
)
}) })
} catch (error) { } catch (error) {
console.error('Error checking photos:', error) console.error('Error checking photos:', error)
} finally { } finally {

View file

@ -5,11 +5,13 @@ This directory contains tools to debug why photos aren't appearing on the photos
## API Test Endpoint ## API Test Endpoint
Visit the following URL in your browser while the dev server is running: Visit the following URL in your browser while the dev server is running:
``` ```
http://localhost:5173/api/test-photos http://localhost:5173/api/test-photos
``` ```
This endpoint will return detailed information about: This endpoint will return detailed information about:
- All photos with showInPhotos=true and albumId=null - All photos with showInPhotos=true and albumId=null
- Status distribution of these photos - Status distribution of these photos
- Raw SQL query results - Raw SQL query results
@ -18,11 +20,13 @@ This endpoint will return detailed information about:
## Database Query Script ## Database Query Script
Run the following command to query the database directly: Run the following command to query the database directly:
```bash ```bash
npx tsx scripts/test-photos-query.ts npx tsx scripts/test-photos-query.ts
``` ```
This script will show: This script will show:
- Total photos in the database - Total photos in the database
- Photos matching the criteria (showInPhotos=true, albumId=null) - Photos matching the criteria (showInPhotos=true, albumId=null)
- Status distribution - Status distribution

View file

@ -69,10 +69,14 @@ async function main() {
// Create AlbumMedia record if photo belongs to an album // Create AlbumMedia record if photo belongs to an album
if (photo.albumId) { if (photo.albumId) {
const mediaId = photo.mediaId || (await prisma.photo.findUnique({ const mediaId =
where: { id: photo.id }, photo.mediaId ||
select: { mediaId: true } (
}))?.mediaId await prisma.photo.findUnique({
where: { id: photo.id },
select: { mediaId: true }
})
)?.mediaId
if (mediaId) { if (mediaId) {
// Check if AlbumMedia already exists // Check if AlbumMedia already exists
@ -121,7 +125,6 @@ async function main() {
console.log(`\nVerification:`) console.log(`\nVerification:`)
console.log(`- Media records with photo data: ${mediaWithPhotoData}`) console.log(`- Media records with photo data: ${mediaWithPhotoData}`)
console.log(`- Album-media relationships: ${albumMediaRelations}`) console.log(`- Album-media relationships: ${albumMediaRelations}`)
} catch (error) { } catch (error) {
console.error('Migration failed:', error) console.error('Migration failed:', error)
process.exit(1) process.exit(1)

View file

@ -186,7 +186,6 @@ async function testMediaSharing() {
where: { id: media.id } where: { id: media.id }
}) })
console.log('✓ Test data cleaned up') console.log('✓ Test data cleaned up')
} catch (error) { } catch (error) {
console.error('\n❌ ERROR:', error) console.error('\n❌ ERROR:', error)
process.exit(1) process.exit(1)

View file

@ -30,8 +30,10 @@ async function testPhotoQueries() {
}) })
console.log(`\nPhotos with showInPhotos=true and albumId=null: ${photosForDisplay.length}`) console.log(`\nPhotos with showInPhotos=true and albumId=null: ${photosForDisplay.length}`)
photosForDisplay.forEach(photo => { photosForDisplay.forEach((photo) => {
console.log(` - ID: ${photo.id}, Status: ${photo.status}, Slug: ${photo.slug || 'none'}, File: ${photo.filename}`) console.log(
` - ID: ${photo.id}, Status: ${photo.status}, Slug: ${photo.slug || 'none'}, File: ${photo.filename}`
)
}) })
// Query 3: Check status distribution // Query 3: Check status distribution
@ -60,8 +62,10 @@ async function testPhotoQueries() {
} }
}) })
console.log(`\nPublished photos (status='published', showInPhotos=true, albumId=null): ${publishedPhotos.length}`) console.log(
publishedPhotos.forEach(photo => { `\nPublished photos (status='published', showInPhotos=true, albumId=null): ${publishedPhotos.length}`
)
publishedPhotos.forEach((photo) => {
console.log(` - ID: ${photo.id}, File: ${photo.filename}, Published: ${photo.publishedAt}`) console.log(` - ID: ${photo.id}, File: ${photo.filename}, Published: ${photo.publishedAt}`)
}) })
@ -76,7 +80,7 @@ async function testPhotoQueries() {
if (draftPhotos.length > 0) { if (draftPhotos.length > 0) {
console.log(`\n⚠ Found ${draftPhotos.length} draft photos with showInPhotos=true:`) 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(` - ID: ${photo.id}, File: ${photo.filename}`)
}) })
console.log('These photos need to be published to appear in the photos page!') console.log('These photos need to be published to appear in the photos page!')
@ -94,7 +98,6 @@ async function testPhotoQueries() {
uniqueStatuses.forEach(({ status }) => { uniqueStatuses.forEach(({ status }) => {
console.log(` - "${status}"`) console.log(` - "${status}"`)
}) })
} catch (error) { } catch (error) {
console.error('Error running queries:', error) console.error('Error running queries:', error)
} finally { } finally {

View file

@ -167,7 +167,6 @@
} }
} }
function handleCancel() { function handleCancel() {
if (hasChanges() && !confirm('Are you sure you want to cancel? Your changes will be lost.')) { if (hasChanges() && !confirm('Are you sure you want to cancel? Your changes will be lost.')) {
return return

View file

@ -0,0 +1,162 @@
<script lang="ts">
import Editor from './Editor.svelte'
import type { JSONContent } from '@tiptap/core'
interface Props {
data?: JSONContent
onChange?: (content: JSONContent) => void
placeholder?: string
minHeight?: number
autofocus?: boolean
mode?: 'default' | 'inline'
showToolbar?: boolean
class?: string
}
let {
data = $bindable(),
onChange = () => {},
placeholder = 'Write your content here...',
minHeight = 400,
autofocus = false,
mode = 'default',
showToolbar = true,
class: className = ''
}: Props = $props()
let editorRef: Editor | undefined = $state()
// Forward editor methods if needed
export function focus() {
editorRef?.focus()
}
export function blur() {
editorRef?.blur()
}
export function getContent() {
return editorRef?.getContent()
}
</script>
<div class={`case-study-editor-wrapper ${mode} ${className}`}>
<Editor
bind:this={editorRef}
bind:data
{onChange}
{placeholder}
{minHeight}
{autofocus}
{showToolbar}
class="case-study-editor"
/>
</div>
<style lang="scss">
@import '$styles/variables.scss';
.case-study-editor-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
width: 100%;
}
/* Default mode - used in ProjectForm */
.case-study-editor-wrapper.default {
:global(.case-study-editor) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.case-study-editor .edra) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.case-study-editor .editor-toolbar) {
border-radius: 999px;
border: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: 0 0 16px rgba(0, 0, 0, 0.12);
background: $grey-95;
}
:global(.case-study-editor .edra-editor) {
padding: 0 $unit-4x;
overflow-y: auto;
box-sizing: border-box;
}
:global(.case-study-editor .ProseMirror) {
min-height: calc(100% - 80px);
}
:global(.case-study-editor .ProseMirror:focus) {
outline: none;
}
:global(.case-study-editor .ProseMirror > * + *) {
margin-top: 0.75em;
}
:global(.case-study-editor .ProseMirror p.is-editor-empty:first-child::before) {
color: #adb5bd;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
}
/* Inline mode - used in UniverseComposer */
.case-study-editor-wrapper.inline {
:global(.case-study-editor) {
border: none !important;
box-shadow: none !important;
}
:global(.case-study-editor .edra-editor) {
padding: $unit-2x 0;
}
:global(.case-study-editor .editor-container) {
padding: 0 $unit-3x;
}
:global(.case-study-editor .editor-content) {
padding: 0;
min-height: 80px;
font-size: 15px;
line-height: 1.5;
}
:global(.case-study-editor .ProseMirror) {
padding: 0;
min-height: 80px;
}
:global(.case-study-editor .ProseMirror:focus) {
outline: none;
}
:global(.case-study-editor .ProseMirror p) {
margin: 0;
}
:global(
.case-study-editor .ProseMirror.ProseMirror-focused .is-editor-empty:first-child::before
) {
color: $grey-40;
content: attr(data-placeholder);
float: left;
pointer-events: none;
height: 0;
}
}
</style>

View file

@ -131,10 +131,12 @@
} }
:global(.editor-content .editor-toolbar) { :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; box-sizing: border-box;
background: $grey-95; background: $grey-95;
padding: $unit-2x; padding: $unit $unit-2x;
position: sticky; position: sticky;
z-index: 10; z-index: 10;
overflow-x: auto; overflow-x: auto;

View file

@ -251,7 +251,11 @@
const editorInstance = (view as any).editor const editorInstance = (view as any).editor
if (editorInstance) { if (editorInstance) {
// Use pasteHTML to let Tiptap process the HTML and apply configured extensions // 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 { } else {
// Fallback to plain text if editor instance not available // Fallback to plain text if editor instance not available
const { state, dispatch } = view const { state, dispatch } = view
@ -506,6 +510,7 @@
} }
}} }}
class="edra-editor" class="edra-editor"
class:with-toolbar={showToolbar}
></div> ></div>
</div> </div>
@ -724,7 +729,7 @@
</div> </div>
{/if} {/if}
<style> <style lang="scss">
.edra { .edra {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@ -736,9 +741,10 @@
.editor-toolbar { .editor-toolbar {
background: var(--edra-button-bg-color); background: var(--edra-button-bg-color);
box-sizing: border-box; box-sizing: border-box;
padding: 0.5rem; padding: $unit ($unit-2x + $unit);
position: sticky; position: sticky;
top: 68px; box-sizing: border-box;
top: 75px;
z-index: 10; z-index: 10;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
@ -758,6 +764,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
// .edra-editor.with-toolbar {
// padding-top: 52px; /* Account for sticky toolbar height */
// }
:global(.ProseMirror) { :global(.ProseMirror) {
width: 100%; width: 100%;
min-height: 100%; min-height: 100%;

View file

@ -157,8 +157,8 @@
setTimeout(() => { setTimeout(() => {
console.log('[GalleryUploader] Upload completed:', { console.log('[GalleryUploader] Upload completed:', {
uploadedCount: uploadedMedia.length, uploadedCount: uploadedMedia.length,
uploaded: uploadedMedia.map(m => ({ id: m.id, filename: m.filename })), uploaded: uploadedMedia.map((m) => ({ id: m.id, filename: m.filename })),
currentValue: value?.map(v => ({ id: v.id, mediaId: v.mediaId, filename: v.filename })) currentValue: value?.map((v) => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
}) })
// Don't update value here - let parent handle it through API response // Don't update value here - let parent handle it through API response
@ -224,7 +224,6 @@
uploadError = null uploadError = null
} }
// Drag and drop reordering handlers // Drag and drop reordering handlers
function handleImageDragStart(event: DragEvent, index: number) { function handleImageDragStart(event: DragEvent, index: number) {
// Prevent reordering while uploading or disabled // Prevent reordering while uploading or disabled
@ -324,8 +323,8 @@
// Debug logging // Debug logging
console.log('[GalleryUploader] Media selected from library:', { console.log('[GalleryUploader] Media selected from library:', {
selectedCount: mediaArray.length, selectedCount: mediaArray.length,
selected: mediaArray.map(m => ({ id: m.id, filename: m.filename })), selected: mediaArray.map((m) => ({ id: m.id, filename: m.filename })),
currentValue: value?.map(v => ({ id: v.id, mediaId: v.mediaId, filename: v.filename })) currentValue: value?.map((v) => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
}) })
// Filter out duplicates before passing to parent // Filter out duplicates before passing to parent
@ -343,7 +342,7 @@
console.log('[GalleryUploader] Filtered new media:', { console.log('[GalleryUploader] Filtered new media:', {
newCount: newMedia.length, newCount: newMedia.length,
newMedia: newMedia.map(m => ({ id: m.id, filename: m.filename })) newMedia: newMedia.map((m) => ({ id: m.id, filename: m.filename }))
}) })
if (newMedia.length > 0) { if (newMedia.length > 0) {
@ -384,7 +383,7 @@
// Handle updates from the media details modal // Handle updates from the media details modal
function handleImageUpdate(updatedMedia: any) { function handleImageUpdate(updatedMedia: any) {
// Update the media in our value array // Update the media in our value array
const index = value.findIndex(m => (m.mediaId || m.id) === updatedMedia.id) const index = value.findIndex((m) => (m.mediaId || m.id) === updatedMedia.id)
if (index !== -1) { if (index !== -1) {
value[index] = { value[index] = {
...value[index], ...value[index],
@ -409,7 +408,7 @@
class:drag-over={isDragOver} class:drag-over={isDragOver}
class:uploading={isUploading} class:uploading={isUploading}
class:has-error={!!uploadError} class:has-error={!!uploadError}
class:disabled={disabled} class:disabled
ondragover={disabled ? undefined : handleDragOver} ondragover={disabled ? undefined : handleDragOver}
ondragleave={disabled ? undefined : handleDragLeave} ondragleave={disabled ? undefined : handleDragLeave}
ondrop={disabled ? undefined : handleDrop} ondrop={disabled ? undefined : handleDrop}
@ -524,12 +523,12 @@
<!-- Action Buttons --> <!-- Action Buttons -->
{#if !isUploading && canAddMore} {#if !isUploading && canAddMore}
<div class="action-buttons"> <div class="action-buttons">
<Button variant="primary" onclick={handleBrowseClick} disabled={disabled}> <Button variant="primary" onclick={handleBrowseClick} {disabled}>
{hasImages ? 'Add More Images' : 'Choose Images'} {hasImages ? 'Add More Images' : 'Choose Images'}
</Button> </Button>
{#if showBrowseLibrary} {#if showBrowseLibrary}
<Button variant="ghost" onclick={handleBrowseLibrary} disabled={disabled}>Browse Library</Button> <Button variant="ghost" onclick={handleBrowseLibrary} {disabled}>Browse Library</Button>
{/if} {/if}
</div> </div>
{/if} {/if}
@ -542,7 +541,7 @@
class="gallery-item" class="gallery-item"
class:dragging={draggedIndex === index} class:dragging={draggedIndex === index}
class:drag-over={draggedOverIndex === index} class:drag-over={draggedOverIndex === index}
class:disabled={disabled} class:disabled
draggable={!disabled} draggable={!disabled}
ondragstart={(e) => handleImageDragStart(e, index)} ondragstart={(e) => handleImageDragStart(e, index)}
ondragover={(e) => handleImageDragOver(e, index)} ondragover={(e) => handleImageDragOver(e, index)}
@ -575,7 +574,7 @@
type="button" type="button"
onclick={() => handleImageClick(media)} onclick={() => handleImageClick(media)}
aria-label="Edit image {media.filename}" aria-label="Edit image {media.filename}"
disabled={disabled} {disabled}
> >
<SmartImage <SmartImage
media={{ media={{
@ -611,7 +610,7 @@
}} }}
type="button" type="button"
aria-label="Remove image" aria-label="Remove image"
disabled={disabled} {disabled}
> >
<svg <svg
width="16" width="16"
@ -991,7 +990,6 @@
} }
} }
.file-info { .file-info {
padding: $unit-2x; padding: $unit-2x;
padding-top: $unit; padding-top: $unit;

View file

@ -205,7 +205,11 @@
<div class="image-pane"> <div class="image-pane">
{#if media.mimeType.startsWith('image/')} {#if media.mimeType.startsWith('image/')}
<div class="image-container"> <div class="image-container">
<SmartImage {media} alt={media.description || media.altText || media.filename} class="preview-image" /> <SmartImage
{media}
alt={media.description || media.altText || media.filename}
class="preview-image"
/>
</div> </div>
{:else} {:else}
<div class="file-placeholder"> <div class="file-placeholder">

View file

@ -4,7 +4,7 @@
import AdminPage from './AdminPage.svelte' import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte' import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import FormFieldWrapper from './FormFieldWrapper.svelte' import FormFieldWrapper from './FormFieldWrapper.svelte'
import Editor from './Editor.svelte' import CaseStudyEditor from './CaseStudyEditor.svelte'
import ProjectMetadataForm from './ProjectMetadataForm.svelte' import ProjectMetadataForm from './ProjectMetadataForm.svelte'
import ProjectBrandingForm from './ProjectBrandingForm.svelte' import ProjectBrandingForm from './ProjectBrandingForm.svelte'
import ProjectImagesForm from './ProjectImagesForm.svelte' import ProjectImagesForm from './ProjectImagesForm.svelte'
@ -272,18 +272,16 @@
</div> </div>
<!-- Case Study Panel --> <!-- Case Study Panel -->
<div class="panel case-study-wrapper" class:active={activeTab === 'case-study'}> <div class="panel panel-case-study" class:active={activeTab === 'case-study'}>
<div class="editor-content"> <CaseStudyEditor
<Editor bind:this={editorRef}
bind:this={editorRef} bind:data={formData.caseStudyContent}
bind:data={formData.caseStudyContent} onChange={handleEditorChange}
onChange={handleEditorChange} placeholder="Write your case study here..."
placeholder="Write your case study here..." minHeight={400}
minHeight={400} autofocus={false}
autofocus={false} mode="default"
class="case-study-editor" />
/>
</div>
</div> </div>
</div> </div>
{/if} {/if}
@ -338,7 +336,6 @@
} }
} }
.admin-container { .admin-container {
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
@ -414,31 +411,16 @@
gap: $unit-6x; gap: $unit-6x;
} }
.case-study-wrapper { .panel-case-study {
background: white; background: white;
padding: 0; padding: 0;
min-height: 80vh; min-height: 80vh;
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
@include breakpoint('phone') { @include breakpoint('phone') {
height: 600px; min-height: 600px;
}
}
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
/* The editor component will handle its own padding and scrolling */
:global(.case-study-editor) {
flex: 1;
overflow: auto;
} }
} }
</style> </style>

View file

@ -336,7 +336,7 @@
.title-input { .title-input {
width: 100%; width: 100%;
padding: $unit-3x; padding: $unit-4x;
border: none; border: none;
background: transparent; background: transparent;
font-size: 1rem; font-size: 1rem;

View file

@ -109,7 +109,12 @@
{#if availableActions.length > 0} {#if availableActions.length > 0}
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
{/if} {/if}
<a href={viewUrl} target="_blank" rel="noopener noreferrer" class="dropdown-item view-link"> <a
href={viewUrl}
target="_blank"
rel="noopener noreferrer"
class="dropdown-item view-link"
>
View on site View on site
</a> </a>
{/if} {/if}

View file

@ -2,7 +2,7 @@
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import Modal from './Modal.svelte' import Modal from './Modal.svelte'
import Editor from './Editor.svelte' import CaseStudyEditor from './CaseStudyEditor.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte' import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import FormFieldWrapper from './FormFieldWrapper.svelte' import FormFieldWrapper from './FormFieldWrapper.svelte'
import Button from './Button.svelte' import Button from './Button.svelte'
@ -288,7 +288,7 @@
</div> </div>
<div class="composer-body"> <div class="composer-body">
<Editor <CaseStudyEditor
bind:this={editorInstance} bind:this={editorInstance}
bind:data={content} bind:data={content}
onChange={(newContent) => { onChange={(newContent) => {
@ -296,11 +296,10 @@
characterCount = getTextFromContent(newContent) characterCount = getTextFromContent(newContent)
}} }}
placeholder="What's on your mind?" placeholder="What's on your mind?"
simpleMode={true}
autofocus={true}
minHeight={80} minHeight={80}
autofocus={true}
mode="inline"
showToolbar={false} showToolbar={false}
class="composer-editor"
/> />
{#if attachedPhotos.length > 0} {#if attachedPhotos.length > 0}
@ -440,7 +439,7 @@
</div> </div>
{:else} {:else}
<div class="content-section"> <div class="content-section">
<Editor <CaseStudyEditor
bind:this={editorInstance} bind:this={editorInstance}
bind:data={content} bind:data={content}
onChange={(newContent) => { onChange={(newContent) => {
@ -448,9 +447,9 @@
characterCount = getTextFromContent(newContent) characterCount = getTextFromContent(newContent)
}} }}
placeholder="Start writing your essay..." placeholder="Start writing your essay..."
simpleMode={false}
autofocus={true}
minHeight={500} minHeight={500}
autofocus={true}
mode="default"
/> />
</div> </div>
{/if} {/if}
@ -484,7 +483,7 @@
</svg> </svg>
</Button> </Button>
<div class="composer-body"> <div class="composer-body">
<Editor <CaseStudyEditor
bind:this={editorInstance} bind:this={editorInstance}
bind:data={content} bind:data={content}
onChange={(newContent) => { onChange={(newContent) => {
@ -492,11 +491,10 @@
characterCount = getTextFromContent(newContent) characterCount = getTextFromContent(newContent)
}} }}
placeholder="What's on your mind?" placeholder="What's on your mind?"
simpleMode={true}
autofocus={true}
minHeight={120} minHeight={120}
autofocus={true}
mode="inline"
showToolbar={false} showToolbar={false}
class="inline-composer-editor"
/> />
{#if attachedPhotos.length > 0} {#if attachedPhotos.length > 0}
@ -651,47 +649,6 @@
.composer-body { .composer-body {
display: flex; display: flex;
flex-direction: column; 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 { .link-fields {
@ -790,10 +747,6 @@
.composer-body { .composer-body {
display: flex; display: flex;
flex-direction: column; 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 { .inline-composer .link-fields {
padding: 0 $unit-3x; padding: 0 $unit-3x;
display: flex; display: flex;

View file

@ -7,17 +7,19 @@
const isAdminRoute = $derived($page.url.pathname.startsWith('/admin')) const isAdminRoute = $derived($page.url.pathname.startsWith('/admin'))
// Generate person structured data for the site // Generate person structured data for the site
const personJsonLd = $derived(generatePersonJsonLd({ const personJsonLd = $derived(
name: 'Justin Edmund', generatePersonJsonLd({
jobTitle: 'Software Designer', name: 'Justin Edmund',
description: 'Software designer based in San Francisco', jobTitle: 'Software Designer',
url: 'https://jedmund.com', description: 'Software designer based in San Francisco',
sameAs: [ url: 'https://jedmund.com',
'https://twitter.com/jedmund', sameAs: [
'https://github.com/jedmund', 'https://twitter.com/jedmund',
'https://www.linkedin.com/in/jedmund' 'https://github.com/jedmund',
] 'https://www.linkedin.com/in/jedmund'
})) ]
})
)
</script> </script>
<svelte:head> <svelte:head>

View file

@ -495,7 +495,7 @@
try { try {
console.log('[Album Edit] handleGalleryAdd called:', { console.log('[Album Edit] handleGalleryAdd called:', {
newPhotosCount: newPhotos.length, newPhotosCount: newPhotos.length,
newPhotos: newPhotos.map(p => ({ newPhotos: newPhotos.map((p) => ({
id: p.id, id: p.id,
mediaId: p.mediaId, mediaId: p.mediaId,
filename: p.filename, filename: p.filename,
@ -696,7 +696,9 @@
onRemove={handleGalleryRemove} onRemove={handleGalleryRemove}
showBrowseLibrary={true} showBrowseLibrary={true}
placeholder="Add photos to this album by uploading or selecting from your media library" 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} disabled={isManagingPhotos}
/> />
</div> </div>
@ -818,7 +820,6 @@
} }
} }
.loading-container { .loading-container {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -480,7 +480,9 @@
Alt Alt
</span> </span>
{:else} {:else}
<span class="indicator-pill no-alt-text" title="No description"> No Alt </span> <span class="indicator-pill no-alt-text" title="No description">
No Alt
</span>
{/if} {/if}
</div> </div>
<span class="filesize">{formatFileSize(item.size)}</span> <span class="filesize">{formatFileSize(item.size)}</span>

View file

@ -146,7 +146,6 @@
</header> </header>
<div class="upload-container"> <div class="upload-container">
<!-- File List --> <!-- File List -->
{#if files.length > 0} {#if files.length > 0}
<div class="file-list"> <div class="file-list">
@ -160,7 +159,9 @@
disabled={isUploading || files.length === 0} disabled={isUploading || files.length === 0}
loading={isUploading} loading={isUploading}
> >
{isUploading ? 'Uploading...' : `Upload ${files.length} File${files.length !== 1 ? 's' : ''}`} {isUploading
? 'Uploading...'
: `Upload ${files.length} File${files.length !== 1 ? 's' : ''}`}
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@ -169,7 +170,15 @@
disabled={isUploading} disabled={isUploading}
title="Clear all files" title="Clear all files"
> >
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10"></circle> <circle cx="12" cy="12" r="10"></circle>
<line x1="8" y1="8" x2="16" y2="16"></line> <line x1="8" y1="8" x2="16" y2="16"></line>
<line x1="16" y1="8" x2="8" y2="16"></line> <line x1="16" y1="8" x2="8" y2="16"></line>
@ -195,13 +204,18 @@
{#if isUploading} {#if isUploading}
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-fill" style="width: {uploadProgress[file.name] || 0}%"></div> <div
class="progress-fill"
style="width: {uploadProgress[file.name] || 0}%"
></div>
</div> </div>
<div class="upload-status"> <div class="upload-status">
{#if uploadProgress[file.name] === 100} {#if uploadProgress[file.name] === 100}
<span class="status-complete">✓ Complete</span> <span class="status-complete">✓ Complete</span>
{:else if uploadProgress[file.name] > 0} {:else if uploadProgress[file.name] > 0}
<span class="status-uploading">{Math.round(uploadProgress[file.name] || 0)}%</span> <span class="status-uploading"
>{Math.round(uploadProgress[file.name] || 0)}%</span
>
{:else} {:else}
<span class="status-waiting">Waiting...</span> <span class="status-waiting">Waiting...</span>
{/if} {/if}
@ -312,8 +326,24 @@
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round" /> <line
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" /> x1="12"
y1="5"
x2="12"
y2="19"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<line
x1="5"
y1="12"
x2="19"
y2="12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg> </svg>
<span>Add more files or drop them here</span> <span>Add more files or drop them here</span>
</div> </div>

View file

@ -3,7 +3,7 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import AdminPage from '$lib/components/admin/AdminPage.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 LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte' import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte' import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte' import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
@ -135,7 +135,6 @@
} }
} }
onMount(async () => { onMount(async () => {
// Wait a tick to ensure page params are loaded // Wait a tick to ensure page params are loaded
await new Promise((resolve) => setTimeout(resolve, 0)) await new Promise((resolve) => setTimeout(resolve, 0))
@ -391,7 +390,7 @@
{#if config?.showContent && contentReady} {#if config?.showContent && contentReady}
<div class="editor-wrapper"> <div class="editor-wrapper">
<Editor bind:data={content} placeholder="Continue writing..." /> <CaseStudyEditor bind:data={content} placeholder="Continue writing..." mode="default" />
</div> </div>
{/if} {/if}
</div> </div>
@ -490,7 +489,6 @@
} }
} }
.dropdown-menu { .dropdown-menu {
position: absolute; position: absolute;
top: calc(100% + $unit); top: calc(100% + $unit);
@ -533,13 +531,13 @@
.main-content { .main-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit-3x; gap: $unit-2x;
min-width: 0; min-width: 0;
} }
.title-input { .title-input {
width: 100%; width: 100%;
padding: 0 $unit-2x; padding: 0 $unit-4x;
border: none; border: none;
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 700; font-weight: 700;

View file

@ -3,7 +3,7 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import AdminPage from '$lib/components/admin/AdminPage.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 PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
import Button from '$lib/components/admin/Button.svelte' import Button from '$lib/components/admin/Button.svelte'
import PublishDropdown from '$lib/components/admin/PublishDropdown.svelte' import PublishDropdown from '$lib/components/admin/PublishDropdown.svelte'
@ -195,7 +195,7 @@
{#if config?.showContent} {#if config?.showContent}
<div class="editor-wrapper"> <div class="editor-wrapper">
<Editor bind:data={content} placeholder="Start writing..." /> <CaseStudyEditor bind:data={content} placeholder="Start writing..." mode="default" />
</div> </div>
{/if} {/if}
</div> </div>
@ -314,13 +314,13 @@
.main-content { .main-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit-3x; gap: $unit-2x;
min-width: 0; min-width: 0;
} }
.title-input { .title-input {
width: 100%; width: 100%;
padding: 0 $unit-2x; padding: 0 $unit-4x;
border: none; border: none;
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 700; font-weight: 700;

View file

@ -74,16 +74,16 @@ async function extractExifData(file: File) {
if (exif.ExposureTime) { if (exif.ExposureTime) {
formattedExif.shutterSpeed = formattedExif.shutterSpeed =
exif.ExposureTime < 1 exif.ExposureTime < 1 ? `1/${Math.round(1 / exif.ExposureTime)}` : `${exif.ExposureTime}s`
? `1/${Math.round(1 / exif.ExposureTime)}`
: `${exif.ExposureTime}s`
} }
if (exif.ISO) { if (exif.ISO) {
formattedExif.iso = `ISO ${exif.ISO}` formattedExif.iso = `ISO ${exif.ISO}`
} else if (exif.ISOSpeedRatings) { } else if (exif.ISOSpeedRatings) {
// Handle alternative ISO field // 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}` formattedExif.iso = `ISO ${iso}`
} }
@ -105,7 +105,8 @@ async function extractExifData(file: File) {
// Additional metadata // Additional metadata
if (exif.Orientation) { 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) { if (exif.ColorSpace) {

View file

@ -60,7 +60,8 @@ export const GET: RequestHandler = async (event) => {
// Get navigation info // Get navigation info
const prevMedia = albumMediaIndex > 0 ? album.media[albumMediaIndex - 1].media : null 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 // Transform to photo format for compatibility
const photo = { const photo = {

View file

@ -169,9 +169,11 @@ export const PUT: RequestHandler = async (event) => {
data: { data: {
photoCaption: body.caption !== undefined ? body.caption : existing.photoCaption, photoCaption: body.caption !== undefined ? body.caption : existing.photoCaption,
photoTitle: body.title !== undefined ? body.title : existing.photoTitle, 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, isPhotography: body.showInPhotos !== undefined ? body.showInPhotos : existing.isPhotography,
photoPublishedAt: body.publishedAt !== undefined ? body.publishedAt : existing.photoPublishedAt photoPublishedAt:
body.publishedAt !== undefined ? body.publishedAt : existing.photoPublishedAt
} }
}) })

View file

@ -172,47 +172,55 @@ export const GET: RequestHandler = async () => {
totalPublishedPhotos: publishedPhotos.length, totalPublishedPhotos: publishedPhotos.length,
totalPhotosNoAlbum: allPhotosNoAlbum.length, totalPhotosNoAlbum: allPhotosNoAlbum.length,
totalPhotosInDatabase: allPhotos.length, totalPhotosInDatabase: allPhotos.length,
photosByStatus: photosByStatus.map(item => ({ photosByStatus: photosByStatus.map((item) => ({
status: item.status, status: item.status,
count: item._count.id count: item._count.id
})), })),
photosWithShowInPhotosFlag: allPhotos.filter(p => p.showInPhotos).length, photosWithShowInPhotosFlag: allPhotos.filter((p) => p.showInPhotos).length,
photosByFilename: allPhotos.filter(p => p.filename?.includes('B0000057')).map(p => ({ photosByFilename: allPhotos
filename: p.filename, .filter((p) => p.filename?.includes('B0000057'))
showInPhotos: p.showInPhotos, .map((p) => ({
status: p.status, filename: p.filename,
albumId: p.albumId, showInPhotos: p.showInPhotos,
albumTitle: p.album?.title status: p.status,
})) albumId: p.albumId,
albumTitle: p.album?.title
}))
}, },
albums: { albums: {
totalAlbums: allAlbums.length, totalAlbums: allAlbums.length,
photographyAlbums: allAlbums.filter(a => a.isPhotography).map(a => ({ photographyAlbums: allAlbums
id: a.id, .filter((a) => a.isPhotography)
title: a.title, .map((a) => ({
slug: a.slug, id: a.id,
isPhotography: a.isPhotography, title: a.title,
status: a.status, slug: a.slug,
photoCount: a._count.photos isPhotography: a.isPhotography,
})), status: a.status,
nonPhotographyAlbums: allAlbums.filter(a => !a.isPhotography).map(a => ({ photoCount: a._count.photos
id: a.id, })),
title: a.title, nonPhotographyAlbums: allAlbums
slug: a.slug, .filter((a) => !a.isPhotography)
isPhotography: a.isPhotography, .map((a) => ({
status: a.status, id: a.id,
photoCount: a._count.photos title: a.title,
})), slug: a.slug,
albumFive: albumFive ? { isPhotography: a.isPhotography,
id: albumFive.id, status: a.status,
title: albumFive.title, photoCount: a._count.photos
slug: albumFive.slug, })),
isPhotography: albumFive.isPhotography, albumFive: albumFive
status: albumFive.status, ? {
publishedAt: albumFive.publishedAt, id: albumFive.id,
photoCount: albumFive.photos.length, title: albumFive.title,
photos: albumFive.photos slug: albumFive.slug,
} : null, isPhotography: albumFive.isPhotography,
status: albumFive.status,
publishedAt: albumFive.publishedAt,
photoCount: albumFive.photos.length,
photos: albumFive.photos
}
: null,
photosFromPhotographyAlbums: photosFromPhotographyAlbums.length, photosFromPhotographyAlbums: photosFromPhotographyAlbums.length,
photosFromPhotographyAlbumsSample: photosFromPhotographyAlbums.slice(0, 5) photosFromPhotographyAlbumsSample: photosFromPhotographyAlbums.slice(0, 5)
}, },
@ -227,7 +235,9 @@ export const GET: RequestHandler = async () => {
debug: { debug: {
expectedQuery: 'WHERE status = "published" AND showInPhotos = true AND albumId = null', expectedQuery: 'WHERE status = "published" AND showInPhotos = true AND albumId = null',
actualPhotosEndpointQuery: '/api/photos uses this exact query', 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) return jsonResponse(response)
} catch (error) { } catch (error) {
logger.error('Failed to run test photos query', error as 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
)
} }
} }

View file

@ -23,21 +23,26 @@
let isHoveringRight = $state(false) let isHoveringRight = $state(false)
// Spring stores for smooth button movement // Spring stores for smooth button movement
const leftButtonCoords = spring({ x: 0, y: 0 }, { const leftButtonCoords = spring(
stiffness: 0.3, { x: 0, y: 0 },
damping: 0.8 {
}) stiffness: 0.3,
damping: 0.8
}
)
const rightButtonCoords = spring({ x: 0, y: 0 }, { const rightButtonCoords = spring(
stiffness: 0.3, { x: 0, y: 0 },
damping: 0.8 {
}) stiffness: 0.3,
damping: 0.8
}
)
// Default button positions (will be set once photo loads) // Default button positions (will be set once photo loads)
let defaultLeftX = 0 let defaultLeftX = 0
let defaultRightX = 0 let defaultRightX = 0
const pageUrl = $derived($page.url.href) const pageUrl = $derived($page.url.href)
// Parse EXIF data if available // Parse EXIF data if available
@ -100,11 +105,11 @@
// Calculate default positions relative to the image // Calculate default positions relative to the image
// Add 24px (half button width) since we're using translate(-50%, -50%) // Add 24px (half button width) since we're using translate(-50%, -50%)
defaultLeftX = (imageRect.left - 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 defaultRightX = imageRect.right - pageRect.left + 24 + 16 // half button width + gap
// Set initial positions at the vertical center of the image // 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 }) leftButtonCoords.set({ x: defaultLeftX, y: centerY }, { hard: true })
rightButtonCoords.set({ x: defaultRightX, y: centerY }, { hard: true }) rightButtonCoords.set({ x: defaultRightX, y: centerY }, { hard: true })
@ -123,7 +128,11 @@
}) })
// Check mouse position on load // 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 // Get current mouse position from store
const currentPos = getCurrentMousePosition() const currentPos = getCurrentMousePosition()
@ -228,7 +237,7 @@
isHoveringRight = x > photoRect.right isHoveringRight = x > photoRect.right
// Calculate image center Y position // 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 // Update button positions
if (isHoveringLeft) { if (isHoveringLeft) {
@ -257,7 +266,7 @@
if (photoImage && pageContainer) { if (photoImage && pageContainer) {
const imageRect = photoImage.getBoundingClientRect() const imageRect = photoImage.getBoundingClientRect()
const pageRect = pageContainer.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 }) leftButtonCoords.set({ x: defaultLeftX, y: centerY })
rightButtonCoords.set({ x: defaultRightX, y: centerY }) rightButtonCoords.set({ x: defaultRightX, y: centerY })
@ -322,18 +331,9 @@
</div> </div>
</div> </div>
{:else} {:else}
<div <div class="photo-page" onmousemove={handleMouseMove} onmouseleave={handleMouseLeave}>
class="photo-page"
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
>
<div class="photo-content-wrapper"> <div class="photo-content-wrapper">
<PhotoView <PhotoView src={photo.url} alt={photo.caption} title={photo.title} id={photo.id} />
src={photo.url}
alt={photo.caption}
title={photo.title}
id={photo.id}
/>
</div> </div>
<!-- Adjacent Photos Navigation --> <!-- Adjacent Photos Navigation -->
@ -480,7 +480,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { &:hover {
background: $grey-95; background: $grey-95;

View file

@ -25,15 +25,21 @@
let isHoveringRight = $state(false) let isHoveringRight = $state(false)
// Spring stores for smooth button movement // Spring stores for smooth button movement
const leftButtonCoords = spring({ x: 0, y: 0 }, { const leftButtonCoords = spring(
stiffness: 0.3, { x: 0, y: 0 },
damping: 0.8 {
}) stiffness: 0.3,
damping: 0.8
}
)
const rightButtonCoords = spring({ x: 0, y: 0 }, { const rightButtonCoords = spring(
stiffness: 0.3, { x: 0, y: 0 },
damping: 0.8 {
}) stiffness: 0.3,
damping: 0.8
}
)
// Default button positions (will be set once photo loads) // Default button positions (will be set once photo loads)
let defaultLeftX = 0 let defaultLeftX = 0
@ -131,11 +137,11 @@
// Calculate default positions relative to the image // Calculate default positions relative to the image
// Add 24px (half button width) since we're using translate(-50%, -50%) // Add 24px (half button width) since we're using translate(-50%, -50%)
defaultLeftX = (imageRect.left - 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 defaultRightX = imageRect.right - pageRect.left + 24 + 16 // half button width + gap
// Set initial positions at the vertical center of the image // 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 }) leftButtonCoords.set({ x: defaultLeftX, y: centerY }, { hard: true })
rightButtonCoords.set({ x: defaultRightX, y: centerY }, { hard: true }) rightButtonCoords.set({ x: defaultRightX, y: centerY }, { hard: true })
@ -154,7 +160,11 @@
}) })
// Check mouse position on load // 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 // Get current mouse position from store
const currentPos = getCurrentMousePosition() const currentPos = getCurrentMousePosition()
@ -263,7 +273,7 @@
isHoveringRight = x > photoRect.right isHoveringRight = x > photoRect.right
// Calculate image center Y position // 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 // Update button positions
if (isHoveringLeft) { if (isHoveringLeft) {
@ -292,7 +302,7 @@
if (photoImage && pageContainer) { if (photoImage && pageContainer) {
const imageRect = photoImage.getBoundingClientRect() const imageRect = photoImage.getBoundingClientRect()
const pageRect = pageContainer.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 }) leftButtonCoords.set({ x: defaultLeftX, y: centerY })
rightButtonCoords.set({ x: defaultRightX, y: centerY }) rightButtonCoords.set({ x: defaultRightX, y: centerY })
@ -343,18 +353,9 @@
</div> </div>
</div> </div>
{:else if photo} {:else if photo}
<div <div class="photo-page" onmousemove={handleMouseMove} onmouseleave={handleMouseLeave}>
class="photo-page"
onmousemove={handleMouseMove}
onmouseleave={handleMouseLeave}
>
<div class="photo-content-wrapper"> <div class="photo-content-wrapper">
<PhotoView <PhotoView src={photo.url} alt={photo.caption} title={photo.title} id={photo.id} />
src={photo.url}
alt={photo.caption}
title={photo.title}
id={photo.id}
/>
</div> </div>
<!-- Adjacent Photos Navigation --> <!-- Adjacent Photos Navigation -->
@ -468,7 +469,6 @@
justify-content: center; justify-content: center;
} }
// Adjacent Navigation // Adjacent Navigation
.adjacent-navigation { .adjacent-navigation {
position: absolute; position: absolute;
@ -501,7 +501,9 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { &:hover {
background: $grey-95; background: $grey-95;

View file

@ -3,13 +3,13 @@ import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient() const prisma = new PrismaClient()
async function testDb() { async function testDb() {
try { try {
const count = await prisma.media.count() const count = await prisma.media.count()
console.log('Total media entries:', count) console.log('Total media entries:', count)
await prisma.$disconnect() await prisma.$disconnect()
} catch (error) { } catch (error) {
console.error('Database error:', error) console.error('Database error:', error)
} }
} }
testDb() testDb()