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
|
### 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.)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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) {
|
: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;
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
14
test-db.ts
14
test-db.ts
|
|
@ -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()
|
||||||
Loading…
Reference in a new issue