Unify fullscreen editors
This commit is contained in:
parent
f1ab953b89
commit
f753d5fb8b
36 changed files with 705 additions and 504 deletions
|
|
@ -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 breaking changes to existing functionality
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
- Railway deployment (no local disk storage)
|
||||
|
|
|
|||
|
|
@ -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<string, number>)
|
||||
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<string, number>
|
||||
)
|
||||
|
||||
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<string, number>)
|
||||
const statusCounts = allPhotos.reduce(
|
||||
(acc, photo) => {
|
||||
acc[photo.status] = (acc[photo.status] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)
|
||||
|
||||
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()
|
||||
checkPhotosDisplay()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
- Photos might be associated with an album (albumId is not null)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
testMediaSharing()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
testPhotoQueries()
|
||||
|
|
|
|||
|
|
@ -52,4 +52,4 @@
|
|||
:global([data-smiz-btn-unzoom]) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -167,7 +167,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
function handleCancel() {
|
||||
if (hasChanges() && !confirm('Are you sure you want to cancel? Your changes will be lost.')) {
|
||||
return
|
||||
|
|
|
|||
162
src/lib/components/admin/CaseStudyEditor.svelte
Normal file
162
src/lib/components/admin/CaseStudyEditor.svelte
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -724,7 +729,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
<style lang="scss">
|
||||
.edra {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
|
@ -736,9 +741,10 @@
|
|||
.editor-toolbar {
|
||||
background: var(--edra-button-bg-color);
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem;
|
||||
padding: $unit ($unit-2x + $unit);
|
||||
position: sticky;
|
||||
top: 68px;
|
||||
box-sizing: border-box;
|
||||
top: 75px;
|
||||
z-index: 10;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
|
@ -758,6 +764,10 @@
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// .edra-editor.with-toolbar {
|
||||
// padding-top: 52px; /* Account for sticky toolbar height */
|
||||
// }
|
||||
|
||||
:global(.ProseMirror) {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
|
|
|
|||
|
|
@ -157,10 +157,10 @@
|
|||
setTimeout(() => {
|
||||
console.log('[GalleryUploader] Upload completed:', {
|
||||
uploadedCount: uploadedMedia.length,
|
||||
uploaded: uploadedMedia.map(m => ({ id: m.id, filename: m.filename })),
|
||||
currentValue: value?.map(v => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
|
||||
uploaded: uploadedMedia.map((m) => ({ id: m.id, filename: m.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
|
||||
// Only pass the newly uploaded media, not the entire gallery
|
||||
onUpload(uploadedMedia)
|
||||
|
|
@ -224,7 +224,6 @@
|
|||
uploadError = null
|
||||
}
|
||||
|
||||
|
||||
// Drag and drop reordering handlers
|
||||
function handleImageDragStart(event: DragEvent, index: number) {
|
||||
// Prevent reordering while uploading or disabled
|
||||
|
|
@ -232,12 +231,12 @@
|
|||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
draggedIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
|
||||
// Debug logging
|
||||
console.log('[GalleryUploader] Drag start:', {
|
||||
index,
|
||||
|
|
@ -324,8 +323,8 @@
|
|||
// Debug logging
|
||||
console.log('[GalleryUploader] Media selected from library:', {
|
||||
selectedCount: mediaArray.length,
|
||||
selected: mediaArray.map(m => ({ id: m.id, filename: m.filename })),
|
||||
currentValue: value?.map(v => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
|
||||
selected: mediaArray.map((m) => ({ id: m.id, filename: m.filename })),
|
||||
currentValue: value?.map((v) => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
|
||||
})
|
||||
|
||||
// Filter out duplicates before passing to parent
|
||||
|
|
@ -343,7 +342,7 @@
|
|||
|
||||
console.log('[GalleryUploader] Filtered new media:', {
|
||||
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) {
|
||||
|
|
@ -384,7 +383,7 @@
|
|||
// Handle updates from the media details modal
|
||||
function handleImageUpdate(updatedMedia: any) {
|
||||
// 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) {
|
||||
value[index] = {
|
||||
...value[index],
|
||||
|
|
@ -395,7 +394,7 @@
|
|||
}
|
||||
value = [...value] // Trigger reactivity
|
||||
}
|
||||
|
||||
|
||||
// Update selectedImage for the modal
|
||||
selectedImage = updatedMedia
|
||||
}
|
||||
|
|
@ -409,7 +408,7 @@
|
|||
class:drag-over={isDragOver}
|
||||
class:uploading={isUploading}
|
||||
class:has-error={!!uploadError}
|
||||
class:disabled={disabled}
|
||||
class:disabled
|
||||
ondragover={disabled ? undefined : handleDragOver}
|
||||
ondragleave={disabled ? undefined : handleDragLeave}
|
||||
ondrop={disabled ? undefined : handleDrop}
|
||||
|
|
@ -524,12 +523,12 @@
|
|||
<!-- Action Buttons -->
|
||||
{#if !isUploading && canAddMore}
|
||||
<div class="action-buttons">
|
||||
<Button variant="primary" onclick={handleBrowseClick} disabled={disabled}>
|
||||
<Button variant="primary" onclick={handleBrowseClick} {disabled}>
|
||||
{hasImages ? 'Add More Images' : 'Choose Images'}
|
||||
</Button>
|
||||
|
||||
{#if showBrowseLibrary}
|
||||
<Button variant="ghost" onclick={handleBrowseLibrary} disabled={disabled}>Browse Library</Button>
|
||||
<Button variant="ghost" onclick={handleBrowseLibrary} {disabled}>Browse Library</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -542,7 +541,7 @@
|
|||
class="gallery-item"
|
||||
class:dragging={draggedIndex === index}
|
||||
class:drag-over={draggedOverIndex === index}
|
||||
class:disabled={disabled}
|
||||
class:disabled
|
||||
draggable={!disabled}
|
||||
ondragstart={(e) => handleImageDragStart(e, index)}
|
||||
ondragover={(e) => handleImageDragOver(e, index)}
|
||||
|
|
@ -575,7 +574,7 @@
|
|||
type="button"
|
||||
onclick={() => handleImageClick(media)}
|
||||
aria-label="Edit image {media.filename}"
|
||||
disabled={disabled}
|
||||
{disabled}
|
||||
>
|
||||
<SmartImage
|
||||
media={{
|
||||
|
|
@ -611,7 +610,7 @@
|
|||
}}
|
||||
type="button"
|
||||
aria-label="Remove image"
|
||||
disabled={disabled}
|
||||
{disabled}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
|
|
@ -760,7 +759,7 @@
|
|||
&.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-80;
|
||||
background-color: $grey-97;
|
||||
|
|
@ -940,7 +939,7 @@
|
|||
background: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
|
@ -991,7 +990,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.file-info {
|
||||
padding: $unit-2x;
|
||||
padding-top: $unit;
|
||||
|
|
|
|||
|
|
@ -205,7 +205,11 @@
|
|||
<div class="image-pane">
|
||||
{#if media.mimeType.startsWith('image/')}
|
||||
<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>
|
||||
{:else}
|
||||
<div class="file-placeholder">
|
||||
|
|
|
|||
|
|
@ -43,13 +43,13 @@
|
|||
if (isOpen) {
|
||||
// Save current scroll position
|
||||
const scrollY = window.scrollY
|
||||
|
||||
|
||||
// Lock body scroll
|
||||
document.body.style.position = 'fixed'
|
||||
document.body.style.top = `-${scrollY}px`
|
||||
document.body.style.width = '100%'
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
|
||||
return () => {
|
||||
// Restore body scroll
|
||||
const scrollY = document.body.style.top
|
||||
|
|
@ -57,7 +57,7 @@
|
|||
document.body.style.top = ''
|
||||
document.body.style.width = ''
|
||||
document.body.style.overflow = ''
|
||||
|
||||
|
||||
// Restore scroll position
|
||||
if (scrollY) {
|
||||
window.scrollTo(0, parseInt(scrollY || '0') * -1)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import AdminPage from './AdminPage.svelte'
|
||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import CaseStudyEditor from './CaseStudyEditor.svelte'
|
||||
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
||||
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
||||
import ProjectImagesForm from './ProjectImagesForm.svelte'
|
||||
|
|
@ -272,18 +272,16 @@
|
|||
</div>
|
||||
|
||||
<!-- Case Study Panel -->
|
||||
<div class="panel case-study-wrapper" class:active={activeTab === 'case-study'}>
|
||||
<div class="editor-content">
|
||||
<Editor
|
||||
bind:this={editorRef}
|
||||
bind:data={formData.caseStudyContent}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="Write your case study here..."
|
||||
minHeight={400}
|
||||
autofocus={false}
|
||||
class="case-study-editor"
|
||||
/>
|
||||
</div>
|
||||
<div class="panel panel-case-study" class:active={activeTab === 'case-study'}>
|
||||
<CaseStudyEditor
|
||||
bind:this={editorRef}
|
||||
bind:data={formData.caseStudyContent}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="Write your case study here..."
|
||||
minHeight={400}
|
||||
autofocus={false}
|
||||
mode="default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -338,7 +336,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.admin-container {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
|
@ -414,31 +411,16 @@
|
|||
gap: $unit-6x;
|
||||
}
|
||||
|
||||
.case-study-wrapper {
|
||||
.panel-case-study {
|
||||
background: white;
|
||||
padding: 0;
|
||||
min-height: 80vh;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
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;
|
||||
min-height: 600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@
|
|||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: $unit-3x;
|
||||
padding: $unit-4x;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
|
|
|
|||
|
|
@ -109,7 +109,12 @@
|
|||
{#if availableActions.length > 0}
|
||||
<div class="dropdown-divider"></div>
|
||||
{/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
|
||||
</a>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
|
||||
<div class="composer-body">
|
||||
<Editor
|
||||
<CaseStudyEditor
|
||||
bind:this={editorInstance}
|
||||
bind:data={content}
|
||||
onChange={(newContent) => {
|
||||
|
|
@ -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 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class="content-section">
|
||||
<Editor
|
||||
<CaseStudyEditor
|
||||
bind:this={editorInstance}
|
||||
bind:data={content}
|
||||
onChange={(newContent) => {
|
||||
|
|
@ -448,9 +447,9 @@
|
|||
characterCount = getTextFromContent(newContent)
|
||||
}}
|
||||
placeholder="Start writing your essay..."
|
||||
simpleMode={false}
|
||||
autofocus={true}
|
||||
minHeight={500}
|
||||
autofocus={true}
|
||||
mode="default"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -484,7 +483,7 @@
|
|||
</svg>
|
||||
</Button>
|
||||
<div class="composer-body">
|
||||
<Editor
|
||||
<CaseStudyEditor
|
||||
bind:this={editorInstance}
|
||||
bind:data={content}
|
||||
onChange={(newContent) => {
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
]
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -818,7 +820,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -480,7 +480,9 @@
|
|||
Alt
|
||||
</span>
|
||||
{: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}
|
||||
</div>
|
||||
<span class="filesize">{formatFileSize(item.size)}</span>
|
||||
|
|
|
|||
|
|
@ -146,7 +146,6 @@
|
|||
</header>
|
||||
|
||||
<div class="upload-container">
|
||||
|
||||
<!-- File List -->
|
||||
{#if files.length > 0}
|
||||
<div class="file-list">
|
||||
|
|
@ -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' : ''}`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -169,7 +170,15 @@
|
|||
disabled={isUploading}
|
||||
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>
|
||||
<line x1="8" y1="8" x2="16" y2="16"></line>
|
||||
<line x1="16" y1="8" x2="8" y2="16"></line>
|
||||
|
|
@ -195,13 +204,18 @@
|
|||
|
||||
{#if isUploading}
|
||||
<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 class="upload-status">
|
||||
{#if uploadProgress[file.name] === 100}
|
||||
<span class="status-complete">✓ Complete</span>
|
||||
{: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}
|
||||
<span class="status-waiting">Waiting...</span>
|
||||
{/if}
|
||||
|
|
@ -312,8 +326,24 @@
|
|||
fill="none"
|
||||
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 x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<line
|
||||
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>
|
||||
<span>Add more files or drop them here</span>
|
||||
</div>
|
||||
|
|
@ -394,11 +424,11 @@
|
|||
&.has-files {
|
||||
padding: $unit-4x;
|
||||
}
|
||||
|
||||
|
||||
&.compact {
|
||||
padding: $unit-3x;
|
||||
min-height: auto;
|
||||
|
||||
|
||||
.drop-zone-content {
|
||||
.compact-content {
|
||||
display: flex;
|
||||
|
|
@ -407,7 +437,7 @@
|
|||
gap: $unit-2x;
|
||||
color: $grey-40;
|
||||
font-size: 0.875rem;
|
||||
|
||||
|
||||
.add-icon {
|
||||
color: $grey-50;
|
||||
}
|
||||
|
|
@ -419,7 +449,7 @@
|
|||
border-color: $grey-60;
|
||||
background: $grey-90;
|
||||
}
|
||||
|
||||
|
||||
&.uploading {
|
||||
border-color: #3b82f6;
|
||||
border-style: solid;
|
||||
|
|
@ -574,7 +604,7 @@
|
|||
background: #3b82f6;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
|
@ -592,7 +622,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
|
|
@ -601,19 +631,19 @@
|
|||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.upload-status {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
|
||||
.status-complete {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
|
||||
.status-uploading {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
|
||||
.status-waiting {
|
||||
color: $grey-50;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
|
||||
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||
|
|
@ -135,7 +135,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
onMount(async () => {
|
||||
// Wait a tick to ensure page params are loaded
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
|
@ -391,7 +390,7 @@
|
|||
|
||||
{#if config?.showContent && contentReady}
|
||||
<div class="editor-wrapper">
|
||||
<Editor bind:data={content} placeholder="Continue writing..." />
|
||||
<CaseStudyEditor bind:data={content} placeholder="Continue writing..." mode="default" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<div class="editor-wrapper">
|
||||
<Editor bind:data={content} placeholder="Start writing..." />
|
||||
<CaseStudyEditor bind:data={content} placeholder="Start writing..." mode="default" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="photo-page"
|
||||
onmousemove={handleMouseMove}
|
||||
onmouseleave={handleMouseLeave}
|
||||
>
|
||||
<div class="photo-page" onmousemove={handleMouseMove} onmouseleave={handleMouseLeave}>
|
||||
<div class="photo-content-wrapper">
|
||||
<PhotoView
|
||||
src={photo.url}
|
||||
alt={photo.caption}
|
||||
title={photo.title}
|
||||
id={photo.id}
|
||||
/>
|
||||
<PhotoView src={photo.url} alt={photo.caption} title={photo.title} id={photo.id} />
|
||||
</div>
|
||||
|
||||
<!-- Adjacent Photos Navigation -->
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
</div>
|
||||
</div>
|
||||
{:else if photo}
|
||||
<div
|
||||
class="photo-page"
|
||||
onmousemove={handleMouseMove}
|
||||
onmouseleave={handleMouseLeave}
|
||||
>
|
||||
<div class="photo-page" onmousemove={handleMouseMove} onmouseleave={handleMouseLeave}>
|
||||
<div class="photo-content-wrapper">
|
||||
<PhotoView
|
||||
src={photo.url}
|
||||
alt={photo.caption}
|
||||
title={photo.title}
|
||||
id={photo.id}
|
||||
/>
|
||||
<PhotoView src={photo.url} alt={photo.caption} title={photo.title} id={photo.id} />
|
||||
</div>
|
||||
|
||||
<!-- Adjacent Photos Navigation -->
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,4 +39,4 @@ export const load: PageLoad = async ({ params, fetch }) => {
|
|||
error: error instanceof Error ? error.message : 'Failed to load photo'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
16
test-db.ts
16
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()
|
||||
testDb()
|
||||
|
|
|
|||
Loading…
Reference in a new issue