commit
0a12fe0d39
74 changed files with 5747 additions and 1925 deletions
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -56,6 +56,8 @@
|
|||
"redis": "^4.7.0",
|
||||
"sharp": "^0.34.2",
|
||||
"steamapi": "^3.0.11",
|
||||
"svelte-medium-image-zoom": "^0.2.6",
|
||||
"svelte-portal": "^2.2.1",
|
||||
"svelte-tiptap": "^2.1.0",
|
||||
"svgo": "^3.3.2",
|
||||
"tinyduration": "^3.3.1",
|
||||
|
|
@ -7832,6 +7834,19 @@
|
|||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-medium-image-zoom": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/svelte-medium-image-zoom/-/svelte-medium-image-zoom-0.2.6.tgz",
|
||||
"integrity": "sha512-PJAm9R8IgzcMmUEdCmLMYtSU6Qtzr0nh5OTKrQi/RTOrcQ/tRb7w+AGVvVyFKaR40fD5cr8986EOxjBSdD3vfg==",
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-portal": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/svelte-portal/-/svelte-portal-2.2.1.tgz",
|
||||
"integrity": "sha512-uF7is5sM4aq5iN7QF/67XLnTUvQCf2iiG/B1BHTqLwYVY1dsVmTeXZ/LeEyU6dLjApOQdbEG9lkqHzxiQtOLEQ=="
|
||||
},
|
||||
"node_modules/svelte-preprocess": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.4.tgz",
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@
|
|||
"redis": "^4.7.0",
|
||||
"sharp": "^0.34.2",
|
||||
"steamapi": "^3.0.11",
|
||||
"svelte-medium-image-zoom": "^0.2.6",
|
||||
"svelte-portal": "^2.2.1",
|
||||
"svelte-tiptap": "^2.1.0",
|
||||
"svgo": "^3.3.2",
|
||||
"tinyduration": "^3.3.1",
|
||||
|
|
|
|||
199
prd/PRD-dominant-color-extraction.md
Normal file
199
prd/PRD-dominant-color-extraction.md
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
# PRD: Dominant Color Extraction for Uploaded Images
|
||||
|
||||
## Overview
|
||||
|
||||
This PRD outlines the implementation of automatic dominant color extraction for images uploaded to the media library. This feature will analyze uploaded images to extract their primary colors, enabling color-based organization, search, and visual enhancements throughout the application.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Automatic Color Analysis**: Extract dominant colors from images during the upload process
|
||||
2. **Data Storage**: Store color information efficiently alongside existing image metadata
|
||||
3. **Visual Enhancement**: Use extracted colors to enhance UI/UX in galleries and image displays
|
||||
4. **Performance**: Ensure color extraction doesn't significantly impact upload performance
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Color Extraction Library Options
|
||||
|
||||
1. **node-vibrant** (Recommended)
|
||||
|
||||
- Pros: Lightweight, fast, good algorithm, actively maintained
|
||||
- Cons: Node.js only (server-side processing)
|
||||
- NPM: `node-vibrant`
|
||||
|
||||
2. **color-thief-node**
|
||||
|
||||
- Pros: Simple API, battle-tested algorithm
|
||||
- Cons: Less feature-rich than vibrant
|
||||
- NPM: `colorthief`
|
||||
|
||||
3. **Cloudinary Color Analysis**
|
||||
- Pros: Integrated with existing upload pipeline, no extra processing
|
||||
- Cons: Requires paid plan, vendor lock-in
|
||||
- API: `colors` parameter in upload response
|
||||
|
||||
### Recommended Approach: node-vibrant
|
||||
|
||||
```javascript
|
||||
import Vibrant from 'node-vibrant'
|
||||
|
||||
// Extract colors from uploaded image
|
||||
const palette = await Vibrant.from(buffer).getPalette()
|
||||
const dominantColors = {
|
||||
vibrant: palette.Vibrant?.hex,
|
||||
darkVibrant: palette.DarkVibrant?.hex,
|
||||
lightVibrant: palette.LightVibrant?.hex,
|
||||
muted: palette.Muted?.hex,
|
||||
darkMuted: palette.DarkMuted?.hex,
|
||||
lightMuted: palette.LightMuted?.hex
|
||||
}
|
||||
```
|
||||
|
||||
## Database Schema Changes
|
||||
|
||||
### Option 1: Add to Existing exifData JSON (Recommended)
|
||||
|
||||
```prisma
|
||||
model Media {
|
||||
// ... existing fields
|
||||
exifData Json? // Add color data here: { colors: { vibrant, muted, etc }, ...existingExif }
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Separate Colors Field
|
||||
|
||||
```prisma
|
||||
model Media {
|
||||
// ... existing fields
|
||||
dominantColors Json? // { vibrant, darkVibrant, lightVibrant, muted, darkMuted, lightMuted }
|
||||
}
|
||||
```
|
||||
|
||||
## API Changes
|
||||
|
||||
### Upload Endpoint (`/api/media/upload`)
|
||||
|
||||
Update the upload handler to extract colors:
|
||||
|
||||
```typescript
|
||||
// After successful upload to Cloudinary
|
||||
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
|
||||
const buffer = await file.arrayBuffer()
|
||||
|
||||
// Extract EXIF data (existing)
|
||||
const exifData = await extractExifData(file)
|
||||
|
||||
// Extract dominant colors (new)
|
||||
const colorData = await extractDominantColors(buffer)
|
||||
|
||||
// Combine data
|
||||
const metadata = {
|
||||
...exifData,
|
||||
colors: colorData
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"url": "...",
|
||||
"dominantColors": {
|
||||
"vibrant": "#4285f4",
|
||||
"darkVibrant": "#1a73e8",
|
||||
"lightVibrant": "#8ab4f8",
|
||||
"muted": "#5f6368",
|
||||
"darkMuted": "#3c4043",
|
||||
"lightMuted": "#e8eaed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## UI/UX Considerations
|
||||
|
||||
### 1. Media Library Display
|
||||
|
||||
- Show color swatches on hover/focus
|
||||
- Optional: Color-based filtering or sorting
|
||||
|
||||
### 2. Gallery Image Modal
|
||||
|
||||
- Display color palette in metadata section
|
||||
- Show hex values for each color
|
||||
- Copy-to-clipboard functionality for colors
|
||||
|
||||
### 3. Album/Gallery Views
|
||||
|
||||
- Use dominant color for background accents
|
||||
- Create dynamic gradients from extracted colors
|
||||
- Enhance loading states with color placeholders
|
||||
|
||||
### 4. Potential Future Features
|
||||
|
||||
- Color-based search ("find blue images")
|
||||
- Automatic theme generation for albums
|
||||
- Color harmony analysis for galleries
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Backend Implementation (1 day)
|
||||
|
||||
1. Install and configure node-vibrant
|
||||
2. Create color extraction utility function
|
||||
3. Integrate into upload pipeline
|
||||
4. Update database schema (migration)
|
||||
5. Update API responses
|
||||
|
||||
### Phase 2: Basic Frontend Display (0.5 day)
|
||||
|
||||
1. Update Media type definitions
|
||||
2. Display colors in GalleryImageModal
|
||||
3. Add color swatches to media details
|
||||
|
||||
### Phase 3: Enhanced UI Features (1 day)
|
||||
|
||||
1. Implement color-based backgrounds
|
||||
2. Add loading placeholders with colors
|
||||
3. Create color palette component
|
||||
|
||||
### Phase 4: Testing & Optimization (0.5 day)
|
||||
|
||||
1. Test with various image types
|
||||
2. Optimize for performance
|
||||
3. Handle edge cases (B&W images, etc.)
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Performance**: Color extraction adds < 200ms to upload time
|
||||
2. **Accuracy**: Colors accurately represent image content
|
||||
3. **Coverage**: 95%+ of uploaded images have color data
|
||||
4. **User Experience**: Improved visual coherence in galleries
|
||||
|
||||
## Edge Cases & Considerations
|
||||
|
||||
1. **Black & White Images**: Should return grayscale values
|
||||
2. **Transparent PNGs**: Handle alpha channel appropriately
|
||||
3. **Very Large Images**: Consider downsampling for performance
|
||||
4. **Failed Extraction**: Gracefully handle errors without blocking upload
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Color Search**: Search images by dominant color
|
||||
2. **Auto-Tagging**: Suggest tags based on color analysis
|
||||
3. **Accessibility**: Use colors to improve contrast warnings
|
||||
4. **Analytics**: Track most common colors in library
|
||||
5. **Batch Processing**: Extract colors for existing images
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `node-vibrant`: ^3.2.1
|
||||
- No additional infrastructure required
|
||||
- Compatible with existing Cloudinary workflow
|
||||
|
||||
## Timeline
|
||||
|
||||
- Total effort: 2-3 days
|
||||
- Can be implemented incrementally
|
||||
- No breaking changes to existing functionality
|
||||
464
prd/PRD-og-image-generation.md
Normal file
464
prd/PRD-og-image-generation.md
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
# PRD: OpenGraph Image Generation System
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This PRD outlines the implementation of a comprehensive OpenGraph image generation system for jedmund.com. The system will dynamically generate context-appropriate OG images for different content types while maintaining visual consistency and brand identity. The goal is to improve social media engagement and provide better visual representations of content when shared.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Current State
|
||||
|
||||
- Most pages use a static default OG image
|
||||
- Dynamic content (projects, essays, photos) doesn't have representative imagery when shared
|
||||
- No visual differentiation between content types in social previews
|
||||
- Missed opportunity for branding and engagement
|
||||
|
||||
### Impact
|
||||
|
||||
- Poor social media engagement rates
|
||||
- Generic appearance when content is shared
|
||||
- Lost opportunity to showcase project visuals and branding
|
||||
- Inconsistent visual identity across different content types
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Dynamic Generation**: Create context-appropriate OG images based on content type
|
||||
2. **Visual Consistency**: Maintain brand identity while allowing for content-specific variations
|
||||
3. **Performance**: Ensure fast generation with proper caching strategies
|
||||
4. **Extensibility**: Build a system that can easily accommodate new content types
|
||||
5. **Simplicity**: Keep the implementation DRY and maintainable
|
||||
|
||||
## Requirements
|
||||
|
||||
### Content Type Requirements
|
||||
|
||||
#### 1. Work Projects
|
||||
|
||||
- **Format**: [Avatar] + [Logo] (with "+" symbol) centered on brand background color
|
||||
- **Data needed**:
|
||||
- Project logo URL (`logoUrl`)
|
||||
- Brand background color (`backgroundColor`)
|
||||
- Avatar image (use existing `src/assets/illos/jedmund.svg`)
|
||||
- **Layout**: Avatar (100x100), "+" symbol, Logo (100x100) horizontally centered
|
||||
- **Fallback**: If no logo, use project title on brand color
|
||||
- **Font**: cstd Regular for any text
|
||||
|
||||
#### 2. Essays (Universe)
|
||||
|
||||
- **Format**: Universe icon + "Universe" label above essay title
|
||||
- **Layout**: Left-aligned, vertically centered content block
|
||||
- **Styling**:
|
||||
- 32px padding on all edges
|
||||
- Universe icon (24x24) + 8px gap + "Universe" label (smaller font)
|
||||
- Essay title below (larger font, max 2 lines with ellipsis)
|
||||
- Universe branding: red text (#FF0000)
|
||||
- Title: #4D4D4D
|
||||
- Background: white
|
||||
- Avatar (48x48) in bottom right corner
|
||||
- **Font**: cstd Regular for all text
|
||||
|
||||
#### 3. Labs Projects
|
||||
|
||||
- **Format**: Labs icon + "Labs" label above project title
|
||||
- **Layout**: Same as Essays - left-aligned, vertically centered
|
||||
- **Styling**:
|
||||
- 32px padding on all edges
|
||||
- Labs icon (24x24) + 8px gap + "Labs" label (smaller font)
|
||||
- Project title below (larger font, max 2 lines with ellipsis)
|
||||
- Labs branding: red text (#FF0000)
|
||||
- Title: #4D4D4D
|
||||
- Background: white
|
||||
- Avatar (48x48) in bottom right corner
|
||||
- **Font**: cstd Regular for all text
|
||||
|
||||
#### 4. Photos
|
||||
|
||||
- **Format**: The photo itself, fitted within frame
|
||||
- **Styling**:
|
||||
- Photo scaled to fit within 1200x630 bounds
|
||||
- Avatar (48x48) in bottom right corner
|
||||
- **Data needed**: Photo URL
|
||||
|
||||
#### 5. Albums
|
||||
|
||||
- **Format**: First photo (blurred) as background + Photos format overlay
|
||||
- **Layout**: Same as Essays/Labs - left-aligned, vertically centered
|
||||
- **Styling**:
|
||||
- First photo as blurred background (using CSS filter or canvas blur)
|
||||
- 32px padding on all edges
|
||||
- Photos icon (24x24) + 8px gap + "Photos" label (smaller font)
|
||||
- Album title below (larger font, max 2 lines with ellipsis)
|
||||
- All text in white
|
||||
- Avatar (48x48) in bottom right corner
|
||||
- **Font**: cstd Regular for all text
|
||||
|
||||
#### 6. Root Pages (Homepage, Universe, Photos, Labs, About)
|
||||
|
||||
- **No change**: Continue using existing static OG image
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
1. **Caching**: Generated images must be cached indefinitely
|
||||
2. **Performance**: Generation should be fast (<500ms)
|
||||
3. **Quality**: Images must be high quality (1200x630px)
|
||||
4. **Reliability**: Graceful fallbacks for missing data
|
||||
5. **Security**: Prevent abuse through rate limiting
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Page Route │────▶│ Metadata Utils │────▶│ OG Image URL │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ /api/og-image/ │
|
||||
│ +server.ts │
|
||||
└──────────────────┘
|
||||
│
|
||||
┌────────┴────────┐
|
||||
▼ ▼
|
||||
┌──────────┐ ┌──────────────┐
|
||||
│ Generate │ │ Return Cache │
|
||||
│ SVG │ │ │
|
||||
└──────────┘ └──────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ Convert │────────┐
|
||||
│ to PNG │ │
|
||||
└──────────┘ ▼
|
||||
│ For Albums:
|
||||
│ Apply blur
|
||||
▼ effect
|
||||
┌──────────┐
|
||||
│ Upload to│
|
||||
│Cloudinary│
|
||||
└──────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────┐
|
||||
│ Store │
|
||||
│ in Redis │
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
### Implementation Details
|
||||
|
||||
#### 1. API Endpoint Structure
|
||||
|
||||
```typescript
|
||||
/api/og-image?type=work&title=Project&logo=url&bg=color
|
||||
/api/og-image?type=essay&title=Essay+Title
|
||||
/api/og-image?type=labs&title=Lab+Project
|
||||
/api/og-image?type=photo&url=photo-url
|
||||
/api/og-image?type=album&title=Album&bg=photo-url
|
||||
```
|
||||
|
||||
#### 2. Hybrid Template System
|
||||
|
||||
- SVG templates for text-based layouts (work, essays, labs, photos)
|
||||
- Canvas/Sharp for blur effects (albums)
|
||||
- Use template literals for dynamic content injection
|
||||
- Embed base64-encoded assets (icons, avatar) to avoid external dependencies
|
||||
- All text rendered in cstd Regular font
|
||||
|
||||
#### 3. Asset Management
|
||||
|
||||
- Avatar: Use existing SVG at src/assets/illos/jedmund.svg, convert to base64
|
||||
- Icons: Convert Universe, Labs, Photos icons to base64
|
||||
- Fonts: Embed cstd Regular font for consistent rendering
|
||||
- The "+" symbol in work projects must be rendered as part of the layout
|
||||
|
||||
#### 4. Caching Strategy (Cloudinary-based)
|
||||
|
||||
##### Multi-Level Caching Architecture
|
||||
|
||||
**Level 1: Cloudinary CDN (Permanent Storage)**
|
||||
|
||||
- Upload generated images to `jedmund/og-images/` folder
|
||||
- Use content-based public IDs: `og-{type}-{contentHash}`
|
||||
- Leverage Cloudinary's global CDN for distribution
|
||||
- Automatic format optimization and responsive delivery
|
||||
|
||||
**Level 2: Redis Cache (Fast Lookups)**
|
||||
|
||||
- Cache mapping: content ID → Cloudinary public ID
|
||||
- TTL: 24 hours for quick access
|
||||
- Key structure: `og:{type}:{id}:{version}` → `cloudinary_public_id`
|
||||
|
||||
**Level 3: Browser Cache (Client-side)**
|
||||
|
||||
- Set long cache headers on Cloudinary URLs
|
||||
- Immutable URLs with content-based versioning
|
||||
|
||||
##### Content-Based Versioning
|
||||
|
||||
```typescript
|
||||
function generateOgImageId(type: string, data: any): string {
|
||||
const content = {
|
||||
type,
|
||||
// Include only content that affects the image
|
||||
...(type === 'work' && { title: data.title, logo: data.logoUrl, bg: data.backgroundColor }),
|
||||
...(type === 'essay' && { title: data.title }),
|
||||
...(type === 'labs' && { title: data.title }),
|
||||
...(type === 'photo' && { url: data.url }),
|
||||
...(type === 'album' && { title: data.title, firstPhoto: data.photos[0].src })
|
||||
}
|
||||
|
||||
const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex').slice(0, 8)
|
||||
return `og-${type}-${hash}`
|
||||
}
|
||||
```
|
||||
|
||||
##### Caching Flow
|
||||
|
||||
1. **Check Redis** for existing Cloudinary URL
|
||||
2. **If found**, return Cloudinary URL immediately
|
||||
3. **If not found**:
|
||||
- Generate SVG/PNG image
|
||||
- Upload to Cloudinary with content-based public ID
|
||||
- Store Cloudinary URL in Redis
|
||||
- Return Cloudinary URL
|
||||
|
||||
##### Invalidation Strategy
|
||||
|
||||
- **Automatic**: Content changes = new hash = new public ID
|
||||
- **Manual**: Admin UI to force regeneration (stretch goal)
|
||||
- **No cleanup needed**: Cloudinary handles storage
|
||||
|
||||
### Code Organization
|
||||
|
||||
```
|
||||
src/
|
||||
├── routes/
|
||||
│ └── api/
|
||||
│ └── og-image/
|
||||
│ └── +server.ts # Main endpoint
|
||||
├── lib/
|
||||
│ └── og-image/
|
||||
│ ├── templates/
|
||||
│ │ ├── work.ts # Work project template
|
||||
│ │ ├── essay.ts # Essay template
|
||||
│ │ ├── labs.ts # Labs template
|
||||
│ │ ├── photo.ts # Photo template
|
||||
│ │ └── album.ts # Album template
|
||||
│ ├── assets/
|
||||
│ │ ├── avatar.ts # Base64 avatar
|
||||
│ │ └── icons.ts # Base64 icons
|
||||
│ ├── generator.ts # Core generation logic
|
||||
│ └── cloudinary.ts # Cloudinary upload logic
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Foundation (Day 1)
|
||||
|
||||
- [ ] Install dependencies (sharp for image processing)
|
||||
- [ ] Create API endpoint structure
|
||||
- [ ] Set up Cloudinary integration for og-images folder
|
||||
- [ ] Implement Redis caching layer
|
||||
- [ ] Implement basic SVG to PNG conversion
|
||||
|
||||
### Phase 2: Asset Preparation (Day 2)
|
||||
|
||||
- [ ] Load Avatar SVG from src/assets/illos/jedmund.svg
|
||||
- [ ] Convert Avatar SVG to base64 for embedding
|
||||
- [ ] Convert Universe, Labs, Photos icons to base64
|
||||
- [ ] Embed cstd Regular font as base64
|
||||
- [ ] Create asset management module
|
||||
- [ ] Test asset embedding in SVGs
|
||||
|
||||
### Phase 3: Template Development (Days 3-4)
|
||||
|
||||
- [ ] Create Work project template
|
||||
- [ ] Create Essay/Universe template
|
||||
- [ ] Create Labs template (reuse Essay structure)
|
||||
- [ ] Create Photo template
|
||||
- [ ] Create Album template
|
||||
|
||||
### Phase 4: Integration (Day 5)
|
||||
|
||||
- [ ] Update metadata utils to generate OG image URLs
|
||||
- [ ] Implement Cloudinary upload pipeline
|
||||
- [ ] Set up Redis caching for Cloudinary URLs
|
||||
- [ ] Update all relevant pages to use dynamic OG images
|
||||
- [ ] Add fallback handling
|
||||
- [ ] Test all content types
|
||||
|
||||
### Phase 5: Optimization (Day 6)
|
||||
|
||||
- [ ] Performance testing
|
||||
- [ ] Add rate limiting
|
||||
- [ ] Optimize SVG generation
|
||||
- [ ] Add monitoring/logging
|
||||
|
||||
## Potential Pitfalls & Mitigations
|
||||
|
||||
### 1. Performance Issues
|
||||
|
||||
**Risk**: SVG to PNG conversion could be slow, especially with blur effects
|
||||
**Mitigation**:
|
||||
|
||||
- Pre-generate common images
|
||||
- Use efficient SVG structures for text-based layouts
|
||||
- Use Sharp's built-in blur capabilities for album backgrounds
|
||||
- Implement request coalescing
|
||||
|
||||
### 2. Memory Usage
|
||||
|
||||
**Risk**: Image processing could consume significant memory
|
||||
**Mitigation**:
|
||||
|
||||
- Stream processing where possible
|
||||
- Implement memory limits
|
||||
- Use worker threads if needed
|
||||
|
||||
### 3. Font Rendering
|
||||
|
||||
**Risk**: cstd Regular font may not render consistently
|
||||
**Mitigation**:
|
||||
|
||||
- Embed cstd Regular font as base64 in SVG
|
||||
- Use font subsetting to reduce size
|
||||
- Test rendering across different platforms
|
||||
- Fallback to similar web-safe fonts if needed
|
||||
|
||||
### 4. Asset Loading
|
||||
|
||||
**Risk**: External assets could fail to load
|
||||
**Mitigation**:
|
||||
|
||||
- Embed all assets as base64
|
||||
- No external dependencies
|
||||
- Graceful fallbacks
|
||||
|
||||
### 5. Cache Invalidation
|
||||
|
||||
**Risk**: Updated content shows old OG images
|
||||
**Mitigation**:
|
||||
|
||||
- Include version/timestamp in URL params
|
||||
- Use content-based cache keys
|
||||
- Provide manual cache purge option
|
||||
|
||||
## Success Metrics
|
||||
|
||||
1. **Generation Time**: <500ms for 95% of requests
|
||||
2. **Cache Hit Rate**: >90% after 24 hours
|
||||
3. **Error Rate**: <0.1% of requests
|
||||
4. **Visual Quality**: All text readable, proper contrast
|
||||
5. **Social Engagement**: Increased click-through rates on shared links
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **A/B Testing**: Test different layouts/styles
|
||||
2. **Internationalization**: Support for multiple languages
|
||||
3. **Dynamic Backgrounds**: Gradient or pattern options
|
||||
4. **Animation**: Animated OG images for supported platforms
|
||||
5. **Analytics**: Track which images drive most engagement
|
||||
|
||||
## Stretch Goals
|
||||
|
||||
### Admin UI for OG Image Management
|
||||
|
||||
1. **OG Image Viewer**
|
||||
|
||||
- Display current OG image for each content type
|
||||
- Show Cloudinary URL and metadata
|
||||
- Preview how it appears on social platforms
|
||||
|
||||
2. **Manual Regeneration**
|
||||
|
||||
- "Regenerate OG Image" button per content item
|
||||
- Preview new image before confirming
|
||||
- Bulk regeneration tools for content types
|
||||
|
||||
3. **Analytics Dashboard**
|
||||
|
||||
- Track generation frequency
|
||||
- Monitor cache hit rates
|
||||
- Show most viewed OG images
|
||||
|
||||
4. **Template Editor** (Advanced)
|
||||
- Visual editor for OG image templates
|
||||
- Live preview with sample data
|
||||
- Save custom templates per content type
|
||||
|
||||
## Task Checklist
|
||||
|
||||
### High Priority
|
||||
|
||||
- [ ] Set up API endpoint with proper routing
|
||||
- [ ] Install sharp and @resvg/resvg-js for image processing
|
||||
- [ ] Configure Cloudinary og-images folder
|
||||
- [ ] Implement Redis caching for Cloudinary URLs
|
||||
- [ ] Create hybrid template system (SVG + Canvas)
|
||||
- [ ] Load and convert Avatar SVG to base64
|
||||
- [ ] Convert icons to base64 format
|
||||
- [ ] Embed cstd Regular font
|
||||
- [ ] Implement Work project template (with "+" symbol)
|
||||
- [ ] Implement Essay/Universe template
|
||||
- [ ] Implement Labs template (same layout as Essays)
|
||||
- [ ] Implement Photo template
|
||||
- [ ] Implement Album template with blur effect
|
||||
- [ ] Implement Cloudinary upload pipeline
|
||||
- [ ] Update metadata utils to generate URLs
|
||||
- [ ] Test end-to-end caching flow
|
||||
|
||||
### Medium Priority
|
||||
|
||||
- [ ] Add comprehensive error handling
|
||||
- [ ] Implement rate limiting
|
||||
- [ ] Add request logging
|
||||
- [ ] Create fallback templates
|
||||
- [ ] Performance optimization
|
||||
|
||||
### Low Priority
|
||||
|
||||
- [ ] Add monitoring dashboard
|
||||
- [ ] Create manual regeneration endpoint
|
||||
- [ ] Add A/B testing capability
|
||||
- [ ] Documentation
|
||||
|
||||
### Stretch Goals
|
||||
|
||||
- [ ] Admin UI: OG image viewer
|
||||
- [ ] Admin UI: Manual regeneration button
|
||||
- [ ] Admin UI: Bulk regeneration tools
|
||||
- [ ] Admin UI: Preview before regeneration
|
||||
- [ ] Analytics dashboard for OG images
|
||||
- [ ] Template editor (advanced)
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Required Packages
|
||||
|
||||
- `sharp`: For SVG to PNG conversion and blur effects
|
||||
- `@resvg/resvg-js`: Alternative high-quality SVG to PNG converter
|
||||
- `cloudinary`: Already installed, for image storage and CDN
|
||||
- `ioredis`: Already installed, for caching Cloudinary URLs
|
||||
- Built-in Node.js modules for base64 encoding
|
||||
|
||||
### External Assets Needed
|
||||
|
||||
- Avatar SVG (existing at src/assets/illos/jedmund.svg)
|
||||
- Universe icon SVG
|
||||
- Labs icon SVG
|
||||
- Photos icon SVG
|
||||
- cstd Regular font file
|
||||
|
||||
### API Requirements
|
||||
|
||||
- Access to project data (logo, colors)
|
||||
- Access to photo URLs
|
||||
- Access to content titles and descriptions
|
||||
|
||||
### Infrastructure Requirements
|
||||
|
||||
- Cloudinary account with og-images folder configured
|
||||
- Redis instance for caching (already available)
|
||||
- Railway deployment (no local disk storage)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Photo" ADD COLUMN "mediaId" INTEGER;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Photo_mediaId_idx" ON "Photo"("mediaId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "Media"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- This migration was already applied manually or is empty
|
||||
-- Placeholder file to satisfy Prisma migration requirements
|
||||
105
prisma/migrations/migrate-photos-to-media.sql
Normal file
105
prisma/migrations/migrate-photos-to-media.sql
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
-- Step 1: Add new columns to Media table
|
||||
ALTER TABLE "Media"
|
||||
ADD COLUMN IF NOT EXISTS "photoCaption" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "photoTitle" VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS "photoDescription" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "photoSlug" VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS "photoPublishedAt" TIMESTAMP(3);
|
||||
|
||||
-- Step 2: Create AlbumMedia table
|
||||
CREATE TABLE IF NOT EXISTS "AlbumMedia" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"albumId" INTEGER NOT NULL,
|
||||
"mediaId" INTEGER NOT NULL,
|
||||
"displayOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AlbumMedia_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Step 3: Create indexes for AlbumMedia
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "AlbumMedia_albumId_mediaId_key" ON "AlbumMedia"("albumId", "mediaId");
|
||||
CREATE INDEX IF NOT EXISTS "AlbumMedia_albumId_idx" ON "AlbumMedia"("albumId");
|
||||
CREATE INDEX IF NOT EXISTS "AlbumMedia_mediaId_idx" ON "AlbumMedia"("mediaId");
|
||||
|
||||
-- Step 4: Add foreign key constraints
|
||||
ALTER TABLE "AlbumMedia"
|
||||
ADD CONSTRAINT "AlbumMedia_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
ADD CONSTRAINT "AlbumMedia_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "Media"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Step 5: Migrate data from Photo to Media (for photos without mediaId)
|
||||
UPDATE "Media" m
|
||||
SET
|
||||
"photoCaption" = p."caption",
|
||||
"photoTitle" = p."title",
|
||||
"photoDescription" = p."description",
|
||||
"photoSlug" = p."slug",
|
||||
"photoPublishedAt" = p."publishedAt",
|
||||
"isPhotography" = CASE WHEN p."showInPhotos" = true THEN true ELSE m."isPhotography" END
|
||||
FROM "Photo" p
|
||||
WHERE p."mediaId" = m."id";
|
||||
|
||||
-- Step 6: For photos without mediaId, create new Media records
|
||||
INSERT INTO "Media" (
|
||||
"filename",
|
||||
"mimeType",
|
||||
"size",
|
||||
"url",
|
||||
"thumbnailUrl",
|
||||
"width",
|
||||
"height",
|
||||
"exifData",
|
||||
"isPhotography",
|
||||
"photoCaption",
|
||||
"photoTitle",
|
||||
"photoDescription",
|
||||
"photoSlug",
|
||||
"photoPublishedAt",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
SELECT
|
||||
p."filename",
|
||||
'image/jpeg', -- Default, adjust as needed
|
||||
0, -- Default size
|
||||
p."url",
|
||||
p."thumbnailUrl",
|
||||
p."width",
|
||||
p."height",
|
||||
p."exifData",
|
||||
p."showInPhotos",
|
||||
p."caption",
|
||||
p."title",
|
||||
p."description",
|
||||
p."slug",
|
||||
p."publishedAt",
|
||||
p."createdAt",
|
||||
NOW()
|
||||
FROM "Photo" p
|
||||
WHERE p."mediaId" IS NULL;
|
||||
|
||||
-- Step 7: Create AlbumMedia records from existing Photo-Album relationships
|
||||
INSERT INTO "AlbumMedia" ("albumId", "mediaId", "displayOrder", "createdAt")
|
||||
SELECT
|
||||
p."albumId",
|
||||
COALESCE(p."mediaId", (
|
||||
SELECT m."id"
|
||||
FROM "Media" m
|
||||
WHERE m."url" = p."url"
|
||||
AND m."photoSlug" = p."slug"
|
||||
LIMIT 1
|
||||
)),
|
||||
p."displayOrder",
|
||||
p."createdAt"
|
||||
FROM "Photo" p
|
||||
WHERE p."albumId" IS NOT NULL
|
||||
AND (p."mediaId" IS NOT NULL OR EXISTS (
|
||||
SELECT 1 FROM "Media" m
|
||||
WHERE m."url" = p."url"
|
||||
AND m."photoSlug" = p."slug"
|
||||
));
|
||||
|
||||
-- Step 8: Add unique constraint on photoSlug
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "Media_photoSlug_key" ON "Media"("photoSlug");
|
||||
|
||||
-- Note: Do NOT drop the Photo table yet - we'll do that after verifying the migration
|
||||
|
|
@ -76,7 +76,8 @@ model Album {
|
|||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
photos Photo[]
|
||||
photos Photo[] // Will be removed after migration
|
||||
media AlbumMedia[]
|
||||
|
||||
@@index([slug])
|
||||
@@index([status])
|
||||
|
|
@ -86,6 +87,7 @@ model Album {
|
|||
model Photo {
|
||||
id Int @id @default(autoincrement())
|
||||
albumId Int?
|
||||
mediaId Int? // Reference to the Media item
|
||||
filename String @db.VarChar(255)
|
||||
url String @db.VarChar(500)
|
||||
thumbnailUrl String? @db.VarChar(500)
|
||||
|
|
@ -107,9 +109,11 @@ model Photo {
|
|||
|
||||
// Relations
|
||||
album Album? @relation(fields: [albumId], references: [id], onDelete: Cascade)
|
||||
media Media? @relation(fields: [mediaId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([slug])
|
||||
@@index([status])
|
||||
@@index([mediaId])
|
||||
}
|
||||
|
||||
// Media table (general uploads)
|
||||
|
|
@ -124,15 +128,24 @@ model Media {
|
|||
width Int?
|
||||
height Int?
|
||||
exifData Json? // EXIF data for photos
|
||||
altText String? @db.Text // Alt text for accessibility
|
||||
description String? @db.Text // Optional description
|
||||
description String? @db.Text // Description (used for alt text and captions)
|
||||
isPhotography Boolean @default(false) // Star for photos experience
|
||||
|
||||
// Photo-specific fields (migrated from Photo model)
|
||||
photoCaption String? @db.Text // Caption when used as standalone photo
|
||||
photoTitle String? @db.VarChar(255) // Title when used as standalone photo
|
||||
photoDescription String? @db.Text // Description when used as standalone photo
|
||||
photoSlug String? @unique @db.VarChar(255) // Slug for standalone photo
|
||||
photoPublishedAt DateTime? // Published date for standalone photo
|
||||
|
||||
usedIn Json @default("[]") // Track where media is used (legacy)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
usage MediaUsage[]
|
||||
photos Photo[] // Will be removed after migration
|
||||
albums AlbumMedia[]
|
||||
}
|
||||
|
||||
// Media usage tracking table
|
||||
|
|
@ -151,4 +164,21 @@ model MediaUsage {
|
|||
@@unique([mediaId, contentType, contentId, fieldName])
|
||||
@@index([mediaId])
|
||||
@@index([contentType, contentId])
|
||||
}
|
||||
|
||||
// Album-Media relationship table (many-to-many)
|
||||
model AlbumMedia {
|
||||
id Int @id @default(autoincrement())
|
||||
albumId Int
|
||||
mediaId Int
|
||||
displayOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Relations
|
||||
album Album @relation(fields: [albumId], references: [id], onDelete: Cascade)
|
||||
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([albumId, mediaId])
|
||||
@@index([albumId])
|
||||
@@index([mediaId])
|
||||
}
|
||||
122
scripts/check-photos-display.ts
Normal file
122
scripts/check-photos-display.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function checkPhotosDisplay() {
|
||||
try {
|
||||
console.log('=== Checking Photos Display ===\n')
|
||||
|
||||
// Check albums marked for photography
|
||||
const photographyAlbums = await prisma.album.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
isPhotography: true
|
||||
},
|
||||
include: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Found ${photographyAlbums.length} published photography albums:`)
|
||||
photographyAlbums.forEach((album) => {
|
||||
console.log(`- "${album.title}" (${album.slug}): ${album.photos.length} published photos`)
|
||||
})
|
||||
|
||||
// Check individual photos marked to show in photos
|
||||
const individualPhotos = await prisma.photo.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true,
|
||||
albumId: null
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`\nFound ${individualPhotos.length} individual photos marked to show in Photos`)
|
||||
individualPhotos.forEach((photo) => {
|
||||
console.log(`- Photo ID ${photo.id}: ${photo.filename}`)
|
||||
})
|
||||
|
||||
// Check if there are any published photos in albums
|
||||
const photosInAlbums = await prisma.photo.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true,
|
||||
albumId: { not: null }
|
||||
},
|
||||
include: {
|
||||
album: true
|
||||
}
|
||||
})
|
||||
|
||||
console.log(
|
||||
`\nFound ${photosInAlbums.length} published photos in albums with showInPhotos=true`
|
||||
)
|
||||
const albumGroups = photosInAlbums.reduce(
|
||||
(acc, photo) => {
|
||||
const albumTitle = photo.album?.title || 'Unknown'
|
||||
acc[albumTitle] = (acc[albumTitle] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)
|
||||
|
||||
Object.entries(albumGroups).forEach(([album, count]) => {
|
||||
console.log(`- Album "${album}": ${count} photos`)
|
||||
})
|
||||
|
||||
// Check media marked as photography
|
||||
const photographyMedia = await prisma.media.findMany({
|
||||
where: {
|
||||
isPhotography: true
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`\nFound ${photographyMedia.length} media items marked as photography`)
|
||||
|
||||
// Check for any photos regardless of status
|
||||
const allPhotos = await prisma.photo.findMany({
|
||||
include: {
|
||||
album: true
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`\nTotal photos in database: ${allPhotos.length}`)
|
||||
const statusCounts = allPhotos.reduce(
|
||||
(acc, photo) => {
|
||||
acc[photo.status] = (acc[photo.status] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)
|
||||
|
||||
Object.entries(statusCounts).forEach(([status, count]) => {
|
||||
console.log(`- Status "${status}": ${count} photos`)
|
||||
})
|
||||
|
||||
// Check all albums
|
||||
const allAlbums = await prisma.album.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { photos: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`\nTotal albums in database: ${allAlbums.length}`)
|
||||
allAlbums.forEach((album) => {
|
||||
console.log(
|
||||
`- "${album.title}" (${album.slug}): status=${album.status}, isPhotography=${album.isPhotography}, photos=${album._count.photos}`
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error checking photos:', error)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
checkPhotosDisplay()
|
||||
46
scripts/debug-photos.md
Normal file
46
scripts/debug-photos.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Debug Photos Display
|
||||
|
||||
This directory contains tools to debug why photos aren't appearing on the photos page.
|
||||
|
||||
## API Test Endpoint
|
||||
|
||||
Visit the following URL in your browser while the dev server is running:
|
||||
|
||||
```
|
||||
http://localhost:5173/api/test-photos
|
||||
```
|
||||
|
||||
This endpoint will return detailed information about:
|
||||
|
||||
- All photos with showInPhotos=true and albumId=null
|
||||
- Status distribution of these photos
|
||||
- Raw SQL query results
|
||||
- Comparison with what the /api/photos endpoint expects
|
||||
|
||||
## Database Query Script
|
||||
|
||||
Run the following command to query the database directly:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/test-photos-query.ts
|
||||
```
|
||||
|
||||
This script will show:
|
||||
|
||||
- Total photos in the database
|
||||
- Photos matching the criteria (showInPhotos=true, albumId=null)
|
||||
- Status distribution
|
||||
- Published vs draft photos
|
||||
- All unique status values in the database
|
||||
|
||||
## What to Check
|
||||
|
||||
1. **Status Values**: The main photos API expects `status='published'`. Check if your photos have this status.
|
||||
2. **showInPhotos Flag**: Make sure photos have `showInPhotos=true`
|
||||
3. **Album Association**: Photos should have `albumId=null` to appear as individual photos
|
||||
|
||||
## Common Issues
|
||||
|
||||
- Photos might be in 'draft' status instead of 'published'
|
||||
- Photos might have showInPhotos=false
|
||||
- Photos might be associated with an album (albumId is not null)
|
||||
11
scripts/migrate-alttext-to-description.sql
Normal file
11
scripts/migrate-alttext-to-description.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-- Consolidate altText into description
|
||||
-- If description is null or empty, copy altText value
|
||||
-- If both exist, keep description (assuming it's more comprehensive)
|
||||
UPDATE "Media"
|
||||
SET description = COALESCE(NULLIF(description, ''), "altText")
|
||||
WHERE "altText" IS NOT NULL AND "altText" != '';
|
||||
|
||||
-- Show how many records were affected
|
||||
SELECT COUNT(*) as updated_records
|
||||
FROM "Media"
|
||||
WHERE "altText" IS NOT NULL AND "altText" != '';
|
||||
136
scripts/migrate-photos-to-media.ts
Normal file
136
scripts/migrate-photos-to-media.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function main() {
|
||||
console.log('Starting photo to media migration...')
|
||||
|
||||
try {
|
||||
// Step 1: Get all photos
|
||||
const photos = await prisma.photo.findMany({
|
||||
include: {
|
||||
album: true,
|
||||
media: true
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`Found ${photos.length} photos to migrate`)
|
||||
|
||||
// Step 2: Process each photo
|
||||
let migratedCount = 0
|
||||
let createdMediaCount = 0
|
||||
let albumMediaCount = 0
|
||||
|
||||
for (const photo of photos) {
|
||||
if (photo.mediaId && photo.media) {
|
||||
// Photo has associated media - update the media record
|
||||
await prisma.media.update({
|
||||
where: { id: photo.mediaId },
|
||||
data: {
|
||||
photoCaption: photo.caption,
|
||||
photoTitle: photo.title,
|
||||
photoDescription: photo.description,
|
||||
photoSlug: photo.slug,
|
||||
photoPublishedAt: photo.publishedAt,
|
||||
isPhotography: photo.showInPhotos
|
||||
}
|
||||
})
|
||||
migratedCount++
|
||||
} else {
|
||||
// Photo has no media - create new media record
|
||||
const newMedia = await prisma.media.create({
|
||||
data: {
|
||||
filename: photo.filename,
|
||||
originalName: photo.filename,
|
||||
mimeType: 'image/jpeg', // Default, could be improved
|
||||
size: 0, // Unknown
|
||||
url: photo.url,
|
||||
thumbnailUrl: photo.thumbnailUrl,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
exifData: photo.exifData,
|
||||
isPhotography: photo.showInPhotos,
|
||||
photoCaption: photo.caption,
|
||||
photoTitle: photo.title,
|
||||
photoDescription: photo.description,
|
||||
photoSlug: photo.slug,
|
||||
photoPublishedAt: photo.publishedAt,
|
||||
createdAt: photo.createdAt
|
||||
}
|
||||
})
|
||||
createdMediaCount++
|
||||
|
||||
// Update the photo to reference the new media
|
||||
await prisma.photo.update({
|
||||
where: { id: photo.id },
|
||||
data: { mediaId: newMedia.id }
|
||||
})
|
||||
}
|
||||
|
||||
// Create AlbumMedia record if photo belongs to an album
|
||||
if (photo.albumId) {
|
||||
const mediaId =
|
||||
photo.mediaId ||
|
||||
(
|
||||
await prisma.photo.findUnique({
|
||||
where: { id: photo.id },
|
||||
select: { mediaId: true }
|
||||
})
|
||||
)?.mediaId
|
||||
|
||||
if (mediaId) {
|
||||
// Check if AlbumMedia already exists
|
||||
const existing = await prisma.albumMedia.findUnique({
|
||||
where: {
|
||||
albumId_mediaId: {
|
||||
albumId: photo.albumId,
|
||||
mediaId: mediaId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
await prisma.albumMedia.create({
|
||||
data: {
|
||||
albumId: photo.albumId,
|
||||
mediaId: mediaId,
|
||||
displayOrder: photo.displayOrder,
|
||||
createdAt: photo.createdAt
|
||||
}
|
||||
})
|
||||
albumMediaCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Migration completed:`)
|
||||
console.log(`- Updated ${migratedCount} existing media records`)
|
||||
console.log(`- Created ${createdMediaCount} new media records`)
|
||||
console.log(`- Created ${albumMediaCount} album-media relationships`)
|
||||
|
||||
// Step 3: Verify migration
|
||||
const mediaWithPhotoData = await prisma.media.count({
|
||||
where: {
|
||||
OR: [
|
||||
{ photoCaption: { not: null } },
|
||||
{ photoTitle: { not: null } },
|
||||
{ photoSlug: { not: null } }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const albumMediaRelations = await prisma.albumMedia.count()
|
||||
|
||||
console.log(`\nVerification:`)
|
||||
console.log(`- Media records with photo data: ${mediaWithPhotoData}`)
|
||||
console.log(`- Album-media relationships: ${albumMediaRelations}`)
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
198
scripts/test-media-sharing.ts
Executable file
198
scripts/test-media-sharing.ts
Executable file
|
|
@ -0,0 +1,198 @@
|
|||
#!/usr/bin/env tsx
|
||||
// Test script to verify that Media can be shared across multiple albums
|
||||
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function testMediaSharing() {
|
||||
console.log('Testing Media sharing across albums...\n')
|
||||
|
||||
try {
|
||||
// 1. Create a test media item
|
||||
console.log('1. Creating test media item...')
|
||||
const media = await prisma.media.create({
|
||||
data: {
|
||||
filename: 'test-shared-image.jpg',
|
||||
originalName: 'Test Shared Image',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 1024000,
|
||||
url: 'https://example.com/test-shared-image.jpg',
|
||||
thumbnailUrl: 'https://example.com/test-shared-image-thumb.jpg',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
altText: 'A test image that will be shared across albums',
|
||||
description: 'This is a test image to verify media sharing',
|
||||
isPhotography: true
|
||||
}
|
||||
})
|
||||
console.log(`✓ Created media with ID: ${media.id}\n`)
|
||||
|
||||
// 2. Create two test albums
|
||||
console.log('2. Creating test albums...')
|
||||
const album1 = await prisma.album.create({
|
||||
data: {
|
||||
slug: 'test-album-1',
|
||||
title: 'Test Album 1',
|
||||
description: 'First test album for media sharing',
|
||||
status: 'published'
|
||||
}
|
||||
})
|
||||
console.log(`✓ Created album 1 with ID: ${album1.id}`)
|
||||
|
||||
const album2 = await prisma.album.create({
|
||||
data: {
|
||||
slug: 'test-album-2',
|
||||
title: 'Test Album 2',
|
||||
description: 'Second test album for media sharing',
|
||||
status: 'published'
|
||||
}
|
||||
})
|
||||
console.log(`✓ Created album 2 with ID: ${album2.id}\n`)
|
||||
|
||||
// 3. Add the same media to both albums
|
||||
console.log('3. Adding media to both albums...')
|
||||
const photo1 = await prisma.photo.create({
|
||||
data: {
|
||||
albumId: album1.id,
|
||||
mediaId: media.id,
|
||||
filename: media.filename,
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
caption: 'Same media in album 1',
|
||||
displayOrder: 1,
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
}
|
||||
})
|
||||
console.log(`✓ Added photo to album 1 with ID: ${photo1.id}`)
|
||||
|
||||
const photo2 = await prisma.photo.create({
|
||||
data: {
|
||||
albumId: album2.id,
|
||||
mediaId: media.id,
|
||||
filename: media.filename,
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
caption: 'Same media in album 2',
|
||||
displayOrder: 1,
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
}
|
||||
})
|
||||
console.log(`✓ Added photo to album 2 with ID: ${photo2.id}\n`)
|
||||
|
||||
// 4. Create media usage records
|
||||
console.log('4. Creating media usage records...')
|
||||
await prisma.mediaUsage.createMany({
|
||||
data: [
|
||||
{
|
||||
mediaId: media.id,
|
||||
contentType: 'album',
|
||||
contentId: album1.id,
|
||||
fieldName: 'photos'
|
||||
},
|
||||
{
|
||||
mediaId: media.id,
|
||||
contentType: 'album',
|
||||
contentId: album2.id,
|
||||
fieldName: 'photos'
|
||||
}
|
||||
]
|
||||
})
|
||||
console.log('✓ Created media usage records\n')
|
||||
|
||||
// 5. Verify the media is in both albums
|
||||
console.log('5. Verifying media is in both albums...')
|
||||
const verifyAlbum1 = await prisma.album.findUnique({
|
||||
where: { id: album1.id },
|
||||
include: {
|
||||
photos: {
|
||||
include: {
|
||||
media: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const verifyAlbum2 = await prisma.album.findUnique({
|
||||
where: { id: album2.id },
|
||||
include: {
|
||||
photos: {
|
||||
include: {
|
||||
media: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`✓ Album 1 has ${verifyAlbum1?.photos.length} photo(s)`)
|
||||
console.log(` - Photo mediaId: ${verifyAlbum1?.photos[0]?.mediaId}`)
|
||||
console.log(` - Media filename: ${verifyAlbum1?.photos[0]?.media?.filename}`)
|
||||
|
||||
console.log(`✓ Album 2 has ${verifyAlbum2?.photos.length} photo(s)`)
|
||||
console.log(` - Photo mediaId: ${verifyAlbum2?.photos[0]?.mediaId}`)
|
||||
console.log(` - Media filename: ${verifyAlbum2?.photos[0]?.media?.filename}\n`)
|
||||
|
||||
// 6. Check media usage
|
||||
console.log('6. Checking media usage records...')
|
||||
const mediaUsage = await prisma.mediaUsage.findMany({
|
||||
where: { mediaId: media.id }
|
||||
})
|
||||
console.log(`✓ Media is used in ${mediaUsage.length} places:`)
|
||||
mediaUsage.forEach((usage) => {
|
||||
console.log(` - ${usage.contentType} ID ${usage.contentId} (${usage.fieldName})`)
|
||||
})
|
||||
|
||||
// 7. Verify media can be queried with all its photos
|
||||
console.log('\n7. Querying media with all photos...')
|
||||
const mediaWithPhotos = await prisma.media.findUnique({
|
||||
where: { id: media.id },
|
||||
include: {
|
||||
photos: {
|
||||
include: {
|
||||
album: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log(`✓ Media is in ${mediaWithPhotos?.photos.length} photos:`)
|
||||
mediaWithPhotos?.photos.forEach((photo) => {
|
||||
console.log(` - Photo ID ${photo.id} in album "${photo.album?.title}"`)
|
||||
})
|
||||
|
||||
console.log('\n✅ SUCCESS: Media can be shared across multiple albums!')
|
||||
|
||||
// Cleanup
|
||||
console.log('\n8. Cleaning up test data...')
|
||||
await prisma.mediaUsage.deleteMany({
|
||||
where: { mediaId: media.id }
|
||||
})
|
||||
await prisma.photo.deleteMany({
|
||||
where: { mediaId: media.id }
|
||||
})
|
||||
await prisma.album.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [album1.id, album2.id]
|
||||
}
|
||||
}
|
||||
})
|
||||
await prisma.media.delete({
|
||||
where: { id: media.id }
|
||||
})
|
||||
console.log('✓ Test data cleaned up')
|
||||
} catch (error) {
|
||||
console.error('\n❌ ERROR:', error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testMediaSharing()
|
||||
109
scripts/test-photos-query.ts
Normal file
109
scripts/test-photos-query.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
import 'dotenv/config'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function testPhotoQueries() {
|
||||
console.log('=== Testing Photo Queries ===\n')
|
||||
|
||||
try {
|
||||
// Query 1: Count all photos
|
||||
const totalPhotos = await prisma.photo.count()
|
||||
console.log(`Total photos in database: ${totalPhotos}`)
|
||||
|
||||
// Query 2: Photos with showInPhotos=true and albumId=null
|
||||
const photosForDisplay = await prisma.photo.findMany({
|
||||
where: {
|
||||
showInPhotos: true,
|
||||
albumId: null
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
filename: true,
|
||||
status: true,
|
||||
showInPhotos: true,
|
||||
albumId: true,
|
||||
publishedAt: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`\nPhotos with showInPhotos=true and albumId=null: ${photosForDisplay.length}`)
|
||||
photosForDisplay.forEach((photo) => {
|
||||
console.log(
|
||||
` - ID: ${photo.id}, Status: ${photo.status}, Slug: ${photo.slug || 'none'}, File: ${photo.filename}`
|
||||
)
|
||||
})
|
||||
|
||||
// Query 3: Check status distribution
|
||||
const statusCounts = await prisma.photo.groupBy({
|
||||
by: ['status'],
|
||||
where: {
|
||||
showInPhotos: true,
|
||||
albumId: null
|
||||
},
|
||||
_count: {
|
||||
id: true
|
||||
}
|
||||
})
|
||||
|
||||
console.log('\nStatus distribution for photos with showInPhotos=true and albumId=null:')
|
||||
statusCounts.forEach(({ status, _count }) => {
|
||||
console.log(` - ${status}: ${_count.id}`)
|
||||
})
|
||||
|
||||
// Query 4: Published photos that should appear
|
||||
const publishedPhotos = await prisma.photo.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true,
|
||||
albumId: null
|
||||
}
|
||||
})
|
||||
|
||||
console.log(
|
||||
`\nPublished photos (status='published', showInPhotos=true, albumId=null): ${publishedPhotos.length}`
|
||||
)
|
||||
publishedPhotos.forEach((photo) => {
|
||||
console.log(` - ID: ${photo.id}, File: ${photo.filename}, Published: ${photo.publishedAt}`)
|
||||
})
|
||||
|
||||
// Query 5: Check if there are any draft photos that might need publishing
|
||||
const draftPhotos = await prisma.photo.findMany({
|
||||
where: {
|
||||
status: 'draft',
|
||||
showInPhotos: true,
|
||||
albumId: null
|
||||
}
|
||||
})
|
||||
|
||||
if (draftPhotos.length > 0) {
|
||||
console.log(`\n⚠️ Found ${draftPhotos.length} draft photos with showInPhotos=true:`)
|
||||
draftPhotos.forEach((photo) => {
|
||||
console.log(` - ID: ${photo.id}, File: ${photo.filename}`)
|
||||
})
|
||||
console.log('These photos need to be published to appear in the photos page!')
|
||||
}
|
||||
|
||||
// Query 6: Check unique statuses in the database
|
||||
const uniqueStatuses = await prisma.photo.findMany({
|
||||
distinct: ['status'],
|
||||
select: {
|
||||
status: true
|
||||
}
|
||||
})
|
||||
|
||||
console.log('\nAll unique status values in the database:')
|
||||
uniqueStatuses.forEach(({ status }) => {
|
||||
console.log(` - "${status}"`)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error running queries:', error)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test
|
||||
testPhotoQueries()
|
||||
3
src/app.css
Normal file
3
src/app.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/* Global styles for the entire application */
|
||||
@import './assets/styles/reset.css';
|
||||
@import './assets/styles/globals.scss';
|
||||
6
src/assets/styles/imports.scss
Normal file
6
src/assets/styles/imports.scss
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// This file contains only imports that should be available in every component
|
||||
// It should NOT contain any actual CSS rules to avoid duplication
|
||||
|
||||
@import './variables.scss';
|
||||
@import './fonts.scss';
|
||||
@import './themes.scss';
|
||||
|
|
@ -58,13 +58,15 @@ $mention-padding: $unit-3x;
|
|||
|
||||
$font-stack: 'Circular Std', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
|
||||
$font-unit: 14px;
|
||||
$font-unit: 18px;
|
||||
$font-unit-mobile: 16px;
|
||||
|
||||
$font-size-small: 0.7rem; // 10
|
||||
$font-size: 1rem; // 14
|
||||
$font-size-med: 1.25rem; // 16
|
||||
$font-size-large: 1.4rem; // 18
|
||||
$font-size-xlarge: 1.65rem; // 22
|
||||
$font-size-extra-small: 0.75rem; // 12
|
||||
$font-size-small: 0.875rem; // 14
|
||||
$font-size: 1rem; // 18
|
||||
$font-size-med: 1.25rem; // 20
|
||||
$font-size-large: 1.4rem; // 22
|
||||
$font-size-xlarge: 1.65rem; // 26
|
||||
|
||||
$font-weight: 400;
|
||||
$font-weight-med: 500;
|
||||
|
|
|
|||
|
|
@ -10,25 +10,40 @@ export function setAdminAuth(username: string, password: string) {
|
|||
|
||||
// Get auth headers for API requests
|
||||
export function getAuthHeaders(): HeadersInit {
|
||||
if (!adminCredentials) {
|
||||
// For development, use default credentials
|
||||
// In production, this should redirect to login
|
||||
adminCredentials = btoa('admin:localdev')
|
||||
// First try to get from localStorage (where login stores it)
|
||||
const storedAuth = typeof window !== 'undefined' ? localStorage.getItem('admin_auth') : null
|
||||
if (storedAuth) {
|
||||
return {
|
||||
Authorization: `Basic ${storedAuth}`
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to in-memory credentials if set
|
||||
if (adminCredentials) {
|
||||
return {
|
||||
Authorization: `Basic ${adminCredentials}`
|
||||
}
|
||||
}
|
||||
|
||||
// Development fallback
|
||||
const fallbackAuth = btoa('admin:localdev')
|
||||
return {
|
||||
Authorization: `Basic ${adminCredentials}`
|
||||
Authorization: `Basic ${fallbackAuth}`
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is authenticated (basic check)
|
||||
export function isAuthenticated(): boolean {
|
||||
return adminCredentials !== null
|
||||
const storedAuth = typeof window !== 'undefined' ? localStorage.getItem('admin_auth') : null
|
||||
return storedAuth !== null || adminCredentials !== null
|
||||
}
|
||||
|
||||
// Clear auth (logout)
|
||||
export function clearAuth() {
|
||||
adminCredentials = null
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('admin_auth')
|
||||
}
|
||||
}
|
||||
|
||||
// Make authenticated API request
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@
|
|||
}
|
||||
|
||||
.artist-name {
|
||||
font-size: $font-size-small;
|
||||
font-size: $font-size-extra-small;
|
||||
font-weight: $font-weight-med;
|
||||
color: $grey-40;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@
|
|||
}
|
||||
|
||||
.game-playtime {
|
||||
font-size: $font-size-small;
|
||||
font-size: $font-size-extra-small;
|
||||
font-weight: $font-weight-med;
|
||||
color: $grey-40;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@
|
|||
aria-haspopup="true"
|
||||
style="color: {getTextColor(activeItem.variant)};"
|
||||
>
|
||||
<svelte:component this={activeItem.icon} class="nav-icon" />
|
||||
<activeItem.icon class="nav-icon" />
|
||||
<span>{activeItem.text}</span>
|
||||
<ChevronDownIcon class="chevron {isOpen ? 'open' : ''}" />
|
||||
</button>
|
||||
|
|
@ -126,7 +126,7 @@
|
|||
class:active={item === activeItem}
|
||||
onclick={() => (isOpen = false)}
|
||||
>
|
||||
<svelte:component this={item.icon} class="nav-icon" />
|
||||
<item.icon class="nav-icon" />
|
||||
<span>{item.text}</span>
|
||||
</a>
|
||||
{/each}
|
||||
|
|
|
|||
|
|
@ -21,11 +21,12 @@
|
|||
// For individual photos, check if we have album context
|
||||
if (albumSlug) {
|
||||
// Navigate to photo within album
|
||||
const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix
|
||||
goto(`/photos/${albumSlug}/${photoId}`)
|
||||
const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
|
||||
goto(`/photos/${albumSlug}/${mediaId}`)
|
||||
} else {
|
||||
// For standalone photos, navigate to a generic photo page (to be implemented)
|
||||
console.log('Individual photo navigation not yet implemented')
|
||||
// Navigate to individual photo page using the media ID
|
||||
const mediaId = item.id.replace(/^(media|photo)-/, '') // Support both prefixes
|
||||
goto(`/photos/p/${mediaId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
230
src/lib/components/PhotoMetadata.svelte
Normal file
230
src/lib/components/PhotoMetadata.svelte
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
<script lang="ts">
|
||||
import BackButton from './BackButton.svelte'
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
caption?: string
|
||||
description?: string
|
||||
exifData?: any
|
||||
createdAt?: string
|
||||
backHref?: string
|
||||
backLabel?: string
|
||||
showBackButton?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
caption,
|
||||
description,
|
||||
exifData,
|
||||
createdAt,
|
||||
backHref,
|
||||
backLabel,
|
||||
showBackButton = false,
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const hasDetails = $derived(title || caption || description)
|
||||
const hasMetadata = $derived(exifData || createdAt)
|
||||
</script>
|
||||
|
||||
<div class="photo-metadata {className}">
|
||||
{#if hasDetails}
|
||||
<div class="photo-details">
|
||||
{#if title}
|
||||
<h1 class="photo-title">{title}</h1>
|
||||
{/if}
|
||||
|
||||
{#if caption || description}
|
||||
<p class="photo-description">{caption || description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasMetadata}
|
||||
<div class="metadata-grid {hasDetails ? 'metadata-section' : ''}">
|
||||
{#if exifData?.camera}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Camera</span>
|
||||
<span class="metadata-value">{exifData.camera}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exifData?.lens}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Lens</span>
|
||||
<span class="metadata-value">{exifData.lens}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exifData?.focalLength}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Focal Length</span>
|
||||
<span class="metadata-value">{exifData.focalLength}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exifData?.aperture}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Aperture</span>
|
||||
<span class="metadata-value">{exifData.aperture}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exifData?.shutterSpeed}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Shutter Speed</span>
|
||||
<span class="metadata-value">{exifData.shutterSpeed}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exifData?.iso}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">ISO</span>
|
||||
<span class="metadata-value">{exifData.iso}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exifData?.dateTaken}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Date Taken</span>
|
||||
<span class="metadata-value">{formatDate(exifData.dateTaken)}</span>
|
||||
</div>
|
||||
{:else if createdAt}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Date</span>
|
||||
<span class="metadata-value">{formatDate(createdAt)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exifData?.location}
|
||||
<div class="metadata-item">
|
||||
<span class="metadata-label">Location</span>
|
||||
<span class="metadata-value">{exifData.location}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showBackButton && backHref && backLabel}
|
||||
<div class="card-footer">
|
||||
<BackButton href={backHref} label={backLabel} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
@import '$styles/mixins.scss';
|
||||
|
||||
.photo-metadata {
|
||||
background: $grey-100;
|
||||
border: 1px solid $grey-90;
|
||||
border-radius: $image-corner-radius;
|
||||
padding: $unit-3x;
|
||||
padding-bottom: $unit-2x;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-details {
|
||||
margin-bottom: $unit-4x;
|
||||
padding-bottom: $unit-4x;
|
||||
border-bottom: 1px solid $grey-90;
|
||||
text-align: center;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
margin-bottom: $unit-3x;
|
||||
padding-bottom: $unit-3x;
|
||||
}
|
||||
|
||||
.photo-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-10;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-description {
|
||||
font-size: 1rem;
|
||||
color: $grey-30;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: $unit-3x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
&.metadata-section {
|
||||
margin-bottom: $unit-4x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
margin-bottom: $unit-3x;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
|
||||
.metadata-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: $grey-40;
|
||||
}
|
||||
|
||||
.metadata-value {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-10;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
margin-top: $unit-3x;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
55
src/lib/components/PhotoView.svelte
Normal file
55
src/lib/components/PhotoView.svelte
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script lang="ts">
|
||||
import Zoom from 'svelte-medium-image-zoom'
|
||||
import 'svelte-medium-image-zoom/dist/styles.css'
|
||||
|
||||
interface Props {
|
||||
src: string
|
||||
alt?: string
|
||||
title?: string
|
||||
id?: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { src, alt = '', title, id, class: className = '' }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="photo-view {className}">
|
||||
{#key id || src}
|
||||
<Zoom>
|
||||
<img {src} alt={title || alt || 'Photo'} class="photo-image" />
|
||||
</Zoom>
|
||||
{/key}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
@import '$styles/mixins.scss';
|
||||
|
||||
.photo-view {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.photo-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 700px;
|
||||
object-fit: contain;
|
||||
border-radius: $image-corner-radius;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@include breakpoint('phone') {
|
||||
border-radius: $image-corner-radius;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the zoom library's close button
|
||||
:global([data-smiz-btn-unzoom]) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
</script>
|
||||
|
||||
<a {href} class="pill {variant}" class:active>
|
||||
<svelte:component this={icon} />
|
||||
<icon />
|
||||
<span>{text}</span>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
style="position: relative; overflow: hidden; background-color: {backgroundColor}; height: {containerHeight}; display: flex; justify-content: center; align-items: center;"
|
||||
>
|
||||
<div style="position: relative; width: 100%; height: 100%;">
|
||||
<svelte:component this={SVGComponent} />
|
||||
<SVGComponent />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -119,8 +119,7 @@
|
|||
onmouseenter={() => (hoveredIndex = index)}
|
||||
onmouseleave={() => (hoveredIndex = null)}
|
||||
>
|
||||
<svelte:component
|
||||
this={item.icon}
|
||||
<item.icon
|
||||
class="nav-icon {hoveredIndex === index ? 'animate' : ''}"
|
||||
/>
|
||||
<span>{item.text}</span>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
let {
|
||||
media,
|
||||
alt = media.altText || media.filename || '',
|
||||
alt = media.description || media.filename || '',
|
||||
class: className = '',
|
||||
containerWidth,
|
||||
loading = 'lazy',
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
postId?: number
|
||||
initialData?: {
|
||||
title?: string
|
||||
slug?: string
|
||||
content?: JSONContent
|
||||
gallery?: Media[]
|
||||
status: 'draft' | 'published'
|
||||
|
|
@ -29,6 +30,7 @@
|
|||
|
||||
// Form data
|
||||
let title = $state(initialData?.title || '')
|
||||
let slug = $state(initialData?.slug || '')
|
||||
let content = $state<JSONContent>({ type: 'doc', content: [] })
|
||||
let gallery = $state<Media[]>([])
|
||||
let tags = $state(initialData?.tags?.join(', ') || '')
|
||||
|
|
@ -36,6 +38,16 @@
|
|||
// Editor ref
|
||||
let editorRef: any
|
||||
|
||||
// Auto-generate slug from title
|
||||
$effect(() => {
|
||||
if (title && !slug) {
|
||||
slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
})
|
||||
|
||||
// Initialize data for edit mode
|
||||
$effect(() => {
|
||||
if (initialData && mode === 'edit') {
|
||||
|
|
@ -114,7 +126,7 @@
|
|||
try {
|
||||
const postData = {
|
||||
title: title.trim(),
|
||||
slug: generateSlug(title),
|
||||
slug: slug,
|
||||
postType: 'album',
|
||||
status: newStatus,
|
||||
content,
|
||||
|
|
@ -155,13 +167,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (hasChanges() && !confirm('Are you sure you want to cancel? Your changes will be lost.')) {
|
||||
return
|
||||
|
|
@ -242,6 +247,13 @@
|
|||
required={true}
|
||||
error={title.trim().length === 0 ? 'Title is required' : undefined}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Slug"
|
||||
bind:value={slug}
|
||||
placeholder="album-url-slug"
|
||||
helpText="URL-friendly version of the title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $unit;
|
||||
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -1,170 +0,0 @@
|
|||
<script lang="ts">
|
||||
interface Column<T> {
|
||||
key: string
|
||||
label: string
|
||||
render?: (item: T) => string
|
||||
component?: any
|
||||
width?: string
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
data: T[]
|
||||
columns: Column<T>[]
|
||||
isLoading?: boolean
|
||||
emptyMessage?: string
|
||||
onRowClick?: (item: T) => void
|
||||
unstyled?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
data = [],
|
||||
columns = [],
|
||||
isLoading = false,
|
||||
emptyMessage = 'No data found',
|
||||
onRowClick,
|
||||
unstyled = false
|
||||
}: Props<any> = $props()
|
||||
|
||||
function getCellValue(item: any, column: Column<any>) {
|
||||
if (column.render) {
|
||||
return column.render(item)
|
||||
}
|
||||
|
||||
// Handle nested properties
|
||||
const keys = column.key.split('.')
|
||||
let value = item
|
||||
for (const key of keys) {
|
||||
value = value?.[key]
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="data-table-wrapper" class:unstyled>
|
||||
{#if isLoading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
{:else if data.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>{emptyMessage}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{#each columns as column}
|
||||
<th style={column.width ? `width: ${column.width}` : ''}>
|
||||
{column.label}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data as item}
|
||||
<tr class:clickable={!!onRowClick} onclick={() => onRowClick?.(item)}>
|
||||
{#each columns as column}
|
||||
<td>
|
||||
{#if column.component}
|
||||
<svelte:component this={column.component} {item} />
|
||||
{:else}
|
||||
{getCellValue(item, column)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.data-table-wrapper {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
|
||||
&.unstyled {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: $unit-8x;
|
||||
text-align: center;
|
||||
color: $grey-40;
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid $grey-80;
|
||||
border-top-color: $primary-color;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto $unit-2x;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: $unit-8x;
|
||||
text-align: center;
|
||||
color: $grey-40;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
|
||||
thead {
|
||||
background-color: $grey-95;
|
||||
border-bottom: 1px solid $grey-85;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: $unit-3x $unit-4x;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid $grey-90;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-97;
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: $unit-4x;
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -131,10 +131,12 @@
|
|||
}
|
||||
|
||||
:global(.editor-content .editor-toolbar) {
|
||||
border-radius: $card-corner-radius;
|
||||
border-radius: $corner-radius-full;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 0 16px rgba(0, 0, 0, 0.12);
|
||||
box-sizing: border-box;
|
||||
background: $grey-95;
|
||||
padding: $unit-2x;
|
||||
padding: $unit $unit-2x;
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
overflow-x: auto;
|
||||
|
|
|
|||
|
|
@ -251,7 +251,11 @@
|
|||
const editorInstance = (view as any).editor
|
||||
if (editorInstance) {
|
||||
// Use pasteHTML to let Tiptap process the HTML and apply configured extensions
|
||||
editorInstance.chain().focus().insertContent(htmlData, { parseOptions: { preserveWhitespace: false } }).run()
|
||||
editorInstance
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent(htmlData, { parseOptions: { preserveWhitespace: false } })
|
||||
.run()
|
||||
} else {
|
||||
// Fallback to plain text if editor instance not available
|
||||
const { state, dispatch } = view
|
||||
|
|
@ -506,6 +510,7 @@
|
|||
}
|
||||
}}
|
||||
class="edra-editor"
|
||||
class:with-toolbar={showToolbar}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -724,7 +729,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
<style lang="scss">
|
||||
.edra {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
|
@ -736,9 +741,10 @@
|
|||
.editor-toolbar {
|
||||
background: var(--edra-button-bg-color);
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem;
|
||||
padding: $unit ($unit-2x + $unit);
|
||||
position: sticky;
|
||||
top: 68px;
|
||||
box-sizing: border-box;
|
||||
top: 75px;
|
||||
z-index: 10;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
|
@ -758,6 +764,10 @@
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// .edra-editor.with-toolbar {
|
||||
// padding-top: 52px; /* Account for sticky toolbar height */
|
||||
// }
|
||||
|
||||
:global(.ProseMirror) {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import Input from './Input.svelte'
|
||||
import SmartImage from '../SmartImage.svelte'
|
||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||
import MediaDetailsModal from './MediaDetailsModal.svelte'
|
||||
import { authenticatedFetch } from '$lib/admin-auth'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -20,6 +21,7 @@
|
|||
helpText?: string
|
||||
showBrowseLibrary?: boolean
|
||||
maxFileSize?: number // MB limit
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -35,7 +37,8 @@
|
|||
placeholder = 'Drag and drop images here, or click to browse',
|
||||
helpText,
|
||||
showBrowseLibrary = false,
|
||||
maxFileSize = 10
|
||||
maxFileSize = 10,
|
||||
disabled = false
|
||||
}: Props = $props()
|
||||
|
||||
// State
|
||||
|
|
@ -47,6 +50,8 @@
|
|||
let draggedIndex = $state<number | null>(null)
|
||||
let draggedOverIndex = $state<number | null>(null)
|
||||
let isMediaLibraryOpen = $state(false)
|
||||
let isImageModalOpen = $state(false)
|
||||
let selectedImage = $state<any | null>(null)
|
||||
|
||||
// Computed properties
|
||||
const hasImages = $derived(value && value.length > 0)
|
||||
|
|
@ -93,7 +98,7 @@
|
|||
|
||||
// Handle file selection/drop
|
||||
async function handleFiles(files: FileList) {
|
||||
if (files.length === 0) return
|
||||
if (files.length === 0 || disabled) return
|
||||
|
||||
// Validate files
|
||||
const filesToUpload: File[] = []
|
||||
|
|
@ -150,8 +155,13 @@
|
|||
|
||||
// Brief delay to show completion
|
||||
setTimeout(() => {
|
||||
const newValue = [...(value || []), ...uploadedMedia]
|
||||
value = newValue
|
||||
console.log('[GalleryUploader] Upload completed:', {
|
||||
uploadedCount: uploadedMedia.length,
|
||||
uploaded: uploadedMedia.map((m) => ({ id: m.id, filename: m.filename })),
|
||||
currentValue: value?.map((v) => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
|
||||
})
|
||||
|
||||
// Don't update value here - let parent handle it through API response
|
||||
// Only pass the newly uploaded media, not the entire gallery
|
||||
onUpload(uploadedMedia)
|
||||
isUploading = false
|
||||
|
|
@ -214,53 +224,25 @@
|
|||
uploadError = null
|
||||
}
|
||||
|
||||
// Update alt text on server
|
||||
async function handleAltTextChange(item: any, newAltText: string) {
|
||||
if (!item) return
|
||||
|
||||
try {
|
||||
// For album photos, use mediaId; for direct media objects, use id
|
||||
const mediaId = item.mediaId || item.id
|
||||
if (!mediaId) {
|
||||
console.error('No media ID found for alt text update')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch(`/api/media/${mediaId}/metadata`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
altText: newAltText.trim() || null
|
||||
})
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const updatedData = await response.json()
|
||||
if (value) {
|
||||
const index = value.findIndex((v) => (v.mediaId || v.id) === mediaId)
|
||||
if (index !== -1) {
|
||||
value[index] = {
|
||||
...value[index],
|
||||
altText: updatedData.altText,
|
||||
updatedAt: updatedData.updatedAt
|
||||
}
|
||||
value = [...value]
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update alt text:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop reordering handlers
|
||||
function handleImageDragStart(event: DragEvent, index: number) {
|
||||
// Prevent reordering while uploading or disabled
|
||||
if (isUploading || disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
draggedIndex = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log('[GalleryUploader] Drag start:', {
|
||||
index,
|
||||
item: value[index],
|
||||
totalItems: value.length
|
||||
})
|
||||
}
|
||||
|
||||
function handleImageDragOver(event: DragEvent, index: number) {
|
||||
|
|
@ -278,7 +260,20 @@
|
|||
function handleImageDrop(event: DragEvent, dropIndex: number) {
|
||||
event.preventDefault()
|
||||
|
||||
if (draggedIndex === null || !value) return
|
||||
if (draggedIndex === null || !value || isUploading || disabled) return
|
||||
|
||||
// Debug logging before reorder
|
||||
console.log('[GalleryUploader] Before reorder:', {
|
||||
draggedIndex,
|
||||
dropIndex,
|
||||
totalItems: value.length,
|
||||
items: value.map((v, i) => ({
|
||||
index: i,
|
||||
id: v.id,
|
||||
mediaId: v.mediaId,
|
||||
filename: v.filename
|
||||
}))
|
||||
})
|
||||
|
||||
const newValue = [...value]
|
||||
const draggedItem = newValue[draggedIndex]
|
||||
|
|
@ -290,6 +285,17 @@
|
|||
const adjustedDropIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex
|
||||
newValue.splice(adjustedDropIndex, 0, draggedItem)
|
||||
|
||||
// Debug logging after reorder
|
||||
console.log('[GalleryUploader] After reorder:', {
|
||||
adjustedDropIndex,
|
||||
newItems: newValue.map((v, i) => ({
|
||||
index: i,
|
||||
id: v.id,
|
||||
mediaId: v.mediaId,
|
||||
filename: v.filename
|
||||
}))
|
||||
})
|
||||
|
||||
value = newValue
|
||||
onUpload(newValue)
|
||||
if (onReorder) {
|
||||
|
|
@ -314,6 +320,13 @@
|
|||
// For gallery mode, selectedMedia will be an array
|
||||
const mediaArray = Array.isArray(selectedMedia) ? selectedMedia : [selectedMedia]
|
||||
|
||||
// Debug logging
|
||||
console.log('[GalleryUploader] Media selected from library:', {
|
||||
selectedCount: mediaArray.length,
|
||||
selected: mediaArray.map((m) => ({ id: m.id, filename: m.filename })),
|
||||
currentValue: value?.map((v) => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
|
||||
})
|
||||
|
||||
// Filter out duplicates before passing to parent
|
||||
// Create a comprehensive set of existing IDs (both id and mediaId)
|
||||
const existingIds = new Set()
|
||||
|
|
@ -327,6 +340,11 @@
|
|||
return !existingIds.has(media.id) && !existingIds.has(media.mediaId)
|
||||
})
|
||||
|
||||
console.log('[GalleryUploader] Filtered new media:', {
|
||||
newCount: newMedia.length,
|
||||
newMedia: newMedia.map((m) => ({ id: m.id, filename: m.filename }))
|
||||
})
|
||||
|
||||
if (newMedia.length > 0) {
|
||||
// Don't modify the value array here - let the parent component handle it
|
||||
// through the API calls and then update the bound value
|
||||
|
|
@ -337,6 +355,49 @@
|
|||
function handleMediaLibraryClose() {
|
||||
isMediaLibraryOpen = false
|
||||
}
|
||||
|
||||
// Handle clicking on an image to open details modal
|
||||
function handleImageClick(media: any) {
|
||||
// Convert to Media format if needed
|
||||
selectedImage = {
|
||||
id: media.mediaId || media.id,
|
||||
filename: media.filename,
|
||||
originalName: media.originalName || media.filename,
|
||||
mimeType: media.mimeType || 'image/jpeg',
|
||||
size: media.size || 0,
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
altText: media.altText || '',
|
||||
description: media.description || '',
|
||||
isPhotography: media.isPhotography || false,
|
||||
createdAt: media.createdAt,
|
||||
updatedAt: media.updatedAt,
|
||||
exifData: media.exifData || null,
|
||||
usedIn: media.usedIn || []
|
||||
}
|
||||
isImageModalOpen = true
|
||||
}
|
||||
|
||||
// Handle updates from the media details modal
|
||||
function handleImageUpdate(updatedMedia: any) {
|
||||
// Update the media in our value array
|
||||
const index = value.findIndex((m) => (m.mediaId || m.id) === updatedMedia.id)
|
||||
if (index !== -1) {
|
||||
value[index] = {
|
||||
...value[index],
|
||||
altText: updatedMedia.altText,
|
||||
description: updatedMedia.description,
|
||||
isPhotography: updatedMedia.isPhotography,
|
||||
updatedAt: updatedMedia.updatedAt
|
||||
}
|
||||
value = [...value] // Trigger reactivity
|
||||
}
|
||||
|
||||
// Update selectedImage for the modal
|
||||
selectedImage = updatedMedia
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="gallery-uploader">
|
||||
|
|
@ -347,10 +408,11 @@
|
|||
class:drag-over={isDragOver}
|
||||
class:uploading={isUploading}
|
||||
class:has-error={!!uploadError}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
onclick={handleBrowseClick}
|
||||
class:disabled
|
||||
ondragover={disabled ? undefined : handleDragOver}
|
||||
ondragleave={disabled ? undefined : handleDragLeave}
|
||||
ondrop={disabled ? undefined : handleDrop}
|
||||
onclick={disabled ? undefined : handleBrowseClick}
|
||||
>
|
||||
{#if isUploading}
|
||||
<!-- Upload Progress -->
|
||||
|
|
@ -461,12 +523,12 @@
|
|||
<!-- Action Buttons -->
|
||||
{#if !isUploading && canAddMore}
|
||||
<div class="action-buttons">
|
||||
<Button variant="primary" onclick={handleBrowseClick}>
|
||||
<Button variant="primary" onclick={handleBrowseClick} {disabled}>
|
||||
{hasImages ? 'Add More Images' : 'Choose Images'}
|
||||
</Button>
|
||||
|
||||
{#if showBrowseLibrary}
|
||||
<Button variant="ghost" onclick={handleBrowseLibrary}>Browse Library</Button>
|
||||
<Button variant="ghost" onclick={handleBrowseLibrary} {disabled}>Browse Library</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -474,12 +536,13 @@
|
|||
<!-- Image Gallery -->
|
||||
{#if hasImages}
|
||||
<div class="image-gallery">
|
||||
{#each value as media, index (`${media.mediaId || media.id || index}`)}
|
||||
{#each value as media, index (`photo-${media.id || 'temp'}-${media.mediaId || 'new'}-${index}`)}
|
||||
<div
|
||||
class="gallery-item"
|
||||
class:dragging={draggedIndex === index}
|
||||
class:drag-over={draggedOverIndex === index}
|
||||
draggable="true"
|
||||
class:disabled
|
||||
draggable={!disabled}
|
||||
ondragstart={(e) => handleImageDragStart(e, index)}
|
||||
ondragover={(e) => handleImageDragOver(e, index)}
|
||||
ondragleave={handleImageDragLeave}
|
||||
|
|
@ -506,36 +569,48 @@
|
|||
|
||||
<!-- Image Preview -->
|
||||
<div class="image-preview">
|
||||
<SmartImage
|
||||
media={{
|
||||
id: media.mediaId || media.id,
|
||||
filename: media.filename,
|
||||
originalName: media.originalName || media.filename,
|
||||
mimeType: media.mimeType || 'image/jpeg',
|
||||
size: media.size || 0,
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
altText: media.altText,
|
||||
description: media.description,
|
||||
isPhotography: media.isPhotography || false,
|
||||
createdAt: media.createdAt,
|
||||
updatedAt: media.updatedAt
|
||||
}}
|
||||
alt={media.altText || media.filename || 'Gallery image'}
|
||||
containerWidth={300}
|
||||
loading="lazy"
|
||||
aspectRatio="1:1"
|
||||
class="gallery-image"
|
||||
/>
|
||||
<button
|
||||
class="image-button"
|
||||
type="button"
|
||||
onclick={() => handleImageClick(media)}
|
||||
aria-label="Edit image {media.filename}"
|
||||
{disabled}
|
||||
>
|
||||
<SmartImage
|
||||
media={{
|
||||
id: media.mediaId || media.id,
|
||||
filename: media.filename,
|
||||
originalName: media.originalName || media.filename,
|
||||
mimeType: media.mimeType || 'image/jpeg',
|
||||
size: media.size || 0,
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
altText: media.altText,
|
||||
description: media.description,
|
||||
isPhotography: media.isPhotography || false,
|
||||
createdAt: media.createdAt,
|
||||
updatedAt: media.updatedAt
|
||||
}}
|
||||
alt={media.altText || media.filename || 'Gallery image'}
|
||||
containerWidth={300}
|
||||
loading="lazy"
|
||||
aspectRatio="1:1"
|
||||
class="gallery-image"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Remove Button -->
|
||||
<button
|
||||
class="remove-button"
|
||||
onclick={() => handleRemoveImage(index)}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveImage(index)
|
||||
}}
|
||||
type="button"
|
||||
aria-label="Remove image"
|
||||
{disabled}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
|
|
@ -568,20 +643,6 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Alt Text Input -->
|
||||
{#if allowAltText}
|
||||
<div class="alt-text-input">
|
||||
<Input
|
||||
type="text"
|
||||
label="Alt Text"
|
||||
value={media.altText || ''}
|
||||
placeholder="Describe this image"
|
||||
buttonSize="small"
|
||||
onblur={(e) => handleAltTextChange(media, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- File Info -->
|
||||
<div class="file-info">
|
||||
<p class="filename">{media.originalName || media.filename}</p>
|
||||
|
|
@ -624,6 +685,17 @@
|
|||
onClose={handleMediaLibraryClose}
|
||||
/>
|
||||
|
||||
<!-- Media Details Modal -->
|
||||
<MediaDetailsModal
|
||||
bind:isOpen={isImageModalOpen}
|
||||
media={selectedImage}
|
||||
onClose={() => {
|
||||
isImageModalOpen = false
|
||||
selectedImage = null
|
||||
}}
|
||||
onUpdate={handleImageUpdate}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
.gallery-uploader {
|
||||
display: flex;
|
||||
|
|
@ -683,6 +755,16 @@
|
|||
border-color: $red-60;
|
||||
background-color: rgba($red-60, 0.02);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-80;
|
||||
background-color: $grey-97;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-prompt {
|
||||
|
|
@ -828,18 +910,50 @@
|
|||
&:hover .drag-handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
|
||||
.drag-handle {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:hover .drag-handle {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
background-color: $grey-97;
|
||||
|
||||
:global(.gallery-image) {
|
||||
.image-button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:global(.gallery-image) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
|
|
@ -858,12 +972,17 @@
|
|||
color: $grey-40;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1;
|
||||
|
||||
&:hover {
|
||||
background: white;
|
||||
color: $red-60;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .remove-button {
|
||||
|
|
@ -871,10 +990,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.alt-text-input {
|
||||
padding: $unit-2x;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
padding: $unit-2x;
|
||||
padding-top: $unit;
|
||||
|
|
|
|||
|
|
@ -246,7 +246,7 @@
|
|||
<h4>{field.label}</h4>
|
||||
</div>
|
||||
{:else if field.type === 'custom' && field.component}
|
||||
<svelte:component this={field.component} {...field.props} bind:data />
|
||||
<field.component {...field.props} bind:data />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import Modal from './Modal.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import Textarea from './Textarea.svelte'
|
||||
import SmartImage from '../SmartImage.svelte'
|
||||
import { authenticatedFetch } from '$lib/admin-auth'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
|
@ -16,7 +17,6 @@
|
|||
let { isOpen = $bindable(), media, onClose, onUpdate }: Props = $props()
|
||||
|
||||
// Form state
|
||||
let altText = $state('')
|
||||
let description = $state('')
|
||||
let isPhotography = $state(false)
|
||||
let isSaving = $state(false)
|
||||
|
|
@ -36,14 +36,18 @@
|
|||
>([])
|
||||
let loadingUsage = $state(false)
|
||||
|
||||
// EXIF toggle state
|
||||
let showExif = $state(false)
|
||||
|
||||
// Initialize form when media changes
|
||||
$effect(() => {
|
||||
if (media) {
|
||||
altText = media.altText || ''
|
||||
description = media.description || ''
|
||||
// Use description if available, otherwise fall back to altText for backwards compatibility
|
||||
description = media.description || media.altText || ''
|
||||
isPhotography = media.isPhotography || false
|
||||
error = ''
|
||||
successMessage = ''
|
||||
showExif = false
|
||||
loadUsage()
|
||||
}
|
||||
})
|
||||
|
|
@ -72,7 +76,6 @@
|
|||
}
|
||||
|
||||
function handleClose() {
|
||||
altText = ''
|
||||
description = ''
|
||||
isPhotography = false
|
||||
error = ''
|
||||
|
|
@ -94,7 +97,8 @@
|
|||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
altText: altText.trim() || null,
|
||||
// Use description for both altText and description fields
|
||||
altText: description.trim() || null,
|
||||
description: description.trim() || null,
|
||||
isPhotography: isPhotography
|
||||
})
|
||||
|
|
@ -190,213 +194,299 @@
|
|||
{#if media}
|
||||
<Modal
|
||||
bind:isOpen
|
||||
size="large"
|
||||
size="jumbo"
|
||||
closeOnBackdrop={!isSaving}
|
||||
closeOnEscape={!isSaving}
|
||||
on:close={handleClose}
|
||||
onClose={handleClose}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<div class="media-details-modal">
|
||||
<!-- Header -->
|
||||
<div class="modal-header">
|
||||
<div class="header-content">
|
||||
<h2>Media Details</h2>
|
||||
<p class="filename">{media.filename}</p>
|
||||
</div>
|
||||
{#if !isSaving}
|
||||
<Button variant="ghost" onclick={handleClose} iconOnly aria-label="Close modal">
|
||||
<!-- Left Pane - Image Preview -->
|
||||
<div class="image-pane">
|
||||
{#if media.mimeType.startsWith('image/')}
|
||||
<div class="image-container">
|
||||
<SmartImage
|
||||
{media}
|
||||
alt={media.description || media.altText || media.filename}
|
||||
class="preview-image"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="file-placeholder">
|
||||
<svg
|
||||
slot="icon"
|
||||
width="24"
|
||||
height="24"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 6L18 18M6 18L18 6"
|
||||
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="14,2 14,8 20,8"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<span class="file-type">{getFileType(media.mimeType)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="modal-body">
|
||||
<div class="media-preview-section">
|
||||
<!-- Media Preview -->
|
||||
<div class="media-preview">
|
||||
{#if media.mimeType.startsWith('image/')}
|
||||
<SmartImage {media} alt={media.altText || media.filename} />
|
||||
{:else}
|
||||
<div class="file-placeholder">
|
||||
<!-- Right Pane - Details -->
|
||||
<div class="details-pane">
|
||||
<!-- Header -->
|
||||
<div class="pane-header">
|
||||
<h2 class="filename-header">{media.filename}</h2>
|
||||
<div class="header-actions">
|
||||
{#if !isSaving}
|
||||
<Button variant="ghost" onclick={copyUrl} iconOnly aria-label="Copy URL">
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
slot="icon"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="9"
|
||||
y="9"
|
||||
width="13"
|
||||
height="13"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
<path
|
||||
d="M5 15H4C2.89543 15 2 14.1046 2 13V4C2 2.89543 2.89543 2 4 2H13C14.1046 2 15 2.89543 15 4V5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={handleClose} iconOnly aria-label="Close modal">
|
||||
<svg
|
||||
slot="icon"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
|
||||
d="M6 6L18 18M6 18L18 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<polyline
|
||||
points="14,2 14,8 20,8"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span class="file-type">{getFileType(media.mimeType)}</span>
|
||||
</div>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- File Info -->
|
||||
</div>
|
||||
<div class="pane-body">
|
||||
<div class="file-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Type:</span>
|
||||
<span class="value">{getFileType(media.mimeType)}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Size:</span>
|
||||
<span class="value">{formatFileSize(media.size)}</span>
|
||||
</div>
|
||||
{#if media.width && media.height}
|
||||
<div class="info-row">
|
||||
<span class="label">Dimensions:</span>
|
||||
<span class="value">{media.width} × {media.height}px</span>
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="label">Type</span>
|
||||
<span class="value">{getFileType(media.mimeType)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="info-row">
|
||||
<span class="label">Uploaded:</span>
|
||||
<span class="value">{new Date(media.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">URL:</span>
|
||||
<div class="url-section">
|
||||
<span class="url-text">{media.url}</span>
|
||||
<Button variant="ghost" buttonSize="small" onclick={copyUrl}>Copy</Button>
|
||||
<div class="info-item">
|
||||
<span class="label">Size</span>
|
||||
<span class="value">{formatFileSize(media.size)}</span>
|
||||
</div>
|
||||
{#if media.width && media.height}
|
||||
<div class="info-item">
|
||||
<span class="label">Dimensions</span>
|
||||
<span class="value">{media.width} × {media.height}px</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="info-item">
|
||||
<span class="label">Uploaded</span>
|
||||
<span class="value">{new Date(media.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
<div class="edit-form">
|
||||
<h3>Accessibility & SEO</h3>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
label="Alt Text"
|
||||
bind:value={altText}
|
||||
placeholder="Describe this image for screen readers"
|
||||
helpText="Help make your content accessible. Describe what's in the image."
|
||||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Description (Optional)"
|
||||
bind:value={description}
|
||||
placeholder="Additional description or caption"
|
||||
helpText="Optional longer description for context or captions."
|
||||
rows={3}
|
||||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<!-- Photography Toggle -->
|
||||
<div class="photography-toggle">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isPhotography}
|
||||
disabled={isSaving}
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
<div class="toggle-content">
|
||||
<span class="toggle-title">Photography</span>
|
||||
<span class="toggle-description">Show this media in the photography experience</span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Usage Tracking -->
|
||||
<div class="usage-section">
|
||||
<h4>Used In</h4>
|
||||
{#if loadingUsage}
|
||||
<div class="usage-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading usage information...</span>
|
||||
</div>
|
||||
{:else if usage.length > 0}
|
||||
<ul class="usage-list">
|
||||
{#each usage as usageItem}
|
||||
<li class="usage-item">
|
||||
<div class="usage-content">
|
||||
<div class="usage-header">
|
||||
{#if usageItem.contentUrl}
|
||||
<a
|
||||
href={usageItem.contentUrl}
|
||||
class="usage-title"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{usageItem.contentTitle}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="usage-title">{usageItem.contentTitle}</span>
|
||||
{/if}
|
||||
<span class="usage-type">{usageItem.contentType}</span>
|
||||
</div>
|
||||
<div class="usage-details">
|
||||
<span class="usage-field">{usageItem.fieldDisplayName}</span>
|
||||
<span class="usage-date"
|
||||
>Added {new Date(usageItem.createdAt).toLocaleDateString()}</span
|
||||
>
|
||||
</div>
|
||||
{#if media.exifData && Object.keys(media.exifData).length > 0}
|
||||
{#if showExif}
|
||||
<div class="exif-data">
|
||||
{#if media.exifData.camera}
|
||||
<div class="info-item">
|
||||
<span class="label">Camera</span>
|
||||
<span class="value">{media.exifData.camera}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="no-usage">This media file is not currently used in any content.</p>
|
||||
{/if}
|
||||
{#if media.exifData.lens}
|
||||
<div class="info-item">
|
||||
<span class="label">Lens</span>
|
||||
<span class="value">{media.exifData.lens}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if media.exifData.focalLength}
|
||||
<div class="info-item">
|
||||
<span class="label">Focal Length</span>
|
||||
<span class="value">{media.exifData.focalLength}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if media.exifData.aperture}
|
||||
<div class="info-item">
|
||||
<span class="label">Aperture</span>
|
||||
<span class="value">{media.exifData.aperture}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if media.exifData.shutterSpeed}
|
||||
<div class="info-item">
|
||||
<span class="label">Shutter Speed</span>
|
||||
<span class="value">{media.exifData.shutterSpeed}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if media.exifData.iso}
|
||||
<div class="info-item">
|
||||
<span class="label">ISO</span>
|
||||
<span class="value">{media.exifData.iso}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if media.exifData.dateTaken}
|
||||
<div class="info-item">
|
||||
<span class="label">Date Taken</span>
|
||||
<span class="value"
|
||||
>{new Date(media.exifData.dateTaken).toLocaleDateString()}</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if media.exifData.coordinates}
|
||||
<div class="info-item">
|
||||
<span class="label">GPS</span>
|
||||
<span class="value">
|
||||
{media.exifData.coordinates.latitude.toFixed(6)},
|
||||
{media.exifData.coordinates.longitude.toFixed(6)}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => (showExif = !showExif)}
|
||||
buttonSize="small"
|
||||
fullWidth
|
||||
pill={false}
|
||||
class="exif-toggle"
|
||||
>
|
||||
{showExif ? 'Hide EXIF' : 'Show EXIF'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-footer">
|
||||
<div class="footer-left">
|
||||
<Button variant="ghost" onclick={handleDelete} disabled={isSaving} class="delete-button">
|
||||
Delete
|
||||
</Button>
|
||||
<div class="pane-body-content">
|
||||
<!-- Photography Toggle -->
|
||||
<div class="photography-toggle">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isPhotography}
|
||||
disabled={isSaving}
|
||||
class="toggle-input"
|
||||
/>
|
||||
<div class="toggle-content">
|
||||
<span class="toggle-title">Show in Photos</span>
|
||||
<span class="toggle-description">This photo will be displayed in Photos</span>
|
||||
</div>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
<div class="edit-form">
|
||||
<Textarea
|
||||
label="Description"
|
||||
bind:value={description}
|
||||
placeholder="Describe this image (used for alt text and captions)"
|
||||
rows={4}
|
||||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<!-- Usage Tracking -->
|
||||
<div class="usage-section">
|
||||
<h4>Used In</h4>
|
||||
{#if loadingUsage}
|
||||
<div class="usage-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading usage information...</span>
|
||||
</div>
|
||||
{:else if usage.length > 0}
|
||||
<ul class="usage-list">
|
||||
{#each usage as usageItem}
|
||||
<li class="usage-item">
|
||||
<div class="usage-content">
|
||||
<div class="usage-header">
|
||||
{#if usageItem.contentUrl}
|
||||
<a
|
||||
href={usageItem.contentUrl}
|
||||
class="usage-title"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{usageItem.contentTitle}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="usage-title">{usageItem.contentTitle}</span>
|
||||
{/if}
|
||||
<span class="usage-type">{usageItem.contentType}</span>
|
||||
</div>
|
||||
<div class="usage-details">
|
||||
<span class="usage-field">{usageItem.fieldDisplayName}</span>
|
||||
<span class="usage-date"
|
||||
>Added {new Date(usageItem.createdAt).toLocaleDateString()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="no-usage">This media file is not currently used in any content.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-right">
|
||||
{#if error}
|
||||
<span class="error-text">{error}</span>
|
||||
{/if}
|
||||
{#if successMessage}
|
||||
<span class="success-text">{successMessage}</span>
|
||||
{/if}
|
||||
<!-- Footer -->
|
||||
<div class="pane-footer">
|
||||
<div class="footer-left">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={handleDelete}
|
||||
disabled={isSaving}
|
||||
class="delete-button"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" onclick={handleClose} disabled={isSaving}>Cancel</Button>
|
||||
<Button variant="primary" onclick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
<div class="footer-right">
|
||||
{#if error}
|
||||
<span class="error-text">{error}</span>
|
||||
{/if}
|
||||
{#if successMessage}
|
||||
<span class="success-text">{successMessage}</span>
|
||||
{/if}
|
||||
|
||||
<Button variant="primary" onclick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -406,74 +496,36 @@
|
|||
<style lang="scss">
|
||||
.media-details-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $unit-4x;
|
||||
border-bottom: 1px solid $grey-90;
|
||||
flex-shrink: 0;
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-half 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
.filename {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: $unit-4x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-6x;
|
||||
}
|
||||
|
||||
.media-preview-section {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: $unit-4x;
|
||||
align-items: start;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
grid-template-columns: 1fr;
|
||||
gap: $unit-3x;
|
||||
}
|
||||
}
|
||||
|
||||
.media-preview {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
aspect-ratio: 4/3;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: $grey-95;
|
||||
}
|
||||
|
||||
// Left pane - Image preview
|
||||
.image-pane {
|
||||
flex: 1;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $unit-4x;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
:global(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
.image-container {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:global(.preview-image) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
border-radius: $corner-radius-md;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.file-placeholder {
|
||||
|
|
@ -481,7 +533,7 @@
|
|||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
color: $grey-50;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
|
||||
.file-type {
|
||||
font-size: 0.875rem;
|
||||
|
|
@ -490,55 +542,117 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Right pane - Details
|
||||
.details-pane {
|
||||
width: 400px;
|
||||
background-color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $unit-2x $unit-3x;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
flex-shrink: 0;
|
||||
gap: $unit-2x;
|
||||
|
||||
.filename-header {
|
||||
flex: 1;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
.pane-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pane-body-content {
|
||||
padding: $unit-3x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-6x;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
gap: $unit-3x;
|
||||
padding: $unit-3x;
|
||||
background-color: $grey-90;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.info-row {
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $unit-3x;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
|
||||
&.vertical {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: $grey-30;
|
||||
min-width: 80px;
|
||||
color: $grey-50;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-10;
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.url-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
flex: 1;
|
||||
:global(.btn.btn-ghost.exif-toggle) {
|
||||
margin-top: $unit-2x;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid $grey-70;
|
||||
|
||||
.url-text {
|
||||
color: $grey-10;
|
||||
font-size: 0.875rem;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
}
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
border-color: $grey-70;
|
||||
}
|
||||
}
|
||||
|
||||
.exif-data {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $unit-3x;
|
||||
padding-top: $unit-3x;
|
||||
border-top: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
|
|
@ -551,6 +665,7 @@
|
|||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $unit-3x;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
|
@ -561,7 +676,7 @@
|
|||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:checked + .toggle-slider {
|
||||
&:checked + .toggle-content + .toggle-slider {
|
||||
background-color: $blue-60;
|
||||
|
||||
&::before {
|
||||
|
|
@ -569,7 +684,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
&:disabled + .toggle-slider {
|
||||
&:disabled + .toggle-content + .toggle-slider {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
@ -711,12 +826,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
.pane-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $unit-4x;
|
||||
border-top: 1px solid $grey-90;
|
||||
padding: $unit-2x $unit-3x;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
flex-shrink: 0;
|
||||
|
||||
.footer-left {
|
||||
|
|
@ -756,16 +871,30 @@
|
|||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@include breakpoint('phone') {
|
||||
.modal-header {
|
||||
@media (max-width: 768px) {
|
||||
.media-details-modal {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.image-pane {
|
||||
height: 300px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.details-pane {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pane-header {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: $unit-3x;
|
||||
.pane-body {
|
||||
// padding: $unit-3x;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
.pane-footer {
|
||||
padding: $unit-3x;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import Modal from './Modal.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import LoadingSpinner from './LoadingSpinner.svelte'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
|
|
@ -158,12 +157,77 @@
|
|||
<div class="modal-header">
|
||||
<h2>Upload Media</h2>
|
||||
</div>
|
||||
<!-- Drop Zone -->
|
||||
<div class="modal-inner-content">
|
||||
<!-- File List (shown above drop zone when files are selected) -->
|
||||
{#if files.length > 0}
|
||||
<div class="files">
|
||||
{#each files as file, index}
|
||||
<div class="file-item">
|
||||
<div class="file-preview">
|
||||
{#if file.type.startsWith('image/')}
|
||||
<img src={URL.createObjectURL(file)} alt={file.name} />
|
||||
{:else}
|
||||
<div class="file-icon">📄</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<div class="file-name">{file.name}</div>
|
||||
<div class="file-size">{formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
|
||||
{#if !isUploading}
|
||||
<button
|
||||
type="button"
|
||||
class="remove-button"
|
||||
onclick={() => removeFile(index)}
|
||||
title="Remove file"
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isUploading}
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
style="width: {uploadProgress[file.name] || 0}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="upload-status">
|
||||
{#if uploadProgress[file.name] > 0}
|
||||
<span class="status-uploading"
|
||||
>{Math.round(uploadProgress[file.name] || 0)}%</span
|
||||
>
|
||||
{:else}
|
||||
<span class="status-waiting">Waiting...</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Drop Zone (compact when files are selected) -->
|
||||
<div
|
||||
class="drop-zone"
|
||||
class:active={dragActive}
|
||||
class:has-files={files.length > 0}
|
||||
class:compact={files.length > 0}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
|
|
@ -225,9 +289,35 @@
|
|||
<p>or click to browse and select files</p>
|
||||
<p class="upload-hint">Supports JPG, PNG, GIF, WebP, and SVG files</p>
|
||||
{:else}
|
||||
<div class="file-count">
|
||||
<strong>{files.length} file{files.length !== 1 ? 's' : ''} selected</strong>
|
||||
<p>Drop more files to add them, or click to browse</p>
|
||||
<div class="compact-content">
|
||||
<svg
|
||||
class="add-icon"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="12"
|
||||
y1="5"
|
||||
x2="12"
|
||||
y2="19"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="5"
|
||||
y1="12"
|
||||
x2="19"
|
||||
y2="12"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>Add more files or drop them here</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -250,108 +340,55 @@
|
|||
{dragActive ? 'Drop files' : 'Click to browse'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File List -->
|
||||
{#if files.length > 0}
|
||||
<div class="file-list">
|
||||
<div class="file-list-header">
|
||||
<h3>Files to Upload</h3>
|
||||
<div class="file-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
buttonSize="small"
|
||||
onclick={clearAll}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
buttonSize="small"
|
||||
onclick={uploadFiles}
|
||||
disabled={isUploading || files.length === 0}
|
||||
>
|
||||
{#if isUploading}
|
||||
<LoadingSpinner buttonSize="small" />
|
||||
Uploading...
|
||||
{:else}
|
||||
Upload {files.length} File{files.length !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="files">
|
||||
{#each files as file, index}
|
||||
<div class="file-item">
|
||||
<div class="file-preview">
|
||||
{#if file.type.startsWith('image/')}
|
||||
<img src={URL.createObjectURL(file)} alt={file.name} />
|
||||
{:else}
|
||||
<div class="file-icon">📄</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<div class="file-name">{file.name}</div>
|
||||
<div class="file-size">{formatFileSize(file.size)}</div>
|
||||
|
||||
{#if uploadProgress[file.name]}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {uploadProgress[file.name]}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isUploading}
|
||||
<button
|
||||
type="button"
|
||||
class="remove-button"
|
||||
onclick={() => removeFile(index)}
|
||||
title="Remove file"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Upload Results -->
|
||||
{#if successCount > 0 || uploadErrors.length > 0}
|
||||
<div class="upload-results">
|
||||
{#if successCount > 0}
|
||||
<div class="success-message">
|
||||
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
|
||||
{#if successCount === files.length && uploadErrors.length === 0}
|
||||
<br /><small>Closing modal...</small>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if uploadErrors.length > 0}
|
||||
<div class="error-messages">
|
||||
<h4>Upload Errors:</h4>
|
||||
{#each uploadErrors as error}
|
||||
<div class="error-item">❌ {error}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Upload Results -->
|
||||
{#if successCount > 0 || uploadErrors.length > 0}
|
||||
<div class="upload-results">
|
||||
{#if successCount > 0}
|
||||
<div class="success-message">
|
||||
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
|
||||
{#if successCount === files.length && uploadErrors.length === 0}
|
||||
<br /><small>Closing modal...</small>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if uploadErrors.length > 0}
|
||||
<div class="error-messages">
|
||||
<h4>Upload Errors:</h4>
|
||||
{#each uploadErrors as error}
|
||||
<div class="error-item">❌ {error}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Modal Footer with actions -->
|
||||
<div class="modal-footer">
|
||||
<Button
|
||||
variant="secondary"
|
||||
buttonSize="medium"
|
||||
onclick={clearAll}
|
||||
disabled={isUploading || files.length === 0}
|
||||
>
|
||||
Clear all
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
buttonSize="medium"
|
||||
onclick={uploadFiles}
|
||||
disabled={isUploading || files.length === 0}
|
||||
loading={isUploading}
|
||||
>
|
||||
{isUploading
|
||||
? 'Uploading...'
|
||||
: files.length > 0
|
||||
? `Upload ${files.length} file${files.length !== 1 ? 's' : ''}`
|
||||
: 'Upload files'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
|
|
@ -359,8 +396,8 @@
|
|||
.upload-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// height: 70vh;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
|
|
@ -378,6 +415,17 @@
|
|||
|
||||
.modal-inner-content {
|
||||
padding: $unit $unit-3x $unit-3x;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $unit-3x;
|
||||
border-top: 1px solid $grey-85;
|
||||
background: $grey-95;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
|
|
@ -398,10 +446,37 @@
|
|||
padding: $unit-4x;
|
||||
}
|
||||
|
||||
&.compact {
|
||||
padding: $unit-3x;
|
||||
min-height: auto;
|
||||
|
||||
.drop-zone-content {
|
||||
.compact-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $unit-2x;
|
||||
color: $grey-40;
|
||||
font-size: 0.875rem;
|
||||
|
||||
.add-icon {
|
||||
color: $grey-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-60;
|
||||
background: $grey-90;
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
border-color: #3b82f6;
|
||||
border-style: solid;
|
||||
background: rgba(59, 130, 246, 0.02);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.drop-zone-content {
|
||||
|
|
@ -455,45 +530,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
.file-list {
|
||||
background: white;
|
||||
border: 1px solid $grey-85;
|
||||
border-radius: $unit-2x;
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $unit-3x;
|
||||
padding-bottom: $unit-2x;
|
||||
border-bottom: 1px solid $grey-85;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
gap: $unit;
|
||||
margin-bottom: $unit-3x;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-3x;
|
||||
padding: $unit-2x;
|
||||
gap: $unit-2x;
|
||||
padding: $unit;
|
||||
background: $grey-95;
|
||||
border-radius: $unit;
|
||||
border-radius: $image-corner-radius;
|
||||
border: 1px solid $grey-85;
|
||||
}
|
||||
|
||||
|
|
@ -535,17 +585,70 @@
|
|||
}
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
display: flex;
|
||||
min-width: 120px;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: $grey-85;
|
||||
border-radius: 2px;
|
||||
flex-grow: 1;
|
||||
height: $unit-2x;
|
||||
background: $grey-100;
|
||||
padding: $unit-half;
|
||||
border-radius: $corner-radius-full;
|
||||
border: 1px solid $grey-85;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-fill {
|
||||
border-radius: $corner-radius-full;
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
background: $red-60;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 30%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
.status-complete {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.status-uploading {
|
||||
color: $red-60;
|
||||
}
|
||||
|
||||
.status-waiting {
|
||||
color: $grey-50;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -608,11 +711,5 @@
|
|||
align-items: flex-start;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,29 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import { onMount } from 'svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Button from './Button.svelte'
|
||||
|
||||
export let isOpen = false
|
||||
export let size: 'small' | 'medium' | 'large' | 'full' = 'medium'
|
||||
export let closeOnBackdrop = true
|
||||
export let closeOnEscape = true
|
||||
export let showCloseButton = true
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
size?: 'small' | 'medium' | 'large' | 'jumbo' | 'full'
|
||||
closeOnBackdrop?: boolean
|
||||
closeOnEscape?: boolean
|
||||
showCloseButton?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let {
|
||||
isOpen = $bindable(),
|
||||
size = 'medium',
|
||||
closeOnBackdrop = true,
|
||||
closeOnEscape = true,
|
||||
showCloseButton = true,
|
||||
onClose
|
||||
}: Props = $props()
|
||||
|
||||
function handleClose() {
|
||||
isOpen = false
|
||||
dispatch('close')
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
|
|
@ -28,6 +38,34 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Effect to handle body scroll locking
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
// Save current scroll position
|
||||
const scrollY = window.scrollY
|
||||
|
||||
// Lock body scroll
|
||||
document.body.style.position = 'fixed'
|
||||
document.body.style.top = `-${scrollY}px`
|
||||
document.body.style.width = '100%'
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
// Restore body scroll
|
||||
const scrollY = document.body.style.top
|
||||
document.body.style.position = ''
|
||||
document.body.style.top = ''
|
||||
document.body.style.width = ''
|
||||
document.body.style.overflow = ''
|
||||
|
||||
// Restore scroll position
|
||||
if (scrollY) {
|
||||
window.scrollTo(0, parseInt(scrollY || '0') * -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
return () => {
|
||||
|
|
@ -35,7 +73,7 @@
|
|||
}
|
||||
})
|
||||
|
||||
$: modalClass = `modal-${size}`
|
||||
let modalClass = $derived(`modal-${size}`)
|
||||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
|
|
@ -118,6 +156,12 @@
|
|||
max-width: 800px;
|
||||
}
|
||||
|
||||
&.modal-jumbo {
|
||||
width: 90vw;
|
||||
max-width: 1400px;
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
&.modal-full {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import AdminByline from './AdminByline.svelte'
|
||||
|
||||
interface Post {
|
||||
|
|
@ -23,15 +24,59 @@
|
|||
|
||||
let { post }: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
edit: { post: Post }
|
||||
togglePublish: { post: Post }
|
||||
delete: { post: Post }
|
||||
}>()
|
||||
|
||||
let isDropdownOpen = $state(false)
|
||||
|
||||
const postTypeLabels: Record<string, string> = {
|
||||
post: 'Post',
|
||||
essay: 'Essay'
|
||||
}
|
||||
|
||||
function handlePostClick() {
|
||||
function handlePostClick(event: MouseEvent) {
|
||||
// Don't navigate if clicking on the dropdown button
|
||||
if ((event.target as HTMLElement).closest('.dropdown-container')) {
|
||||
return
|
||||
}
|
||||
goto(`/admin/posts/${post.id}/edit`)
|
||||
}
|
||||
|
||||
function handleToggleDropdown(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
isDropdownOpen = !isDropdownOpen
|
||||
}
|
||||
|
||||
function handleEdit(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
dispatch('edit', { post })
|
||||
goto(`/admin/posts/${post.id}/edit`)
|
||||
}
|
||||
|
||||
function handleTogglePublish(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
dispatch('togglePublish', { post })
|
||||
isDropdownOpen = false
|
||||
}
|
||||
|
||||
function handleDelete(event: MouseEvent) {
|
||||
event.stopPropagation()
|
||||
dispatch('delete', { post })
|
||||
isDropdownOpen = false
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
function handleCloseDropdowns() {
|
||||
isDropdownOpen = false
|
||||
}
|
||||
|
||||
document.addEventListener('closeDropdowns', handleCloseDropdowns)
|
||||
return () => document.removeEventListener('closeDropdowns', handleCloseDropdowns)
|
||||
})
|
||||
|
||||
function getPostSnippet(post: Post): string {
|
||||
// Try excerpt first
|
||||
if (post.excerpt) {
|
||||
|
|
@ -95,23 +140,52 @@
|
|||
</script>
|
||||
|
||||
<article class="post-item" onclick={handlePostClick}>
|
||||
{#if post.title}
|
||||
<h3 class="post-title">{post.title}</h3>
|
||||
{/if}
|
||||
<div class="post-main">
|
||||
{#if post.title}
|
||||
<h3 class="post-title">{post.title}</h3>
|
||||
{/if}
|
||||
|
||||
<div class="post-content">
|
||||
<p class="post-preview">{getPostSnippet(post)}</p>
|
||||
<div class="post-content">
|
||||
<p class="post-preview">{getPostSnippet(post)}</p>
|
||||
</div>
|
||||
|
||||
<AdminByline
|
||||
sections={[
|
||||
postTypeLabels[post.postType] || post.postType,
|
||||
post.status === 'published' ? 'Published' : 'Draft',
|
||||
post.status === 'published' && post.publishedAt
|
||||
? `published ${formatDate(post.publishedAt)}`
|
||||
: `created ${formatDate(post.createdAt)}`
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminByline
|
||||
sections={[
|
||||
postTypeLabels[post.postType] || post.postType,
|
||||
post.status === 'published' ? 'Published' : 'Draft',
|
||||
post.status === 'published' && post.publishedAt
|
||||
? `published ${formatDate(post.publishedAt)}`
|
||||
: `created ${formatDate(post.createdAt)}`
|
||||
]}
|
||||
/>
|
||||
<div class="dropdown-container">
|
||||
<button class="action-button" onclick={handleToggleDropdown} aria-label="Post actions">
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="10" cy="4" r="1.5" fill="currentColor" />
|
||||
<circle cx="10" cy="10" r="1.5" fill="currentColor" />
|
||||
<circle cx="10" cy="16" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if isDropdownOpen}
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" onclick={handleEdit}>Edit post</button>
|
||||
<button class="dropdown-item" onclick={handleTogglePublish}>
|
||||
{post.status === 'published' ? 'Unpublish' : 'Publish'} post
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item danger" onclick={handleDelete}>Delete post</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
@ -123,7 +197,8 @@
|
|||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: $unit-2x;
|
||||
|
||||
&:hover {
|
||||
|
|
@ -131,6 +206,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
.post-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
|
|
@ -163,6 +246,70 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: $unit;
|
||||
cursor: pointer;
|
||||
color: $grey-30;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: $unit-half;
|
||||
background: white;
|
||||
border: 1px solid $grey-85;
|
||||
border-radius: $unit;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
min-width: 180px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-20;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-95;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
color: $red-60;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background-color: $grey-90;
|
||||
margin: $unit-half 0;
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 768px) {
|
||||
.post-item {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import AdminPage from './AdminPage.svelte'
|
||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import CaseStudyEditor from './CaseStudyEditor.svelte'
|
||||
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
||||
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
||||
import ProjectImagesForm from './ProjectImagesForm.svelte'
|
||||
|
|
@ -236,6 +236,7 @@
|
|||
show: formData.status !== 'password-protected'
|
||||
}
|
||||
]}
|
||||
viewUrl={project?.slug ? `/work/${project.slug}` : undefined}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -271,18 +272,16 @@
|
|||
</div>
|
||||
|
||||
<!-- Case Study Panel -->
|
||||
<div class="panel case-study-wrapper" class:active={activeTab === 'case-study'}>
|
||||
<div class="editor-content">
|
||||
<Editor
|
||||
bind:this={editorRef}
|
||||
bind:data={formData.caseStudyContent}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="Write your case study here..."
|
||||
minHeight={400}
|
||||
autofocus={false}
|
||||
class="case-study-editor"
|
||||
/>
|
||||
</div>
|
||||
<div class="panel panel-case-study" class:active={activeTab === 'case-study'}>
|
||||
<CaseStudyEditor
|
||||
bind:this={editorRef}
|
||||
bind:data={formData.caseStudyContent}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="Write your case study here..."
|
||||
minHeight={400}
|
||||
autofocus={false}
|
||||
mode="default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -314,6 +313,7 @@
|
|||
width: 250px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -411,31 +411,16 @@
|
|||
gap: $unit-6x;
|
||||
}
|
||||
|
||||
.case-study-wrapper {
|
||||
.panel-case-study {
|
||||
background: white;
|
||||
padding: 0;
|
||||
min-height: 80vh;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
height: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
/* The editor component will handle its own padding and scrolling */
|
||||
:global(.case-study-editor) {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@
|
|||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: $unit-3x;
|
||||
padding: $unit-4x;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
status: string
|
||||
show?: boolean
|
||||
}>
|
||||
viewUrl?: string
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -25,7 +26,8 @@
|
|||
disabled = false,
|
||||
isLoading = false,
|
||||
primaryAction,
|
||||
dropdownActions = []
|
||||
dropdownActions = [],
|
||||
viewUrl
|
||||
}: Props = $props()
|
||||
|
||||
let isDropdownOpen = $state(false)
|
||||
|
|
@ -62,6 +64,9 @@
|
|||
const availableActions = $derived(
|
||||
dropdownActions.filter((action) => action.show !== false && action.status !== currentStatus)
|
||||
)
|
||||
|
||||
const showViewInDropdown = $derived(viewUrl && currentStatus === 'published')
|
||||
const hasDropdownContent = $derived(availableActions.length > 0 || showViewInDropdown)
|
||||
</script>
|
||||
|
||||
<div class="status-dropdown">
|
||||
|
|
@ -74,7 +79,7 @@
|
|||
{isLoading ? `${primaryAction.label.replace(/e$/, 'ing')}...` : primaryAction.label}
|
||||
</Button>
|
||||
|
||||
{#if availableActions.length > 0}
|
||||
{#if hasDropdownContent}
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
|
|
@ -100,6 +105,19 @@
|
|||
{action.label}
|
||||
</DropdownItem>
|
||||
{/each}
|
||||
{#if showViewInDropdown}
|
||||
{#if availableActions.length > 0}
|
||||
<div class="dropdown-divider"></div>
|
||||
{/if}
|
||||
<a
|
||||
href={viewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="dropdown-item view-link"
|
||||
>
|
||||
View on site
|
||||
</a>
|
||||
{/if}
|
||||
</DropdownMenuContainer>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
@ -113,4 +131,28 @@
|
|||
display: flex;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background-color: $grey-90;
|
||||
margin: $unit-half 0;
|
||||
}
|
||||
|
||||
.dropdown-item.view-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: $unit-2x $unit-3x;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-20;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-95;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { createEventDispatcher } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import Modal from './Modal.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import CaseStudyEditor from './CaseStudyEditor.svelte'
|
||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Button from './Button.svelte'
|
||||
|
|
@ -288,7 +288,7 @@
|
|||
</div>
|
||||
|
||||
<div class="composer-body">
|
||||
<Editor
|
||||
<CaseStudyEditor
|
||||
bind:this={editorInstance}
|
||||
bind:data={content}
|
||||
onChange={(newContent) => {
|
||||
|
|
@ -296,11 +296,10 @@
|
|||
characterCount = getTextFromContent(newContent)
|
||||
}}
|
||||
placeholder="What's on your mind?"
|
||||
simpleMode={true}
|
||||
autofocus={true}
|
||||
minHeight={80}
|
||||
autofocus={true}
|
||||
mode="inline"
|
||||
showToolbar={false}
|
||||
class="composer-editor"
|
||||
/>
|
||||
|
||||
{#if attachedPhotos.length > 0}
|
||||
|
|
@ -440,7 +439,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class="content-section">
|
||||
<Editor
|
||||
<CaseStudyEditor
|
||||
bind:this={editorInstance}
|
||||
bind:data={content}
|
||||
onChange={(newContent) => {
|
||||
|
|
@ -448,9 +447,9 @@
|
|||
characterCount = getTextFromContent(newContent)
|
||||
}}
|
||||
placeholder="Start writing your essay..."
|
||||
simpleMode={false}
|
||||
autofocus={true}
|
||||
minHeight={500}
|
||||
autofocus={true}
|
||||
mode="default"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -484,7 +483,7 @@
|
|||
</svg>
|
||||
</Button>
|
||||
<div class="composer-body">
|
||||
<Editor
|
||||
<CaseStudyEditor
|
||||
bind:this={editorInstance}
|
||||
bind:data={content}
|
||||
onChange={(newContent) => {
|
||||
|
|
@ -492,11 +491,10 @@
|
|||
characterCount = getTextFromContent(newContent)
|
||||
}}
|
||||
placeholder="What's on your mind?"
|
||||
simpleMode={true}
|
||||
autofocus={true}
|
||||
minHeight={120}
|
||||
autofocus={true}
|
||||
mode="inline"
|
||||
showToolbar={false}
|
||||
class="inline-composer-editor"
|
||||
/>
|
||||
|
||||
{#if attachedPhotos.length > 0}
|
||||
|
|
@ -651,47 +649,6 @@
|
|||
.composer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global(.edra-editor) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.composer-editor) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
:global(.editor-container) {
|
||||
padding: 0 $unit-3x;
|
||||
}
|
||||
|
||||
:global(.editor-content) {
|
||||
padding: 0;
|
||||
min-height: 80px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:global(.ProseMirror) {
|
||||
padding: 0;
|
||||
min-height: 80px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.ProseMirror-focused .is-editor-empty:first-child::before {
|
||||
color: $grey-40;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-fields {
|
||||
|
|
@ -790,10 +747,6 @@
|
|||
.composer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global(.edra-editor) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -811,44 +764,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
:global(.inline-composer-editor) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
|
||||
:global(.editor-container) {
|
||||
padding: $unit * 1.5 $unit-3x 0;
|
||||
}
|
||||
|
||||
:global(.editor-content) {
|
||||
padding: 0;
|
||||
min-height: 120px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:global(.ProseMirror) {
|
||||
padding: 0;
|
||||
min-height: 120px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.ProseMirror-focused .is-editor-empty:first-child::before {
|
||||
color: $grey-40;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inline-composer .link-fields {
|
||||
padding: 0 $unit-3x;
|
||||
display: flex;
|
||||
|
|
|
|||
30
src/lib/stores/mouse.ts
Normal file
30
src/lib/stores/mouse.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { writable, get } from 'svelte/store'
|
||||
|
||||
// Global mouse position store
|
||||
export const mousePosition = writable({ x: 0, y: 0 })
|
||||
|
||||
// Initialize mouse tracking
|
||||
if (typeof window !== 'undefined') {
|
||||
// Track mouse position globally
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
mousePosition.set({ x: e.clientX, y: e.clientY })
|
||||
})
|
||||
|
||||
// Also capture initial position if mouse is already over window
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Force a mouse event to get initial position
|
||||
const event = new MouseEvent('mousemove', {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
bubbles: true
|
||||
})
|
||||
|
||||
// If the mouse is already over the document, this will update
|
||||
document.dispatchEvent(event)
|
||||
})
|
||||
}
|
||||
|
||||
// Helper function to get current mouse position
|
||||
export function getCurrentMousePosition() {
|
||||
return get(mousePosition)
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ export interface Photo {
|
|||
width: number
|
||||
height: number
|
||||
exif?: ExifData
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export interface PhotoAlbum {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,28 @@
|
|||
<script lang="ts">
|
||||
import '../app.css'
|
||||
import { page } from '$app/stores'
|
||||
import Header from '$components/Header.svelte'
|
||||
import Footer from '$components/Footer.svelte'
|
||||
import { generatePersonJsonLd } from '$lib/utils/metadata'
|
||||
|
||||
let { children } = $props()
|
||||
|
||||
const isAdminRoute = $derived($page.url.pathname.startsWith('/admin'))
|
||||
|
||||
// Generate person structured data for the site
|
||||
const personJsonLd = $derived(generatePersonJsonLd({
|
||||
name: 'Justin Edmund',
|
||||
jobTitle: 'Software Designer',
|
||||
description: 'Software designer based in San Francisco',
|
||||
url: 'https://jedmund.com',
|
||||
sameAs: [
|
||||
'https://twitter.com/jedmund',
|
||||
'https://github.com/jedmund',
|
||||
'https://www.linkedin.com/in/jedmund'
|
||||
]
|
||||
}))
|
||||
const personJsonLd = $derived(
|
||||
generatePersonJsonLd({
|
||||
name: 'Justin Edmund',
|
||||
jobTitle: 'Software Designer',
|
||||
description: 'Software designer based in San Francisco',
|
||||
url: 'https://jedmund.com',
|
||||
sameAs: [
|
||||
'https://twitter.com/jedmund',
|
||||
'https://github.com/jedmund',
|
||||
'https://www.linkedin.com/in/jedmund'
|
||||
]
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -31,7 +36,7 @@
|
|||
{/if}
|
||||
|
||||
<main class:admin-route={isAdminRoute}>
|
||||
<slot />
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
{#if !isAdminRoute}
|
||||
|
|
@ -40,9 +45,6 @@
|
|||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '../assets/styles/reset.css';
|
||||
@import '../assets/styles/globals.scss';
|
||||
|
||||
:global(html) {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
|
||||
// Filter state
|
||||
let photographyFilter = $state<string>('all')
|
||||
let sortBy = $state<string>('newest')
|
||||
|
||||
// Filter options
|
||||
const filterOptions = [
|
||||
|
|
@ -57,6 +58,17 @@
|
|||
{ value: 'false', label: 'Regular albums' }
|
||||
]
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'newest', label: 'Newest first' },
|
||||
{ value: 'oldest', label: 'Oldest first' },
|
||||
{ value: 'title-asc', label: 'Title (A-Z)' },
|
||||
{ value: 'title-desc', label: 'Title (Z-A)' },
|
||||
{ value: 'date-desc', label: 'Date (newest)' },
|
||||
{ value: 'date-asc', label: 'Date (oldest)' },
|
||||
{ value: 'status-published', label: 'Published first' },
|
||||
{ value: 'status-draft', label: 'Draft first' }
|
||||
]
|
||||
|
||||
onMount(async () => {
|
||||
await loadAlbums()
|
||||
// Close dropdown when clicking outside
|
||||
|
|
@ -103,8 +115,8 @@
|
|||
}
|
||||
albumTypeCounts = counts
|
||||
|
||||
// Apply initial filter
|
||||
applyFilter()
|
||||
// Apply initial filter and sort
|
||||
applyFilterAndSort()
|
||||
} catch (err) {
|
||||
error = 'Failed to load albums'
|
||||
console.error(err)
|
||||
|
|
@ -113,14 +125,62 @@
|
|||
}
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
if (photographyFilter === 'all') {
|
||||
filteredAlbums = albums
|
||||
} else if (photographyFilter === 'true') {
|
||||
filteredAlbums = albums.filter((album) => album.isPhotography === true)
|
||||
function applyFilterAndSort() {
|
||||
let filtered = [...albums]
|
||||
|
||||
// Apply filter
|
||||
if (photographyFilter === 'true') {
|
||||
filtered = filtered.filter((album) => album.isPhotography === true)
|
||||
} else if (photographyFilter === 'false') {
|
||||
filteredAlbums = albums.filter((album) => album.isPhotography === false)
|
||||
filtered = filtered.filter((album) => album.isPhotography === false)
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
switch (sortBy) {
|
||||
case 'oldest':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
break
|
||||
case 'title-asc':
|
||||
filtered.sort((a, b) => a.title.localeCompare(b.title))
|
||||
break
|
||||
case 'title-desc':
|
||||
filtered.sort((a, b) => b.title.localeCompare(a.title))
|
||||
break
|
||||
case 'date-desc':
|
||||
filtered.sort((a, b) => {
|
||||
if (!a.date && !b.date) return 0
|
||||
if (!a.date) return 1
|
||||
if (!b.date) return -1
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime()
|
||||
})
|
||||
break
|
||||
case 'date-asc':
|
||||
filtered.sort((a, b) => {
|
||||
if (!a.date && !b.date) return 0
|
||||
if (!a.date) return 1
|
||||
if (!b.date) return -1
|
||||
return new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
})
|
||||
break
|
||||
case 'status-published':
|
||||
filtered.sort((a, b) => {
|
||||
if (a.status === b.status) return 0
|
||||
return a.status === 'published' ? -1 : 1
|
||||
})
|
||||
break
|
||||
case 'status-draft':
|
||||
filtered.sort((a, b) => {
|
||||
if (a.status === b.status) return 0
|
||||
return a.status === 'draft' ? -1 : 1
|
||||
})
|
||||
break
|
||||
case 'newest':
|
||||
default:
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
break
|
||||
}
|
||||
|
||||
filteredAlbums = filtered
|
||||
}
|
||||
|
||||
function handleToggleDropdown(event: CustomEvent<{ albumId: number; event: MouseEvent }>) {
|
||||
|
|
@ -180,9 +240,13 @@
|
|||
|
||||
if (response.ok) {
|
||||
await loadAlbums()
|
||||
} else {
|
||||
const errorData = await response.json()
|
||||
error = errorData.error || 'Failed to delete album'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete album:', err)
|
||||
error = 'Failed to delete album. Please try again.'
|
||||
} finally {
|
||||
showDeleteModal = false
|
||||
albumToDelete = null
|
||||
|
|
@ -195,7 +259,11 @@
|
|||
}
|
||||
|
||||
function handleFilterChange() {
|
||||
applyFilter()
|
||||
applyFilterAndSort()
|
||||
}
|
||||
|
||||
function handleSortChange() {
|
||||
applyFilterAndSort()
|
||||
}
|
||||
|
||||
function handleNewAlbum() {
|
||||
|
|
@ -219,11 +287,20 @@
|
|||
<Select
|
||||
bind:value={photographyFilter}
|
||||
options={filterOptions}
|
||||
buttonSize="small"
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={handleFilterChange}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet right()}
|
||||
<Select
|
||||
bind:value={sortBy}
|
||||
options={sortOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={handleSortChange}
|
||||
/>
|
||||
{/snippet}
|
||||
</AdminFilters>
|
||||
|
||||
<!-- Albums List -->
|
||||
|
|
@ -249,10 +326,10 @@
|
|||
<AlbumListItem
|
||||
{album}
|
||||
isDropdownActive={activeDropdown === album.id}
|
||||
ontoggleDropdown={handleToggleDropdown}
|
||||
onedit={handleEdit}
|
||||
ontogglePublish={handleTogglePublish}
|
||||
ondelete={handleDelete}
|
||||
on:toggleDropdown={handleToggleDropdown}
|
||||
on:edit={handleEdit}
|
||||
on:togglePublish={handleTogglePublish}
|
||||
on:delete={handleDelete}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -264,7 +341,7 @@
|
|||
bind:isOpen={showDeleteModal}
|
||||
title="Delete album?"
|
||||
message={albumToDelete
|
||||
? `Are you sure you want to delete "${albumToDelete.title}"? This action cannot be undone.`
|
||||
? `Are you sure you want to delete "${albumToDelete.title}"? The album will be deleted but all photos will remain in your media library. This action cannot be undone.`
|
||||
: ''}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={cancelDelete}
|
||||
|
|
@ -286,7 +363,7 @@
|
|||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid $grey-80;
|
||||
border-top-color: $primary-color;
|
||||
border-top-color: $grey-40;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto $unit-2x;
|
||||
animation: spin 0.8s linear infinite;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import GalleryUploader from '$lib/components/admin/GalleryUploader.svelte'
|
||||
import StatusDropdown from '$lib/components/admin/StatusDropdown.svelte'
|
||||
import AlbumMetadataPopover from '$lib/components/admin/AlbumMetadataPopover.svelte'
|
||||
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||
|
||||
// Form state
|
||||
let album = $state<any>(null)
|
||||
|
|
@ -28,6 +29,7 @@
|
|||
let isLoading = $state(true)
|
||||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
let showDeleteModal = $state(false)
|
||||
|
||||
// Photo management state
|
||||
let isMediaLibraryOpen = $state(false)
|
||||
|
|
@ -153,7 +155,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
function handleDelete() {
|
||||
showDeleteModal = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
|
|
@ -175,9 +181,15 @@
|
|||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to delete album'
|
||||
console.error('Failed to delete album:', err)
|
||||
} finally {
|
||||
showDeleteModal = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
showDeleteModal = false
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
goto('/admin/albums')
|
||||
}
|
||||
|
|
@ -380,6 +392,24 @@
|
|||
|
||||
async function handlePhotoReorder(reorderedPhotos: any[]) {
|
||||
try {
|
||||
console.log('[Album Edit] handlePhotoReorder called:', {
|
||||
reorderedCount: reorderedPhotos.length,
|
||||
photos: reorderedPhotos.map((p, i) => ({
|
||||
index: i,
|
||||
id: p.id,
|
||||
mediaId: p.mediaId,
|
||||
filename: p.filename
|
||||
}))
|
||||
})
|
||||
|
||||
// Prevent concurrent reordering
|
||||
if (isManagingPhotos) {
|
||||
console.warn('[Album Edit] Skipping reorder - another operation in progress')
|
||||
return
|
||||
}
|
||||
|
||||
isManagingPhotos = true
|
||||
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
|
|
@ -403,11 +433,17 @@
|
|||
|
||||
await Promise.all(updatePromises)
|
||||
|
||||
// Update local state
|
||||
albumPhotos = reorderedPhotos
|
||||
// Update local state only after successful API calls
|
||||
albumPhotos = [...reorderedPhotos]
|
||||
|
||||
console.log('[Album Edit] Reorder completed successfully')
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to reorder photos'
|
||||
console.error('Failed to reorder photos:', err)
|
||||
// Revert to original order on error
|
||||
albumPhotos = [...albumPhotos]
|
||||
} finally {
|
||||
isManagingPhotos = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -457,20 +493,21 @@
|
|||
// Handle new photos added through GalleryUploader (uploads or library selections)
|
||||
async function handleGalleryAdd(newPhotos: any[]) {
|
||||
try {
|
||||
console.log('[Album Edit] handleGalleryAdd called:', {
|
||||
newPhotosCount: newPhotos.length,
|
||||
newPhotos: newPhotos.map((p) => ({
|
||||
id: p.id,
|
||||
mediaId: p.mediaId,
|
||||
filename: p.filename,
|
||||
isFile: p instanceof File
|
||||
})),
|
||||
currentPhotosCount: albumPhotos.length
|
||||
})
|
||||
|
||||
if (newPhotos.length > 0) {
|
||||
// Check if these are new uploads (have File objects) or library selections (have media IDs)
|
||||
const uploadsToAdd = newPhotos.filter((photo) => photo instanceof File || !photo.id)
|
||||
const libraryPhotosToAdd = newPhotos.filter((photo) => photo.id && !(photo instanceof File))
|
||||
|
||||
// Handle new uploads
|
||||
if (uploadsToAdd.length > 0) {
|
||||
await handleAddPhotosFromUpload(uploadsToAdd)
|
||||
}
|
||||
|
||||
// Handle library selections
|
||||
if (libraryPhotosToAdd.length > 0) {
|
||||
await handleAddPhotos(libraryPhotosToAdd)
|
||||
}
|
||||
// All items from GalleryUploader should be media objects, not Files
|
||||
// They either come from uploads (already processed to Media) or library selections
|
||||
await handleAddPhotos(newPhotos)
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to add photos'
|
||||
|
|
@ -603,6 +640,7 @@
|
|||
? { label: 'Save', status: 'published' }
|
||||
: { label: 'Publish', status: 'published' }}
|
||||
dropdownActions={[{ label: 'Save as Draft', status: 'draft', show: status !== 'draft' }]}
|
||||
viewUrl={slug ? `/photos/${slug}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -658,7 +696,10 @@
|
|||
onRemove={handleGalleryRemove}
|
||||
showBrowseLibrary={true}
|
||||
placeholder="Add photos to this album by uploading or selecting from your media library"
|
||||
helpText="Drag photos to reorder them. Click on photos to edit metadata."
|
||||
helpText={isManagingPhotos
|
||||
? 'Processing photos...'
|
||||
: 'Drag photos to reorder them. Click on photos to edit metadata.'}
|
||||
disabled={isManagingPhotos}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -701,6 +742,17 @@
|
|||
onUpdate={handleMediaUpdate}
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<DeleteConfirmationModal
|
||||
bind:isOpen={showDeleteModal}
|
||||
title="Delete album?"
|
||||
message={album
|
||||
? `Are you sure you want to delete "${album.title}"? The album will be deleted but all photos will remain in your media library. This action cannot be undone.`
|
||||
: ''}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={cancelDelete}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
|
|
@ -768,23 +820,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-size: 0.925rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
let currentPage = $state(1)
|
||||
let totalPages = $state(1)
|
||||
let total = $state(0)
|
||||
let viewMode = $state<'grid' | 'list'>('grid')
|
||||
// Only using grid view
|
||||
|
||||
// Filter states
|
||||
let filterType = $state<string>('all')
|
||||
|
|
@ -324,18 +324,9 @@
|
|||
onclick={toggleMultiSelectMode}
|
||||
class={isMultiSelectMode ? 'active' : ''}
|
||||
>
|
||||
{isMultiSelectMode ? '✓' : '☐'}
|
||||
{isMultiSelectMode ? 'Exit Select' : 'Select'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
buttonSize="large"
|
||||
onclick={() => (viewMode = viewMode === 'grid' ? 'list' : 'grid')}
|
||||
>
|
||||
{viewMode === 'grid' ? '📋' : '🖼️'}
|
||||
{viewMode === 'grid' ? 'List' : 'Grid'}
|
||||
</Button>
|
||||
<Button variant="primary" buttonSize="large" onclick={openUploadModal}>Upload...</Button>
|
||||
<Button variant="primary" buttonSize="large" onclick={openUploadModal}>Upload</Button>
|
||||
{/snippet}
|
||||
</AdminHeader>
|
||||
|
||||
|
|
@ -348,14 +339,14 @@
|
|||
<Select
|
||||
bind:value={filterType}
|
||||
options={typeFilterOptions}
|
||||
buttonSize="small"
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={handleFilterChange}
|
||||
/>
|
||||
<Select
|
||||
bind:value={photographyFilter}
|
||||
options={photographyFilterOptions}
|
||||
buttonSize="small"
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={handleFilterChange}
|
||||
/>
|
||||
|
|
@ -414,14 +405,14 @@
|
|||
class="btn btn-secondary btn-small"
|
||||
title="Mark selected items as photography"
|
||||
>
|
||||
📸 Mark Photography
|
||||
Mark Photography
|
||||
</button>
|
||||
<button
|
||||
onclick={handleBulkUnmarkPhotography}
|
||||
class="btn btn-secondary btn-small"
|
||||
title="Remove photography status from selected items"
|
||||
>
|
||||
🚫 Remove Photography
|
||||
Remove Photography
|
||||
</button>
|
||||
<button
|
||||
onclick={handleBulkDelete}
|
||||
|
|
@ -444,7 +435,7 @@
|
|||
<p>No media files found.</p>
|
||||
<Button variant="primary" onclick={openUploadModal}>Upload your first file</Button>
|
||||
</div>
|
||||
{:else if viewMode === 'grid'}
|
||||
{:else}
|
||||
<div class="media-grid">
|
||||
{#each media as item}
|
||||
<div class="media-item-wrapper" class:multiselect={isMultiSelectMode}>
|
||||
|
|
@ -470,7 +461,7 @@
|
|||
{#if item.mimeType.startsWith('image/')}
|
||||
<img
|
||||
src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
|
||||
alt={item.altText || item.filename}
|
||||
alt={item.description || item.filename}
|
||||
/>
|
||||
{:else}
|
||||
<div class="file-placeholder">
|
||||
|
|
@ -479,135 +470,23 @@
|
|||
{/if}
|
||||
<div class="media-info">
|
||||
<span class="filename">{item.filename}</span>
|
||||
<div class="media-indicators">
|
||||
{#if item.isPhotography}
|
||||
<span class="indicator-pill photography" title="Photography">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon
|
||||
points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Photo
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.altText}
|
||||
<span class="indicator-pill alt-text" title="Alt text: {item.altText}">
|
||||
Alt
|
||||
</span>
|
||||
{:else}
|
||||
<span class="indicator-pill no-alt-text" title="No alt text"> No Alt </span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="filesize">{formatFileSize(item.size)}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="media-list">
|
||||
{#each media as item}
|
||||
<div class="media-row-wrapper" class:multiselect={isMultiSelectMode}>
|
||||
{#if isMultiSelectMode}
|
||||
<div class="selection-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMediaIds.has(item.id)}
|
||||
onchange={() => toggleMediaSelection(item.id)}
|
||||
id="media-row-{item.id}"
|
||||
/>
|
||||
<label for="media-row-{item.id}" class="checkbox-label"></label>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="media-row"
|
||||
type="button"
|
||||
onclick={() =>
|
||||
isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)}
|
||||
title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}"
|
||||
class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
|
||||
>
|
||||
<div class="media-preview">
|
||||
{#if item.mimeType.startsWith('image/')}
|
||||
<img
|
||||
src={item.mimeType === 'image/svg+xml'
|
||||
? item.url
|
||||
: item.thumbnailUrl || item.url}
|
||||
alt={item.altText || item.filename}
|
||||
/>
|
||||
{:else}
|
||||
<div class="file-icon">{getFileType(item.mimeType)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="media-details">
|
||||
<div class="filename-row">
|
||||
<span class="filename">{item.filename}</span>
|
||||
<div class="media-info-bottom">
|
||||
<div class="media-indicators">
|
||||
{#if item.isPhotography}
|
||||
<span class="indicator-pill photography" title="Photography">
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polygon
|
||||
points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
Photo
|
||||
</span>
|
||||
<span class="indicator-pill photography" title="Photography"> Photo </span>
|
||||
{/if}
|
||||
{#if item.altText}
|
||||
<span class="indicator-pill alt-text" title="Alt text: {item.altText}">
|
||||
{#if item.description}
|
||||
<span class="indicator-pill alt-text" title="Description: {item.description}">
|
||||
Alt
|
||||
</span>
|
||||
{:else}
|
||||
<span class="indicator-pill no-alt-text" title="No alt text"> No Alt </span>
|
||||
<span class="indicator-pill no-alt-text" title="No description">
|
||||
No Alt
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="filesize">{formatFileSize(item.size)}</span>
|
||||
</div>
|
||||
<span class="file-meta">
|
||||
{getFileType(item.mimeType)} • {formatFileSize(item.size)}
|
||||
{#if item.width && item.height}
|
||||
• {item.width}×{item.height}px
|
||||
{/if}
|
||||
</span>
|
||||
{#if item.altText}
|
||||
<span class="alt-text-preview">
|
||||
Alt: {item.altText}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="no-alt-text-preview">No alt text</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="media-indicator">
|
||||
{#if !isMultiSelectMode}
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9 18L15 12L9 6"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -708,14 +587,15 @@
|
|||
|
||||
.media-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: $unit-3x;
|
||||
margin-bottom: $unit-4x;
|
||||
padding: 0 $unit;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
background: $grey-95;
|
||||
border: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: $unit-2x;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
|
@ -728,6 +608,7 @@
|
|||
background-color: $grey-90;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
|
|
@ -759,11 +640,12 @@
|
|||
padding: $unit-2x;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
gap: $unit;
|
||||
|
||||
.filename {
|
||||
font-size: 0.875rem;
|
||||
font-size: 1rem;
|
||||
color: $grey-20;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
|
@ -774,6 +656,13 @@
|
|||
color: $grey-40;
|
||||
}
|
||||
|
||||
.media-info-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.media-indicators {
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
|
|
@ -1067,12 +956,10 @@
|
|||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
padding: 2px $unit;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
padding: $unit-half $unit;
|
||||
border-radius: $corner-radius-2xl;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1;
|
||||
|
||||
svg {
|
||||
|
|
@ -1084,7 +971,6 @@
|
|||
&.photography {
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
color: #7c3aed;
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
|
||||
svg {
|
||||
fill: #7c3aed;
|
||||
|
|
@ -1094,13 +980,11 @@
|
|||
&.alt-text {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
&.no-alt-text {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
import { goto } from '$app/navigation'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
let files = $state<File[]>([])
|
||||
|
|
@ -147,11 +146,116 @@
|
|||
</header>
|
||||
|
||||
<div class="upload-container">
|
||||
<!-- File List -->
|
||||
{#if files.length > 0}
|
||||
<div class="file-list">
|
||||
<div class="file-list-header">
|
||||
<h3>Files to Upload</h3>
|
||||
<div class="file-actions">
|
||||
<Button
|
||||
variant="primary"
|
||||
buttonSize="small"
|
||||
onclick={uploadFiles}
|
||||
disabled={isUploading || files.length === 0}
|
||||
loading={isUploading}
|
||||
>
|
||||
{isUploading
|
||||
? 'Uploading...'
|
||||
: `Upload ${files.length} File${files.length !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
buttonSize="icon"
|
||||
onclick={clearAll}
|
||||
disabled={isUploading}
|
||||
title="Clear all files"
|
||||
>
|
||||
<svg
|
||||
slot="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="8" y1="8" x2="16" y2="16"></line>
|
||||
<line x1="16" y1="8" x2="8" y2="16"></line>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="files">
|
||||
{#each files as file, index}
|
||||
<div class="file-item">
|
||||
<div class="file-preview">
|
||||
{#if file.type.startsWith('image/')}
|
||||
<img src={URL.createObjectURL(file)} alt={file.name} />
|
||||
{:else}
|
||||
<div class="file-icon">📄</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<div class="file-name">{file.name}</div>
|
||||
<div class="file-size">{formatFileSize(file.size)}</div>
|
||||
|
||||
{#if isUploading}
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
style="width: {uploadProgress[file.name] || 0}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="upload-status">
|
||||
{#if uploadProgress[file.name] === 100}
|
||||
<span class="status-complete">✓ Complete</span>
|
||||
{:else if uploadProgress[file.name] > 0}
|
||||
<span class="status-uploading"
|
||||
>{Math.round(uploadProgress[file.name] || 0)}%</span
|
||||
>
|
||||
{:else}
|
||||
<span class="status-waiting">Waiting...</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isUploading}
|
||||
<button
|
||||
type="button"
|
||||
class="remove-button"
|
||||
onclick={() => removeFile(index)}
|
||||
title="Remove file"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Drop Zone -->
|
||||
<div
|
||||
class="drop-zone"
|
||||
class:active={dragActive}
|
||||
class:has-files={files.length > 0}
|
||||
class:compact={files.length > 0}
|
||||
class:uploading={isUploading}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
|
|
@ -213,9 +317,35 @@
|
|||
<p>or click to browse and select files</p>
|
||||
<p class="upload-hint">Supports JPG, PNG, GIF, WebP, and SVG files</p>
|
||||
{:else}
|
||||
<div class="file-count">
|
||||
<strong>{files.length} file{files.length !== 1 ? 's' : ''} selected</strong>
|
||||
<p>Drop more files to add them, or click to browse</p>
|
||||
<div class="compact-content">
|
||||
<svg
|
||||
class="add-icon"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="12"
|
||||
y1="5"
|
||||
x2="12"
|
||||
y2="19"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="5"
|
||||
y1="12"
|
||||
x2="19"
|
||||
y2="12"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>Add more files or drop them here</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -239,84 +369,6 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- File List -->
|
||||
{#if files.length > 0}
|
||||
<div class="file-list">
|
||||
<div class="file-list-header">
|
||||
<h3>Files to Upload</h3>
|
||||
<div class="file-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
buttonSize="small"
|
||||
onclick={clearAll}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
buttonSize="small"
|
||||
onclick={uploadFiles}
|
||||
disabled={isUploading || files.length === 0}
|
||||
>
|
||||
{#if isUploading}
|
||||
<LoadingSpinner buttonSize="small" />
|
||||
Uploading...
|
||||
{:else}
|
||||
Upload {files.length} File{files.length !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="files">
|
||||
{#each files as file, index}
|
||||
<div class="file-item">
|
||||
<div class="file-preview">
|
||||
{#if file.type.startsWith('image/')}
|
||||
<img src={URL.createObjectURL(file)} alt={file.name} />
|
||||
{:else}
|
||||
<div class="file-icon">📄</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<div class="file-name">{file.name}</div>
|
||||
<div class="file-size">{formatFileSize(file.size)}</div>
|
||||
|
||||
{#if uploadProgress[file.name]}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {uploadProgress[file.name]}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isUploading}
|
||||
<button
|
||||
type="button"
|
||||
class="remove-button"
|
||||
onclick={() => removeFile(index)}
|
||||
title="Remove file"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Results -->
|
||||
{#if successCount > 0 || uploadErrors.length > 0}
|
||||
<div class="upload-results">
|
||||
|
|
@ -373,10 +425,37 @@
|
|||
padding: $unit-4x;
|
||||
}
|
||||
|
||||
&.compact {
|
||||
padding: $unit-3x;
|
||||
min-height: auto;
|
||||
|
||||
.drop-zone-content {
|
||||
.compact-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $unit-2x;
|
||||
color: $grey-40;
|
||||
font-size: 0.875rem;
|
||||
|
||||
.add-icon {
|
||||
color: $grey-50;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-60;
|
||||
background: $grey-90;
|
||||
}
|
||||
|
||||
&.uploading {
|
||||
border-color: #3b82f6;
|
||||
border-style: solid;
|
||||
background: rgba(59, 130, 246, 0.02);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.drop-zone-content {
|
||||
|
|
@ -402,13 +481,6 @@
|
|||
font-size: 0.875rem;
|
||||
color: $grey-50;
|
||||
}
|
||||
|
||||
.file-count {
|
||||
strong {
|
||||
color: $grey-20;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
|
|
@ -435,7 +507,7 @@
|
|||
border: 1px solid $grey-85;
|
||||
border-radius: $unit-2x;
|
||||
padding: $unit-3x;
|
||||
margin-bottom: $unit-4x;
|
||||
margin-bottom: $unit-3x;
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
|
|
@ -454,6 +526,7 @@
|
|||
.file-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -513,15 +586,59 @@
|
|||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: $grey-85;
|
||||
border-radius: 2px;
|
||||
height: 6px;
|
||||
background: $grey-90;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: $unit-half;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 30%,
|
||||
rgba(255, 255, 255, 0.2) 50%,
|
||||
transparent 70%
|
||||
);
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
.status-complete {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.status-uploading {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.status-waiting {
|
||||
color: $grey-50;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||
import Select from '$lib/components/admin/Select.svelte'
|
||||
import UniverseComposer from '$lib/components/admin/UniverseComposer.svelte'
|
||||
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
|
||||
interface Post {
|
||||
id: number
|
||||
|
|
@ -35,11 +37,16 @@
|
|||
// Filter state
|
||||
let selectedTypeFilter = $state<string>('all')
|
||||
let selectedStatusFilter = $state<string>('all')
|
||||
let sortBy = $state<string>('newest')
|
||||
|
||||
// Composer state
|
||||
let showInlineComposer = $state(true)
|
||||
let isInteractingWithFilters = $state(false)
|
||||
|
||||
// Delete confirmation state
|
||||
let showDeleteConfirmation = $state(false)
|
||||
let postToDelete = $state<Post | null>(null)
|
||||
|
||||
// Create filter options
|
||||
const typeFilterOptions = $derived([
|
||||
{ value: 'all', label: 'All posts' },
|
||||
|
|
@ -53,6 +60,15 @@
|
|||
{ value: 'draft', label: 'Draft' }
|
||||
])
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'newest', label: 'Newest first' },
|
||||
{ value: 'oldest', label: 'Oldest first' },
|
||||
{ value: 'title-asc', label: 'Title (A-Z)' },
|
||||
{ value: 'title-desc', label: 'Title (Z-A)' },
|
||||
{ value: 'status-published', label: 'Published first' },
|
||||
{ value: 'status-draft', label: 'Draft first' }
|
||||
]
|
||||
|
||||
const postTypeIcons: Record<string, string> = {
|
||||
post: '💭',
|
||||
essay: '📝'
|
||||
|
|
@ -115,8 +131,8 @@
|
|||
}
|
||||
statusCounts = statusCountsTemp
|
||||
|
||||
// Apply initial filter
|
||||
applyFilter()
|
||||
// Apply initial filter and sort
|
||||
applyFilterAndSort()
|
||||
} catch (err) {
|
||||
error = 'Failed to load posts'
|
||||
console.error(err)
|
||||
|
|
@ -125,8 +141,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
let filtered = posts
|
||||
function applyFilterAndSort() {
|
||||
let filtered = [...posts]
|
||||
|
||||
// Apply type filter
|
||||
if (selectedTypeFilter !== 'all') {
|
||||
|
|
@ -138,25 +154,126 @@
|
|||
filtered = filtered.filter((post) => post.status === selectedStatusFilter)
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
switch (sortBy) {
|
||||
case 'oldest':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
break
|
||||
case 'title-asc':
|
||||
filtered.sort((a, b) => (a.title || '').localeCompare(b.title || ''))
|
||||
break
|
||||
case 'title-desc':
|
||||
filtered.sort((a, b) => (b.title || '').localeCompare(a.title || ''))
|
||||
break
|
||||
case 'status-published':
|
||||
filtered.sort((a, b) => {
|
||||
if (a.status === b.status) return 0
|
||||
return a.status === 'published' ? -1 : 1
|
||||
})
|
||||
break
|
||||
case 'status-draft':
|
||||
filtered.sort((a, b) => {
|
||||
if (a.status === b.status) return 0
|
||||
return a.status === 'draft' ? -1 : 1
|
||||
})
|
||||
break
|
||||
case 'newest':
|
||||
default:
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
break
|
||||
}
|
||||
|
||||
filteredPosts = filtered
|
||||
}
|
||||
|
||||
function handleTypeFilterChange() {
|
||||
applyFilter()
|
||||
applyFilterAndSort()
|
||||
}
|
||||
|
||||
function handleStatusFilterChange() {
|
||||
applyFilter()
|
||||
applyFilterAndSort()
|
||||
}
|
||||
|
||||
function handleSortChange() {
|
||||
applyFilterAndSort()
|
||||
}
|
||||
|
||||
function handleComposerSaved() {
|
||||
// Reload posts when a new post is created
|
||||
loadPosts()
|
||||
}
|
||||
|
||||
function handleNewEssay() {
|
||||
goto('/admin/posts/new?type=essay')
|
||||
}
|
||||
|
||||
async function handleTogglePublish(event: CustomEvent<{ post: Post }>) {
|
||||
const { post } = event.detail
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
const newStatus = post.status === 'published' ? 'draft' : 'published'
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/posts/${post.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${auth}`
|
||||
},
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
// Reload posts to refresh the list
|
||||
await loadPosts()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle publish status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeletePost(event: CustomEvent<{ post: Post }>) {
|
||||
postToDelete = event.detail.post
|
||||
showDeleteConfirmation = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!postToDelete) return
|
||||
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/posts/${postToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
showDeleteConfirmation = false
|
||||
postToDelete = null
|
||||
// Reload posts to refresh the list
|
||||
await loadPosts()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete post:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<AdminHeader title="Universe" slot="header" />
|
||||
<AdminHeader title="Universe" slot="header">
|
||||
{#snippet actions()}
|
||||
<Button variant="primary" buttonSize="large" onclick={handleNewEssay}>New Essay</Button>
|
||||
{/snippet}
|
||||
</AdminHeader>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
|
|
@ -192,6 +309,15 @@
|
|||
onchange={handleStatusFilterChange}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet right()}
|
||||
<Select
|
||||
bind:value={sortBy}
|
||||
options={sortOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={handleSortChange}
|
||||
/>
|
||||
{/snippet}
|
||||
</AdminFilters>
|
||||
|
||||
<!-- Posts List -->
|
||||
|
|
@ -215,13 +341,29 @@
|
|||
{:else}
|
||||
<div class="posts-list">
|
||||
{#each filteredPosts as post}
|
||||
<PostListItem {post} />
|
||||
<PostListItem
|
||||
{post}
|
||||
on:togglePublish={handleTogglePublish}
|
||||
on:delete={handleDeletePost}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</AdminPage>
|
||||
|
||||
<DeleteConfirmationModal
|
||||
bind:isOpen={showDeleteConfirmation}
|
||||
title="Delete Post?"
|
||||
message="Are you sure you want to delete this post? This action cannot be undone."
|
||||
confirmText="Delete Post"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => {
|
||||
showDeleteConfirmation = false
|
||||
postToDelete = null
|
||||
}}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { goto } from '$app/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Editor from '$lib/components/admin/Editor.svelte'
|
||||
import CaseStudyEditor from '$lib/components/admin/CaseStudyEditor.svelte'
|
||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
|
||||
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||
|
|
@ -135,7 +135,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
onMount(async () => {
|
||||
// Wait a tick to ensure page params are loaded
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
|
@ -366,6 +365,7 @@
|
|||
? { label: 'Save', status: 'published' }
|
||||
: { label: 'Publish', status: 'published' }}
|
||||
dropdownActions={[{ label: 'Save as Draft', status: 'draft', show: status !== 'draft' }]}
|
||||
viewUrl={slug ? `/universe/${slug}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -390,7 +390,7 @@
|
|||
|
||||
{#if config?.showContent && contentReady}
|
||||
<div class="editor-wrapper">
|
||||
<Editor bind:data={content} placeholder="Continue writing..." />
|
||||
<CaseStudyEditor bind:data={content} placeholder="Continue writing..." mode="default" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -489,28 +489,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-size: 0.925rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.btn-small {
|
||||
padding: $unit $unit-2x;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + $unit);
|
||||
|
|
@ -553,13 +531,13 @@
|
|||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
gap: $unit-2x;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: 0 $unit-2x;
|
||||
padding: 0 $unit-4x;
|
||||
border: none;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { goto } from '$app/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Editor from '$lib/components/admin/Editor.svelte'
|
||||
import CaseStudyEditor from '$lib/components/admin/CaseStudyEditor.svelte'
|
||||
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import PublishDropdown from '$lib/components/admin/PublishDropdown.svelte'
|
||||
|
|
@ -195,7 +195,7 @@
|
|||
|
||||
{#if config?.showContent}
|
||||
<div class="editor-wrapper">
|
||||
<Editor bind:data={content} placeholder="Start writing..." />
|
||||
<CaseStudyEditor bind:data={content} placeholder="Start writing..." mode="default" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -314,13 +314,13 @@
|
|||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
gap: $unit-2x;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: 0 $unit-2x;
|
||||
padding: 0 $unit-4x;
|
||||
border: none;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
// Filter state
|
||||
let selectedTypeFilter = $state<string>('all')
|
||||
let selectedStatusFilter = $state<string>('all')
|
||||
let sortBy = $state<string>('newest')
|
||||
|
||||
// Create filter options
|
||||
const typeFilterOptions = $derived([
|
||||
|
|
@ -50,6 +51,17 @@
|
|||
{ value: 'draft', label: 'Draft' }
|
||||
])
|
||||
|
||||
const sortOptions = [
|
||||
{ value: 'newest', label: 'Newest first' },
|
||||
{ value: 'oldest', label: 'Oldest first' },
|
||||
{ value: 'title-asc', label: 'Title (A-Z)' },
|
||||
{ value: 'title-desc', label: 'Title (Z-A)' },
|
||||
{ value: 'year-desc', label: 'Year (newest)' },
|
||||
{ value: 'year-asc', label: 'Year (oldest)' },
|
||||
{ value: 'status-published', label: 'Published first' },
|
||||
{ value: 'status-draft', label: 'Draft first' }
|
||||
]
|
||||
|
||||
onMount(async () => {
|
||||
await loadProjects()
|
||||
// Handle clicks outside dropdowns
|
||||
|
|
@ -96,8 +108,8 @@
|
|||
}
|
||||
statusCounts = counts
|
||||
|
||||
// Apply initial filter
|
||||
applyFilter()
|
||||
// Apply initial filter and sort
|
||||
applyFilterAndSort()
|
||||
} catch (err) {
|
||||
error = 'Failed to load projects'
|
||||
console.error(err)
|
||||
|
|
@ -166,8 +178,8 @@
|
|||
projectToDelete = null
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
let filtered = projects
|
||||
function applyFilterAndSort() {
|
||||
let filtered = [...projects]
|
||||
|
||||
// Apply status filter
|
||||
if (selectedStatusFilter !== 'all') {
|
||||
|
|
@ -179,15 +191,54 @@
|
|||
filtered = filtered.filter((project) => project.projectType === selectedTypeFilter)
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
switch (sortBy) {
|
||||
case 'oldest':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
break
|
||||
case 'title-asc':
|
||||
filtered.sort((a, b) => a.title.localeCompare(b.title))
|
||||
break
|
||||
case 'title-desc':
|
||||
filtered.sort((a, b) => b.title.localeCompare(a.title))
|
||||
break
|
||||
case 'year-desc':
|
||||
filtered.sort((a, b) => b.year - a.year)
|
||||
break
|
||||
case 'year-asc':
|
||||
filtered.sort((a, b) => a.year - b.year)
|
||||
break
|
||||
case 'status-published':
|
||||
filtered.sort((a, b) => {
|
||||
if (a.status === b.status) return 0
|
||||
return a.status === 'published' ? -1 : 1
|
||||
})
|
||||
break
|
||||
case 'status-draft':
|
||||
filtered.sort((a, b) => {
|
||||
if (a.status === b.status) return 0
|
||||
return a.status === 'draft' ? -1 : 1
|
||||
})
|
||||
break
|
||||
case 'newest':
|
||||
default:
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
break
|
||||
}
|
||||
|
||||
filteredProjects = filtered
|
||||
}
|
||||
|
||||
function handleStatusFilterChange() {
|
||||
applyFilter()
|
||||
applyFilterAndSort()
|
||||
}
|
||||
|
||||
function handleTypeFilterChange() {
|
||||
applyFilter()
|
||||
applyFilterAndSort()
|
||||
}
|
||||
|
||||
function handleSortChange() {
|
||||
applyFilterAndSort()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -219,6 +270,15 @@
|
|||
onchange={handleStatusFilterChange}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet right()}
|
||||
<Select
|
||||
bind:value={sortBy}
|
||||
options={sortOptions}
|
||||
size="small"
|
||||
variant="minimal"
|
||||
onchange={handleSortChange}
|
||||
/>
|
||||
{/snippet}
|
||||
</AdminFilters>
|
||||
|
||||
<!-- Projects List -->
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@ export const GET: RequestHandler = async (event) => {
|
|||
where: { id },
|
||||
include: {
|
||||
photos: {
|
||||
orderBy: { displayOrder: 'asc' }
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
include: {
|
||||
media: true // Include media relation for each photo
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: { photos: true }
|
||||
|
|
@ -32,35 +35,13 @@ export const GET: RequestHandler = async (event) => {
|
|||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
// Get all media usage records for this album's photos in one query
|
||||
const mediaUsages = await prisma.mediaUsage.findMany({
|
||||
where: {
|
||||
contentType: 'album',
|
||||
contentId: album.id,
|
||||
fieldName: 'photos'
|
||||
},
|
||||
include: {
|
||||
media: true
|
||||
}
|
||||
})
|
||||
|
||||
// Create a map of media by mediaId for efficient lookup
|
||||
const mediaMap = new Map()
|
||||
mediaUsages.forEach((usage) => {
|
||||
if (usage.media) {
|
||||
mediaMap.set(usage.mediaId, usage.media)
|
||||
}
|
||||
})
|
||||
|
||||
// Enrich photos with media information using proper media usage tracking
|
||||
// Enrich photos with media information from the included relation
|
||||
const photosWithMedia = album.photos.map((photo) => {
|
||||
// Find the corresponding media usage record for this photo
|
||||
const usage = mediaUsages.find((u) => u.media && u.media.filename === photo.filename)
|
||||
const media = usage?.media
|
||||
const media = photo.media
|
||||
|
||||
return {
|
||||
...photo,
|
||||
mediaId: media?.id || null,
|
||||
// Add media properties for backward compatibility
|
||||
altText: media?.altText || '',
|
||||
description: media?.description || photo.caption || '',
|
||||
isPhotography: media?.isPhotography || false,
|
||||
|
|
@ -184,17 +165,24 @@ export const DELETE: RequestHandler = async (event) => {
|
|||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
// Check if album has photos
|
||||
if (album._count.photos > 0) {
|
||||
return errorResponse('Cannot delete album that contains photos', 409)
|
||||
}
|
||||
// Use a transaction to ensure both operations succeed or fail together
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// First, unlink all photos from this album (set albumId to null)
|
||||
if (album._count.photos > 0) {
|
||||
await tx.photo.updateMany({
|
||||
where: { albumId: id },
|
||||
data: { albumId: null }
|
||||
})
|
||||
logger.info('Unlinked photos from album', { albumId: id, photoCount: album._count.photos })
|
||||
}
|
||||
|
||||
// Delete album
|
||||
await prisma.album.delete({
|
||||
where: { id }
|
||||
// Then delete the album
|
||||
await tx.album.delete({
|
||||
where: { id }
|
||||
})
|
||||
})
|
||||
|
||||
logger.info('Album deleted', { id, slug: album.slug })
|
||||
logger.info('Album deleted', { id, slug: album.slug, photosUnlinked: album._count.photos })
|
||||
|
||||
return new Response(null, { status: 204 })
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
// POST /api/albums/[id]/photos - Add a photo to an album
|
||||
// POST /api/albums/[id]/photos - Add media to an album
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
|
|
@ -53,29 +53,39 @@ export const POST: RequestHandler = async (event) => {
|
|||
return errorResponse('Only images can be added to albums', 400)
|
||||
}
|
||||
|
||||
// Check if media is already in this album
|
||||
const existing = await prisma.albumMedia.findUnique({
|
||||
where: {
|
||||
albumId_mediaId: {
|
||||
albumId: albumId,
|
||||
mediaId: body.mediaId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return errorResponse('Media is already in this album', 409)
|
||||
}
|
||||
|
||||
// Get the next display order if not provided
|
||||
let displayOrder = body.displayOrder
|
||||
if (displayOrder === undefined) {
|
||||
const lastPhoto = await prisma.photo.findFirst({
|
||||
const lastAlbumMedia = await prisma.albumMedia.findFirst({
|
||||
where: { albumId },
|
||||
orderBy: { displayOrder: 'desc' }
|
||||
})
|
||||
displayOrder = (lastPhoto?.displayOrder || 0) + 1
|
||||
displayOrder = (lastAlbumMedia?.displayOrder || 0) + 1
|
||||
}
|
||||
|
||||
// Create photo record from media
|
||||
const photo = await prisma.photo.create({
|
||||
// Create album-media relationship
|
||||
const albumMedia = await prisma.albumMedia.create({
|
||||
data: {
|
||||
albumId,
|
||||
filename: media.filename,
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
caption: media.description, // Use media description as initial caption
|
||||
displayOrder,
|
||||
status: 'published', // Photos in albums are published by default
|
||||
showInPhotos: true
|
||||
mediaId: body.mediaId,
|
||||
displayOrder
|
||||
},
|
||||
include: {
|
||||
media: true
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -89,31 +99,32 @@ export const POST: RequestHandler = async (event) => {
|
|||
}
|
||||
})
|
||||
|
||||
logger.info('Photo added to album', {
|
||||
logger.info('Media added to album', {
|
||||
albumId,
|
||||
photoId: photo.id,
|
||||
mediaId: body.mediaId
|
||||
})
|
||||
|
||||
// Return photo with media information for frontend compatibility
|
||||
const photoWithMedia = {
|
||||
...photo,
|
||||
mediaId: body.mediaId,
|
||||
altText: media.altText,
|
||||
description: media.description,
|
||||
isPhotography: media.isPhotography,
|
||||
mimeType: media.mimeType,
|
||||
size: media.size
|
||||
}
|
||||
|
||||
return jsonResponse(photoWithMedia)
|
||||
// Return media with album context
|
||||
return jsonResponse({
|
||||
id: albumMedia.media.id,
|
||||
mediaId: albumMedia.media.id,
|
||||
filename: albumMedia.media.filename,
|
||||
url: albumMedia.media.url,
|
||||
thumbnailUrl: albumMedia.media.thumbnailUrl,
|
||||
width: albumMedia.media.width,
|
||||
height: albumMedia.media.height,
|
||||
exifData: albumMedia.media.exifData,
|
||||
caption: albumMedia.media.photoCaption || albumMedia.media.description,
|
||||
displayOrder: albumMedia.displayOrder,
|
||||
media: albumMedia.media
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to add photo to album', error as Error)
|
||||
return errorResponse('Failed to add photo to album', 500)
|
||||
logger.error('Failed to add media to album', error as Error)
|
||||
return errorResponse('Failed to add media to album', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/albums/[id]/photos - Update photo order in album
|
||||
// PUT /api/albums/[id]/photos - Update media order in album
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
|
|
@ -127,12 +138,14 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
try {
|
||||
const body = await parseRequestBody<{
|
||||
photoId: number
|
||||
mediaId: number // Changed from photoId for clarity
|
||||
displayOrder: number
|
||||
}>(event.request)
|
||||
|
||||
if (!body || !body.photoId || body.displayOrder === undefined) {
|
||||
return errorResponse('Photo ID and display order are required', 400)
|
||||
// Also support legacy photoId parameter
|
||||
const mediaId = body?.mediaId || (body as any)?.photoId
|
||||
if (!mediaId || body?.displayOrder === undefined) {
|
||||
return errorResponse('Media ID and display order are required', 400)
|
||||
}
|
||||
|
||||
// Check if album exists
|
||||
|
|
@ -144,31 +157,41 @@ export const PUT: RequestHandler = async (event) => {
|
|||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
// Update photo display order
|
||||
const photo = await prisma.photo.update({
|
||||
// Update album-media display order
|
||||
const albumMedia = await prisma.albumMedia.update({
|
||||
where: {
|
||||
id: body.photoId,
|
||||
albumId // Ensure photo belongs to this album
|
||||
albumId_mediaId: {
|
||||
albumId: albumId,
|
||||
mediaId: mediaId
|
||||
}
|
||||
},
|
||||
data: {
|
||||
displayOrder: body.displayOrder
|
||||
},
|
||||
include: {
|
||||
media: true
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Photo order updated', {
|
||||
logger.info('Media order updated', {
|
||||
albumId,
|
||||
photoId: body.photoId,
|
||||
mediaId: mediaId,
|
||||
displayOrder: body.displayOrder
|
||||
})
|
||||
|
||||
return jsonResponse(photo)
|
||||
// Return in photo format for compatibility
|
||||
return jsonResponse({
|
||||
id: albumMedia.media.id,
|
||||
mediaId: albumMedia.media.id,
|
||||
displayOrder: albumMedia.displayOrder
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to update photo order', error as Error)
|
||||
return errorResponse('Failed to update photo order', 500)
|
||||
logger.error('Failed to update media order', error as Error)
|
||||
return errorResponse('Failed to update media order', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/albums/[id]/photos - Remove a photo from an album (without deleting the media)
|
||||
// DELETE /api/albums/[id]/photos - Remove media from an album (without deleting the media)
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
|
|
@ -182,15 +205,15 @@ export const DELETE: RequestHandler = async (event) => {
|
|||
|
||||
try {
|
||||
const url = new URL(event.request.url)
|
||||
const photoId = url.searchParams.get('photoId')
|
||||
const mediaIdParam = url.searchParams.get('mediaId') || url.searchParams.get('photoId') // Support legacy param
|
||||
|
||||
logger.info('DELETE photo request', { albumId, photoId })
|
||||
logger.info('DELETE media request', { albumId, mediaId: mediaIdParam })
|
||||
|
||||
if (!photoId || isNaN(parseInt(photoId))) {
|
||||
return errorResponse('Photo ID is required as query parameter', 400)
|
||||
if (!mediaIdParam || isNaN(parseInt(mediaIdParam))) {
|
||||
return errorResponse('Media ID is required as query parameter', 400)
|
||||
}
|
||||
|
||||
const photoIdNum = parseInt(photoId)
|
||||
const mediaId = parseInt(mediaIdParam)
|
||||
|
||||
// Check if album exists
|
||||
const album = await prisma.album.findUnique({
|
||||
|
|
@ -202,53 +225,51 @@ export const DELETE: RequestHandler = async (event) => {
|
|||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
// Check if photo exists in this album
|
||||
const photo = await prisma.photo.findFirst({
|
||||
// Check if media exists in this album
|
||||
const albumMedia = await prisma.albumMedia.findUnique({
|
||||
where: {
|
||||
id: photoIdNum,
|
||||
albumId: albumId // Ensure photo belongs to this album
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Photo lookup result', { photoIdNum, albumId, found: !!photo })
|
||||
|
||||
if (!photo) {
|
||||
logger.error('Photo not found in album', { photoIdNum, albumId })
|
||||
return errorResponse('Photo not found in this album', 404)
|
||||
}
|
||||
|
||||
// Find and remove the specific media usage record for this photo
|
||||
// We need to find the media ID associated with this photo to remove the correct usage record
|
||||
const mediaUsage = await prisma.mediaUsage.findFirst({
|
||||
where: {
|
||||
contentType: 'album',
|
||||
contentId: albumId,
|
||||
fieldName: 'photos',
|
||||
media: {
|
||||
filename: photo.filename // Match by filename since that's how they're linked
|
||||
albumId_mediaId: {
|
||||
albumId: albumId,
|
||||
mediaId: mediaId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (mediaUsage) {
|
||||
await prisma.mediaUsage.delete({
|
||||
where: { id: mediaUsage.id }
|
||||
})
|
||||
logger.info('AlbumMedia lookup result', { mediaId, albumId, found: !!albumMedia })
|
||||
|
||||
if (!albumMedia) {
|
||||
logger.error('Media not found in album', { mediaId, albumId })
|
||||
return errorResponse('Media not found in this album', 404)
|
||||
}
|
||||
|
||||
// Delete the photo record (this removes it from the album but keeps the media)
|
||||
await prisma.photo.delete({
|
||||
where: { id: photoIdNum }
|
||||
// Remove media usage record
|
||||
await prisma.mediaUsage.deleteMany({
|
||||
where: {
|
||||
mediaId: mediaId,
|
||||
contentType: 'album',
|
||||
contentId: albumId,
|
||||
fieldName: 'photos'
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Photo removed from album', {
|
||||
photoId: photoIdNum,
|
||||
// Delete the album-media relationship
|
||||
await prisma.albumMedia.delete({
|
||||
where: {
|
||||
albumId_mediaId: {
|
||||
albumId: albumId,
|
||||
mediaId: mediaId
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Media removed from album', {
|
||||
mediaId: mediaId,
|
||||
albumId: albumId
|
||||
})
|
||||
|
||||
return new Response(null, { status: 204 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove photo from album', error as Error)
|
||||
return errorResponse('Failed to remove photo from album', 500)
|
||||
logger.error('Failed to remove media from album', error as Error)
|
||||
return errorResponse('Failed to remove media from album', 500)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,31 +15,32 @@ export const GET: RequestHandler = async (event) => {
|
|||
const album = await prisma.album.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
},
|
||||
media: {
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
caption: true,
|
||||
displayOrder: true
|
||||
include: {
|
||||
media: {
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
photoCaption: true,
|
||||
photoTitle: true,
|
||||
photoDescription: true,
|
||||
description: true,
|
||||
isPhotography: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
exifData: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
}
|
||||
}
|
||||
media: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,7 +50,26 @@ export const GET: RequestHandler = async (event) => {
|
|||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
return jsonResponse(album)
|
||||
// Transform the album data to include photos array
|
||||
const transformedAlbum = {
|
||||
...album,
|
||||
photos: album.media.map((albumMedia) => ({
|
||||
id: albumMedia.media.id,
|
||||
filename: albumMedia.media.filename,
|
||||
url: albumMedia.media.url,
|
||||
thumbnailUrl: albumMedia.media.thumbnailUrl,
|
||||
width: albumMedia.media.width,
|
||||
height: albumMedia.media.height,
|
||||
caption: albumMedia.media.photoCaption || albumMedia.media.description,
|
||||
title: albumMedia.media.photoTitle,
|
||||
description: albumMedia.media.photoDescription,
|
||||
displayOrder: albumMedia.displayOrder,
|
||||
exifData: albumMedia.media.exifData
|
||||
})),
|
||||
totalPhotos: album._count.media
|
||||
}
|
||||
|
||||
return jsonResponse(transformedAlbum)
|
||||
} catch (error) {
|
||||
logger.error('Failed to retrieve album by slug', error as Error)
|
||||
return errorResponse('Failed to retrieve album', 500)
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ export const GET: RequestHandler = async (event) => {
|
|||
height: true,
|
||||
usedIn: true,
|
||||
isPhotography: true,
|
||||
createdAt: true
|
||||
createdAt: true,
|
||||
description: true,
|
||||
exifData: true
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,6 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
try {
|
||||
const body = await parseRequestBody<{
|
||||
altText?: string
|
||||
description?: string
|
||||
isPhotography?: boolean
|
||||
}>(event.request)
|
||||
|
|
@ -68,13 +67,35 @@ export const PUT: RequestHandler = async (event) => {
|
|||
const media = await prisma.media.update({
|
||||
where: { id },
|
||||
data: {
|
||||
altText: body.altText !== undefined ? body.altText : existing.altText,
|
||||
description: body.description !== undefined ? body.description : existing.description,
|
||||
isPhotography:
|
||||
body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography
|
||||
}
|
||||
})
|
||||
|
||||
// If isPhotography changed to true, set photoPublishedAt
|
||||
if (body.isPhotography === true && !existing.isPhotography) {
|
||||
await prisma.media.update({
|
||||
where: { id },
|
||||
data: {
|
||||
photoPublishedAt: new Date(),
|
||||
photoCaption: existing.description // Use description as initial caption
|
||||
}
|
||||
})
|
||||
} else if (body.isPhotography === false && existing.isPhotography) {
|
||||
// If turning off photography, clear photo fields
|
||||
await prisma.media.update({
|
||||
where: { id },
|
||||
data: {
|
||||
photoPublishedAt: null,
|
||||
photoCaption: null,
|
||||
photoTitle: null,
|
||||
photoDescription: null,
|
||||
photoSlug: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('Media updated', { id, filename: media.filename })
|
||||
|
||||
return jsonResponse(media)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,130 @@ import { prisma } from '$lib/server/database'
|
|||
import { uploadFiles, isCloudinaryConfigured } from '$lib/server/cloudinary'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import exifr from 'exifr'
|
||||
|
||||
// Extract EXIF data from image file
|
||||
async function extractExifData(file: File) {
|
||||
try {
|
||||
logger.info(`Starting EXIF extraction for ${file.name}`, {
|
||||
size: file.size,
|
||||
type: file.type
|
||||
})
|
||||
|
||||
const buffer = await file.arrayBuffer()
|
||||
logger.info(`Buffer created for ${file.name}`, { bufferSize: buffer.byteLength })
|
||||
|
||||
// Try parsing without pick first to see all available data
|
||||
const fullExif = await exifr.parse(buffer)
|
||||
logger.info(`Full EXIF data available for ${file.name}:`, {
|
||||
hasData: !!fullExif,
|
||||
availableFields: fullExif ? Object.keys(fullExif).slice(0, 10) : [] // First 10 fields
|
||||
})
|
||||
|
||||
// Now parse with specific fields
|
||||
const exif = await exifr.parse(buffer, {
|
||||
pick: [
|
||||
'Make',
|
||||
'Model',
|
||||
'LensModel',
|
||||
'FocalLength',
|
||||
'FNumber',
|
||||
'ExposureTime',
|
||||
'ISO',
|
||||
'ISOSpeedRatings', // Alternative ISO field
|
||||
'DateTimeOriginal',
|
||||
'DateTime', // Alternative date field
|
||||
'GPSLatitude',
|
||||
'GPSLongitude',
|
||||
'Orientation',
|
||||
'ColorSpace'
|
||||
]
|
||||
})
|
||||
|
||||
logger.info(`EXIF parse result for ${file.name}:`, {
|
||||
hasExif: !!exif,
|
||||
exifKeys: exif ? Object.keys(exif) : []
|
||||
})
|
||||
|
||||
if (!exif) return null
|
||||
|
||||
// Format EXIF data
|
||||
const formattedExif: any = {}
|
||||
|
||||
// Camera info
|
||||
if (exif.Make && exif.Model) {
|
||||
formattedExif.camera = `${exif.Make} ${exif.Model}`.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
// Lens info
|
||||
if (exif.LensModel) {
|
||||
formattedExif.lens = exif.LensModel
|
||||
}
|
||||
|
||||
// Settings
|
||||
if (exif.FocalLength) {
|
||||
formattedExif.focalLength = `${exif.FocalLength}mm`
|
||||
}
|
||||
|
||||
if (exif.FNumber) {
|
||||
formattedExif.aperture = `f/${exif.FNumber}`
|
||||
}
|
||||
|
||||
if (exif.ExposureTime) {
|
||||
formattedExif.shutterSpeed =
|
||||
exif.ExposureTime < 1 ? `1/${Math.round(1 / exif.ExposureTime)}` : `${exif.ExposureTime}s`
|
||||
}
|
||||
|
||||
if (exif.ISO) {
|
||||
formattedExif.iso = `ISO ${exif.ISO}`
|
||||
} else if (exif.ISOSpeedRatings) {
|
||||
// Handle alternative ISO field
|
||||
const iso = Array.isArray(exif.ISOSpeedRatings)
|
||||
? exif.ISOSpeedRatings[0]
|
||||
: exif.ISOSpeedRatings
|
||||
formattedExif.iso = `ISO ${iso}`
|
||||
}
|
||||
|
||||
// Date taken
|
||||
if (exif.DateTimeOriginal) {
|
||||
formattedExif.dateTaken = exif.DateTimeOriginal
|
||||
} else if (exif.DateTime) {
|
||||
// Fallback to DateTime if DateTimeOriginal not available
|
||||
formattedExif.dateTaken = exif.DateTime
|
||||
}
|
||||
|
||||
// GPS coordinates
|
||||
if (exif.GPSLatitude && exif.GPSLongitude) {
|
||||
formattedExif.coordinates = {
|
||||
latitude: exif.GPSLatitude,
|
||||
longitude: exif.GPSLongitude
|
||||
}
|
||||
}
|
||||
|
||||
// Additional metadata
|
||||
if (exif.Orientation) {
|
||||
formattedExif.orientation =
|
||||
exif.Orientation === 1 ? 'Horizontal (normal)' : `Rotated (${exif.Orientation})`
|
||||
}
|
||||
|
||||
if (exif.ColorSpace) {
|
||||
formattedExif.colorSpace = exif.ColorSpace
|
||||
}
|
||||
|
||||
const result = Object.keys(formattedExif).length > 0 ? formattedExif : null
|
||||
logger.info(`Final EXIF result for ${file.name}:`, {
|
||||
hasData: !!result,
|
||||
fields: result ? Object.keys(result) : []
|
||||
})
|
||||
return result
|
||||
} catch (error) {
|
||||
logger.warn('Failed to extract EXIF data', {
|
||||
filename: file.name,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
|
|
@ -52,6 +176,19 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
logger.info(`Starting bulk upload of ${files.length} files`)
|
||||
|
||||
// Extract EXIF data before uploading (files might not be readable after upload)
|
||||
const exifDataMap = new Map()
|
||||
for (const file of files) {
|
||||
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
|
||||
logger.info(`Pre-extracting EXIF data for ${file.name}`)
|
||||
const exifData = await extractExifData(file)
|
||||
if (exifData) {
|
||||
exifDataMap.set(file.name, exifData)
|
||||
logger.info(`EXIF data found for ${file.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Upload all files to Cloudinary
|
||||
const uploadResults = await uploadFiles(files, context as 'media' | 'photos' | 'projects')
|
||||
|
||||
|
|
@ -65,15 +202,20 @@ export const POST: RequestHandler = async (event) => {
|
|||
|
||||
if (result.success) {
|
||||
try {
|
||||
// Get pre-extracted EXIF data
|
||||
const exifData = exifDataMap.get(file.name) || null
|
||||
|
||||
const media = await prisma.media.create({
|
||||
data: {
|
||||
filename: file.name,
|
||||
originalName: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
url: result.secureUrl!,
|
||||
thumbnailUrl: result.thumbnailUrl,
|
||||
width: result.width,
|
||||
height: result.height,
|
||||
exifData: exifData,
|
||||
usedIn: []
|
||||
}
|
||||
})
|
||||
|
|
@ -84,7 +226,8 @@ export const POST: RequestHandler = async (event) => {
|
|||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
filename: media.filename
|
||||
filename: media.filename,
|
||||
exifData: media.exifData
|
||||
})
|
||||
} catch (dbError) {
|
||||
errors.push({
|
||||
|
|
|
|||
|
|
@ -111,7 +111,6 @@ export const POST: RequestHandler = async (event) => {
|
|||
const formData = await event.request.formData()
|
||||
const file = formData.get('file') as File
|
||||
const context = (formData.get('context') as string) || 'media'
|
||||
const altText = (formData.get('altText') as string) || null
|
||||
const description = (formData.get('description') as string) || null
|
||||
const isPhotography = formData.get('isPhotography') === 'true'
|
||||
|
||||
|
|
@ -163,10 +162,8 @@ export const POST: RequestHandler = async (event) => {
|
|||
width: uploadResult.width,
|
||||
height: uploadResult.height,
|
||||
exifData: exifData,
|
||||
altText: altText?.trim() || null,
|
||||
description: description?.trim() || null,
|
||||
isPhotography: isPhotography,
|
||||
usedIn: []
|
||||
isPhotography: isPhotography
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -187,7 +184,6 @@ export const POST: RequestHandler = async (event) => {
|
|||
originalName: media.originalName,
|
||||
mimeType: media.mimeType,
|
||||
size: media.size,
|
||||
altText: media.altText,
|
||||
description: media.description,
|
||||
createdAt: media.createdAt,
|
||||
updatedAt: media.updatedAt
|
||||
|
|
|
|||
|
|
@ -11,27 +11,28 @@ export const GET: RequestHandler = async (event) => {
|
|||
const limit = parseInt(url.searchParams.get('limit') || '50')
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0')
|
||||
|
||||
// Fetch published photography albums
|
||||
// Fetch published photography albums with their media
|
||||
const albums = await prisma.album.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
isPhotography: true
|
||||
},
|
||||
include: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published'
|
||||
},
|
||||
media: {
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
caption: true,
|
||||
displayOrder: true
|
||||
include: {
|
||||
media: {
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
photoCaption: true,
|
||||
exifData: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -40,74 +41,97 @@ export const GET: RequestHandler = async (event) => {
|
|||
take: limit
|
||||
})
|
||||
|
||||
// Fetch individual published photos (not in albums, marked for photography)
|
||||
const individualPhotos = await prisma.photo.findMany({
|
||||
// Fetch individual photos (marked for photography, not in any album)
|
||||
const individualMedia = await prisma.media.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true,
|
||||
albumId: null // Only photos not in albums
|
||||
isPhotography: true,
|
||||
albums: {
|
||||
none: {} // Media not in any album
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
photoSlug: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
caption: true,
|
||||
title: true,
|
||||
description: true
|
||||
photoCaption: true,
|
||||
photoTitle: true,
|
||||
photoDescription: true,
|
||||
createdAt: true,
|
||||
photoPublishedAt: true,
|
||||
exifData: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
orderBy: { photoPublishedAt: 'desc' },
|
||||
skip: offset,
|
||||
take: limit
|
||||
})
|
||||
|
||||
// Transform albums to PhotoAlbum format
|
||||
const photoAlbums: PhotoAlbum[] = albums
|
||||
.filter((album) => album.photos.length > 0) // Only include albums with published photos
|
||||
.map((album) => ({
|
||||
id: `album-${album.id}`,
|
||||
slug: album.slug, // Add slug for navigation
|
||||
title: album.title,
|
||||
description: album.description || undefined,
|
||||
coverPhoto: {
|
||||
id: `cover-${album.photos[0].id}`,
|
||||
src: album.photos[0].url,
|
||||
alt: album.photos[0].caption || album.title,
|
||||
caption: album.photos[0].caption || undefined,
|
||||
width: album.photos[0].width || 400,
|
||||
height: album.photos[0].height || 400
|
||||
},
|
||||
photos: album.photos.map((photo) => ({
|
||||
id: `photo-${photo.id}`,
|
||||
src: photo.url,
|
||||
alt: photo.caption || photo.filename,
|
||||
caption: photo.caption || undefined,
|
||||
width: photo.width || 400,
|
||||
height: photo.height || 400
|
||||
})),
|
||||
createdAt: album.createdAt.toISOString()
|
||||
}))
|
||||
.filter((album) => album.media.length > 0) // Only include albums with media
|
||||
.map((album) => {
|
||||
const firstMedia = album.media[0].media
|
||||
return {
|
||||
id: `album-${album.id}`,
|
||||
slug: album.slug,
|
||||
title: album.title,
|
||||
description: album.description || undefined,
|
||||
coverPhoto: {
|
||||
id: `cover-${firstMedia.id}`,
|
||||
src: firstMedia.url,
|
||||
alt: firstMedia.photoCaption || album.title,
|
||||
caption: firstMedia.photoCaption || undefined,
|
||||
width: firstMedia.width || 400,
|
||||
height: firstMedia.height || 400
|
||||
},
|
||||
photos: album.media.map((albumMedia) => ({
|
||||
id: `media-${albumMedia.media.id}`,
|
||||
src: albumMedia.media.url,
|
||||
alt: albumMedia.media.photoCaption || albumMedia.media.filename,
|
||||
caption: albumMedia.media.photoCaption || undefined,
|
||||
width: albumMedia.media.width || 400,
|
||||
height: albumMedia.media.height || 400
|
||||
})),
|
||||
createdAt: album.createdAt.toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
// Transform individual photos to Photo format
|
||||
const photos: Photo[] = individualPhotos.map((photo) => ({
|
||||
id: `photo-${photo.id}`,
|
||||
src: photo.url,
|
||||
alt: photo.title || photo.caption || photo.filename,
|
||||
caption: photo.caption || undefined,
|
||||
width: photo.width || 400,
|
||||
height: photo.height || 400
|
||||
}))
|
||||
// Transform individual media to Photo format
|
||||
const photos: Photo[] = individualMedia.map((media) => {
|
||||
// Extract date from EXIF data if available
|
||||
let photoDate: string
|
||||
if (media.exifData && typeof media.exifData === 'object' && 'dateTaken' in media.exifData) {
|
||||
// Use EXIF date if available
|
||||
photoDate = media.exifData.dateTaken as string
|
||||
} else if (media.photoPublishedAt) {
|
||||
// Fall back to published date
|
||||
photoDate = media.photoPublishedAt.toISOString()
|
||||
} else {
|
||||
// Fall back to created date
|
||||
photoDate = media.createdAt.toISOString()
|
||||
}
|
||||
|
||||
return {
|
||||
id: `media-${media.id}`,
|
||||
src: media.url,
|
||||
alt: media.photoTitle || media.photoCaption || media.filename,
|
||||
caption: media.photoCaption || undefined,
|
||||
width: media.width || 400,
|
||||
height: media.height || 400,
|
||||
createdAt: photoDate
|
||||
}
|
||||
})
|
||||
|
||||
// Combine albums and individual photos
|
||||
const photoItems: PhotoItem[] = [...photoAlbums, ...photos]
|
||||
|
||||
// Sort by creation date (albums use createdAt, individual photos would need publishedAt or createdAt)
|
||||
// Sort by creation date (both albums and photos now have createdAt)
|
||||
photoItems.sort((a, b) => {
|
||||
const dateA = 'createdAt' in a ? new Date(a.createdAt) : new Date()
|
||||
const dateB = 'createdAt' in b ? new Date(b.createdAt) : new Date()
|
||||
const dateA = a.createdAt ? new Date(a.createdAt) : new Date()
|
||||
const dateB = b.createdAt ? new Date(b.createdAt) : new Date()
|
||||
return dateB.getTime() - dateA.getTime()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -6,14 +6,14 @@ import { logger } from '$lib/server/logger'
|
|||
// GET /api/photos/[albumSlug]/[photoId] - Get individual photo with album context
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const albumSlug = event.params.albumSlug
|
||||
const photoId = parseInt(event.params.photoId)
|
||||
const mediaId = parseInt(event.params.photoId) // Still called photoId in URL for compatibility
|
||||
|
||||
if (!albumSlug || isNaN(photoId)) {
|
||||
return errorResponse('Invalid album slug or photo ID', 400)
|
||||
if (!albumSlug || isNaN(mediaId)) {
|
||||
return errorResponse('Invalid album slug or media ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
// First find the album
|
||||
// First find the album with its media
|
||||
const album = await prisma.album.findUnique({
|
||||
where: {
|
||||
slug: albumSlug,
|
||||
|
|
@ -21,20 +21,25 @@ export const GET: RequestHandler = async (event) => {
|
|||
isPhotography: true
|
||||
},
|
||||
include: {
|
||||
photos: {
|
||||
media: {
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
caption: true,
|
||||
title: true,
|
||||
description: true,
|
||||
displayOrder: true,
|
||||
exifData: true
|
||||
include: {
|
||||
media: {
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
photoCaption: true,
|
||||
photoTitle: true,
|
||||
photoDescription: true,
|
||||
exifData: true,
|
||||
createdAt: true,
|
||||
photoPublishedAt: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -44,16 +49,36 @@ export const GET: RequestHandler = async (event) => {
|
|||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
// Find the specific photo
|
||||
const photo = album.photos.find((p) => p.id === photoId)
|
||||
if (!photo) {
|
||||
// Find the specific media
|
||||
const albumMediaIndex = album.media.findIndex((am) => am.media.id === mediaId)
|
||||
if (albumMediaIndex === -1) {
|
||||
return errorResponse('Photo not found in album', 404)
|
||||
}
|
||||
|
||||
// Get photo index for navigation
|
||||
const photoIndex = album.photos.findIndex((p) => p.id === photoId)
|
||||
const prevPhoto = photoIndex > 0 ? album.photos[photoIndex - 1] : null
|
||||
const nextPhoto = photoIndex < album.photos.length - 1 ? album.photos[photoIndex + 1] : null
|
||||
const albumMedia = album.media[albumMediaIndex]
|
||||
const media = albumMedia.media
|
||||
|
||||
// Get navigation info
|
||||
const prevMedia = albumMediaIndex > 0 ? album.media[albumMediaIndex - 1].media : null
|
||||
const nextMedia =
|
||||
albumMediaIndex < album.media.length - 1 ? album.media[albumMediaIndex + 1].media : null
|
||||
|
||||
// Transform to photo format for compatibility
|
||||
const photo = {
|
||||
id: media.id,
|
||||
filename: media.filename,
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
caption: media.photoCaption,
|
||||
title: media.photoTitle,
|
||||
description: media.photoDescription,
|
||||
displayOrder: albumMedia.displayOrder,
|
||||
exifData: media.exifData,
|
||||
createdAt: media.createdAt,
|
||||
publishedAt: media.photoPublishedAt
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
photo,
|
||||
|
|
@ -64,13 +89,13 @@ export const GET: RequestHandler = async (event) => {
|
|||
description: album.description,
|
||||
location: album.location,
|
||||
date: album.date,
|
||||
totalPhotos: album.photos.length
|
||||
totalPhotos: album.media.length
|
||||
},
|
||||
navigation: {
|
||||
currentIndex: photoIndex + 1, // 1-based for display
|
||||
totalCount: album.photos.length,
|
||||
prevPhoto: prevPhoto ? { id: prevPhoto.id, url: prevPhoto.thumbnailUrl } : null,
|
||||
nextPhoto: nextPhoto ? { id: nextPhoto.id, url: nextPhoto.thumbnailUrl } : null
|
||||
currentIndex: albumMediaIndex + 1, // 1-based for display
|
||||
totalCount: album.media.length,
|
||||
prevPhoto: prevMedia ? { id: prevMedia.id, url: prevMedia.thumbnailUrl } : null,
|
||||
nextPhoto: nextMedia ? { id: nextMedia.id, url: nextMedia.thumbnailUrl } : null
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -3,27 +3,86 @@ import { prisma } from '$lib/server/database'
|
|||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
// GET /api/photos/[id] - Get a single photo
|
||||
// GET /api/photos/[id] - Get a single media item as photo
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid photo ID', 400)
|
||||
return errorResponse('Invalid media ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const photo = await prisma.photo.findUnique({
|
||||
logger.info('Fetching photo', { mediaId: id })
|
||||
|
||||
const media = await prisma.media.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
album: {
|
||||
select: { id: true, title: true, slug: true }
|
||||
albums: {
|
||||
include: {
|
||||
album: {
|
||||
select: { id: true, title: true, slug: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!photo) {
|
||||
if (!media) {
|
||||
logger.warn('Media not found', { mediaId: id })
|
||||
return errorResponse('Photo not found', 404)
|
||||
}
|
||||
|
||||
logger.info('Media found', {
|
||||
mediaId: id,
|
||||
isPhotography: media.isPhotography,
|
||||
albumCount: media.albums.length
|
||||
})
|
||||
|
||||
// For public access, only return media marked as photography
|
||||
const isAdminRequest = checkAdminAuth(event)
|
||||
logger.info('Authorization check', { isAdmin: isAdminRequest })
|
||||
|
||||
if (!isAdminRequest) {
|
||||
if (!media.isPhotography) {
|
||||
logger.warn('Media not marked as photography', { mediaId: id })
|
||||
return errorResponse('Photo not found', 404)
|
||||
}
|
||||
// If media is in an album, check album is published and isPhotography
|
||||
if (media.albums.length > 0) {
|
||||
const album = media.albums[0].album
|
||||
const fullAlbum = await prisma.album.findUnique({
|
||||
where: { id: album.id }
|
||||
})
|
||||
logger.info('Album check', {
|
||||
albumId: album.id,
|
||||
status: fullAlbum?.status,
|
||||
isPhotography: fullAlbum?.isPhotography
|
||||
})
|
||||
if (!fullAlbum || fullAlbum.status !== 'published' || !fullAlbum.isPhotography) {
|
||||
logger.warn('Album not valid for public access', { albumId: album.id })
|
||||
return errorResponse('Photo not found', 404)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transform to match expected photo format
|
||||
const photo = {
|
||||
id: media.id,
|
||||
filename: media.filename,
|
||||
url: media.url,
|
||||
thumbnailUrl: media.thumbnailUrl,
|
||||
width: media.width,
|
||||
height: media.height,
|
||||
exifData: media.exifData,
|
||||
caption: media.photoCaption,
|
||||
title: media.photoTitle,
|
||||
description: media.photoDescription,
|
||||
slug: media.photoSlug,
|
||||
publishedAt: media.photoPublishedAt,
|
||||
createdAt: media.createdAt,
|
||||
album: media.albums.length > 0 ? media.albums[0].album : null,
|
||||
media: media // Include full media object for compatibility
|
||||
}
|
||||
|
||||
return jsonResponse(photo)
|
||||
} catch (error) {
|
||||
logger.error('Failed to retrieve photo', error as Error)
|
||||
|
|
@ -31,8 +90,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
}
|
||||
}
|
||||
|
||||
// DELETE /api/photos/[id] - Delete a photo completely (removes photo record and media usage)
|
||||
// NOTE: This deletes the photo entirely. Use DELETE /api/albums/[id]/photos to remove from album only.
|
||||
// DELETE /api/photos/[id] - Remove media from photography display
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
|
|
@ -41,44 +99,43 @@ export const DELETE: RequestHandler = async (event) => {
|
|||
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid photo ID', 400)
|
||||
return errorResponse('Invalid media ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if photo exists
|
||||
const photo = await prisma.photo.findUnique({
|
||||
// Check if media exists
|
||||
const media = await prisma.media.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!photo) {
|
||||
if (!media) {
|
||||
return errorResponse('Photo not found', 404)
|
||||
}
|
||||
|
||||
// Remove media usage tracking for this photo
|
||||
if (photo.albumId) {
|
||||
await prisma.mediaUsage.deleteMany({
|
||||
where: {
|
||||
contentType: 'album',
|
||||
contentId: photo.albumId,
|
||||
fieldName: 'photos'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Delete the photo record
|
||||
await prisma.photo.delete({
|
||||
where: { id }
|
||||
// Update media to remove from photography
|
||||
await prisma.media.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isPhotography: false,
|
||||
photoCaption: null,
|
||||
photoTitle: null,
|
||||
photoDescription: null,
|
||||
photoSlug: null,
|
||||
photoPublishedAt: null
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Photo deleted from album', {
|
||||
photoId: id,
|
||||
albumId: photo.albumId
|
||||
// Remove from all albums
|
||||
await prisma.albumMedia.deleteMany({
|
||||
where: { mediaId: id }
|
||||
})
|
||||
|
||||
logger.info('Media removed from photography', { mediaId: id })
|
||||
|
||||
return new Response(null, { status: 204 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete photo', error as Error)
|
||||
return errorResponse('Failed to delete photo', 500)
|
||||
logger.error('Failed to remove photo', error as Error)
|
||||
return errorResponse('Failed to remove photo', 500)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,14 +148,14 @@ export const PUT: RequestHandler = async (event) => {
|
|||
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid photo ID', 400)
|
||||
return errorResponse('Invalid media ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await event.request.json()
|
||||
|
||||
// Check if photo exists
|
||||
const existing = await prisma.photo.findUnique({
|
||||
// Check if media exists
|
||||
const existing = await prisma.media.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
|
|
@ -106,20 +163,31 @@ export const PUT: RequestHandler = async (event) => {
|
|||
return errorResponse('Photo not found', 404)
|
||||
}
|
||||
|
||||
// Update photo
|
||||
const photo = await prisma.photo.update({
|
||||
// Update media photo fields
|
||||
const media = await prisma.media.update({
|
||||
where: { id },
|
||||
data: {
|
||||
caption: body.caption !== undefined ? body.caption : existing.caption,
|
||||
title: body.title !== undefined ? body.title : existing.title,
|
||||
description: body.description !== undefined ? body.description : existing.description,
|
||||
displayOrder: body.displayOrder !== undefined ? body.displayOrder : existing.displayOrder,
|
||||
status: body.status !== undefined ? body.status : existing.status,
|
||||
showInPhotos: body.showInPhotos !== undefined ? body.showInPhotos : existing.showInPhotos
|
||||
photoCaption: body.caption !== undefined ? body.caption : existing.photoCaption,
|
||||
photoTitle: body.title !== undefined ? body.title : existing.photoTitle,
|
||||
photoDescription:
|
||||
body.description !== undefined ? body.description : existing.photoDescription,
|
||||
isPhotography: body.showInPhotos !== undefined ? body.showInPhotos : existing.isPhotography,
|
||||
photoPublishedAt:
|
||||
body.publishedAt !== undefined ? body.publishedAt : existing.photoPublishedAt
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Photo updated', { photoId: id })
|
||||
logger.info('Photo metadata updated', { mediaId: id })
|
||||
|
||||
// Return in photo format for compatibility
|
||||
const photo = {
|
||||
id: media.id,
|
||||
caption: media.photoCaption,
|
||||
title: media.photoTitle,
|
||||
description: media.photoDescription,
|
||||
showInPhotos: media.isPhotography,
|
||||
publishedAt: media.photoPublishedAt
|
||||
}
|
||||
|
||||
return jsonResponse(photo)
|
||||
} catch (error) {
|
||||
|
|
|
|||
254
src/routes/api/test-photos/+server.ts
Normal file
254
src/routes/api/test-photos/+server.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
// GET /api/test-photos - Test endpoint to debug photo visibility
|
||||
export const GET: RequestHandler = async () => {
|
||||
try {
|
||||
// Query 1: Get all photos with showInPhotos=true and albumId=null
|
||||
const photosWithShowInPhotos = await prisma.photo.findMany({
|
||||
where: {
|
||||
showInPhotos: true,
|
||||
albumId: null
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
status: true,
|
||||
showInPhotos: true,
|
||||
albumId: true,
|
||||
publishedAt: true,
|
||||
createdAt: true,
|
||||
title: true,
|
||||
description: true,
|
||||
caption: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
// Query 2: Get count of photos by status with showInPhotos=true and albumId=null
|
||||
const photosByStatus = await prisma.photo.groupBy({
|
||||
by: ['status'],
|
||||
where: {
|
||||
showInPhotos: true,
|
||||
albumId: null
|
||||
},
|
||||
_count: {
|
||||
id: true
|
||||
}
|
||||
})
|
||||
|
||||
// Query 3: Get all photos regardless of status to see what exists
|
||||
const allPhotosNoAlbum = await prisma.photo.findMany({
|
||||
where: {
|
||||
albumId: null
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
filename: true,
|
||||
status: true,
|
||||
showInPhotos: true,
|
||||
albumId: true,
|
||||
publishedAt: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
// Query 3b: Get ALL photos to see what's in the database
|
||||
const allPhotos = await prisma.photo.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
filename: true,
|
||||
status: true,
|
||||
showInPhotos: true,
|
||||
albumId: true,
|
||||
publishedAt: true,
|
||||
createdAt: true,
|
||||
album: {
|
||||
select: {
|
||||
title: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
// Query 4: Get specific published photos that should appear
|
||||
const publishedPhotos = await prisma.photo.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true,
|
||||
albumId: null
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
status: true,
|
||||
showInPhotos: true,
|
||||
albumId: true,
|
||||
publishedAt: true,
|
||||
createdAt: true,
|
||||
title: true
|
||||
}
|
||||
})
|
||||
|
||||
// Query 5: Raw SQL query to double-check
|
||||
const rawQuery = await prisma.$queryRaw`
|
||||
SELECT id, slug, filename, status, "showInPhotos", "albumId", "publishedAt", "createdAt"
|
||||
FROM "Photo"
|
||||
WHERE "showInPhotos" = true AND "albumId" IS NULL
|
||||
ORDER BY "createdAt" DESC
|
||||
`
|
||||
|
||||
// Query 6: Get all albums and their isPhotography flag
|
||||
const allAlbums = await prisma.album.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
isPhotography: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
photos: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { id: 'asc' }
|
||||
})
|
||||
|
||||
// Query 7: Get photos from albums with isPhotography=true
|
||||
const photosFromPhotographyAlbums = await prisma.photo.findMany({
|
||||
where: {
|
||||
album: {
|
||||
isPhotography: true
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
filename: true,
|
||||
status: true,
|
||||
showInPhotos: true,
|
||||
albumId: true,
|
||||
album: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
isPhotography: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Query 8: Specifically check album with ID 5
|
||||
const albumFive = await prisma.album.findUnique({
|
||||
where: { id: 5 },
|
||||
include: {
|
||||
photos: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
filename: true,
|
||||
status: true,
|
||||
showInPhotos: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const response = {
|
||||
summary: {
|
||||
totalPhotosWithShowInPhotos: photosWithShowInPhotos.length,
|
||||
totalPublishedPhotos: publishedPhotos.length,
|
||||
totalPhotosNoAlbum: allPhotosNoAlbum.length,
|
||||
totalPhotosInDatabase: allPhotos.length,
|
||||
photosByStatus: photosByStatus.map((item) => ({
|
||||
status: item.status,
|
||||
count: item._count.id
|
||||
})),
|
||||
photosWithShowInPhotosFlag: allPhotos.filter((p) => p.showInPhotos).length,
|
||||
photosByFilename: allPhotos
|
||||
.filter((p) => p.filename?.includes('B0000057'))
|
||||
.map((p) => ({
|
||||
filename: p.filename,
|
||||
showInPhotos: p.showInPhotos,
|
||||
status: p.status,
|
||||
albumId: p.albumId,
|
||||
albumTitle: p.album?.title
|
||||
}))
|
||||
},
|
||||
albums: {
|
||||
totalAlbums: allAlbums.length,
|
||||
photographyAlbums: allAlbums
|
||||
.filter((a) => a.isPhotography)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
slug: a.slug,
|
||||
isPhotography: a.isPhotography,
|
||||
status: a.status,
|
||||
photoCount: a._count.photos
|
||||
})),
|
||||
nonPhotographyAlbums: allAlbums
|
||||
.filter((a) => !a.isPhotography)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
slug: a.slug,
|
||||
isPhotography: a.isPhotography,
|
||||
status: a.status,
|
||||
photoCount: a._count.photos
|
||||
})),
|
||||
albumFive: albumFive
|
||||
? {
|
||||
id: albumFive.id,
|
||||
title: albumFive.title,
|
||||
slug: albumFive.slug,
|
||||
isPhotography: albumFive.isPhotography,
|
||||
status: albumFive.status,
|
||||
publishedAt: albumFive.publishedAt,
|
||||
photoCount: albumFive.photos.length,
|
||||
photos: albumFive.photos
|
||||
}
|
||||
: null,
|
||||
photosFromPhotographyAlbums: photosFromPhotographyAlbums.length,
|
||||
photosFromPhotographyAlbumsSample: photosFromPhotographyAlbums.slice(0, 5)
|
||||
},
|
||||
queries: {
|
||||
photosWithShowInPhotos: photosWithShowInPhotos,
|
||||
publishedPhotos: publishedPhotos,
|
||||
allPhotosNoAlbum: allPhotosNoAlbum,
|
||||
allPhotos: allPhotos,
|
||||
rawQueryResults: rawQuery,
|
||||
allAlbums: allAlbums
|
||||
},
|
||||
debug: {
|
||||
expectedQuery: 'WHERE status = "published" AND showInPhotos = true AND albumId = null',
|
||||
actualPhotosEndpointQuery: '/api/photos uses this exact query',
|
||||
albumsWithPhotographyFlagTrue: allAlbums
|
||||
.filter((a) => a.isPhotography)
|
||||
.map((a) => `${a.id}: ${a.title}`)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Test photos query results', response.summary)
|
||||
|
||||
return jsonResponse(response)
|
||||
} catch (error) {
|
||||
logger.error('Failed to run test photos query', error as Error)
|
||||
return errorResponse(
|
||||
`Failed to run test query: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,15 @@
|
|||
<script lang="ts">
|
||||
import BackButton from '$components/BackButton.svelte'
|
||||
import PhotoView from '$components/PhotoView.svelte'
|
||||
import PhotoMetadata from '$components/PhotoMetadata.svelte'
|
||||
import { generateMetaTags, generateCreativeWorkJsonLd } from '$lib/utils/metadata'
|
||||
import { page } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import { spring } from 'svelte/motion'
|
||||
import { getCurrentMousePosition } from '$lib/stores/mouse'
|
||||
import type { PageData } from './$types'
|
||||
import ArrowLeft from '$icons/arrow-left.svg'
|
||||
import ArrowRight from '$icons/arrow-right.svg'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
|
|
@ -11,43 +18,38 @@
|
|||
const navigation = $derived(data.navigation)
|
||||
const error = $derived(data.error)
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
// Hover tracking for arrow buttons
|
||||
let isHoveringLeft = $state(false)
|
||||
let isHoveringRight = $state(false)
|
||||
|
||||
const formatExif = (exifData: any) => {
|
||||
if (!exifData) return null
|
||||
|
||||
const formatSpeed = (speed: string) => {
|
||||
if (speed?.includes('/')) return speed
|
||||
if (speed?.includes('s')) return speed
|
||||
return speed ? `1/${speed}s` : null
|
||||
// Spring stores for smooth button movement
|
||||
const leftButtonCoords = spring(
|
||||
{ x: 0, y: 0 },
|
||||
{
|
||||
stiffness: 0.3,
|
||||
damping: 0.8
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
camera: exifData.camera,
|
||||
lens: exifData.lens,
|
||||
settings: [
|
||||
exifData.focalLength,
|
||||
exifData.aperture,
|
||||
formatSpeed(exifData.shutterSpeed),
|
||||
exifData.iso ? `ISO ${exifData.iso}` : null
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' • '),
|
||||
location: exifData.location,
|
||||
dateTaken: exifData.dateTaken
|
||||
const rightButtonCoords = spring(
|
||||
{ x: 0, y: 0 },
|
||||
{
|
||||
stiffness: 0.3,
|
||||
damping: 0.8
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Default button positions (will be set once photo loads)
|
||||
let defaultLeftX = 0
|
||||
let defaultRightX = 0
|
||||
|
||||
const exif = $derived(photo ? formatExif(photo.exifData) : null)
|
||||
const pageUrl = $derived($page.url.href)
|
||||
|
||||
// Parse EXIF data if available
|
||||
const exifData = $derived(
|
||||
photo?.exifData && typeof photo.exifData === 'object' ? photo.exifData : null
|
||||
)
|
||||
|
||||
// Generate metadata
|
||||
const photoTitle = $derived(photo?.title || photo?.caption || `Photo ${navigation?.currentIndex}`)
|
||||
const photoDescription = $derived(
|
||||
|
|
@ -61,7 +63,7 @@
|
|||
url: pageUrl,
|
||||
type: 'article',
|
||||
image: photo.url,
|
||||
publishedTime: exif?.dateTaken,
|
||||
publishedTime: exifData?.dateTaken,
|
||||
author: 'Justin Edmund',
|
||||
titleFormat: { type: 'snippet', snippet: photoDescription }
|
||||
})
|
||||
|
|
@ -82,11 +84,214 @@
|
|||
url: pageUrl,
|
||||
image: photo.url,
|
||||
creator: 'Justin Edmund',
|
||||
dateCreated: exif?.dateTaken,
|
||||
keywords: ['photography', album.title, ...(exif?.location ? [exif.location] : [])]
|
||||
dateCreated: exifData?.dateTaken,
|
||||
keywords: ['photography', album.title, ...(exifData?.location ? [exifData.location] : [])]
|
||||
})
|
||||
: null
|
||||
)
|
||||
|
||||
// Set default button positions when component mounts
|
||||
$effect(() => {
|
||||
if (!photo) return
|
||||
|
||||
// Wait for DOM to update and image to load
|
||||
const checkAndSetPositions = () => {
|
||||
const pageContainer = document.querySelector('.photo-page') as HTMLElement
|
||||
const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement
|
||||
|
||||
if (photoImage && photoImage.complete) {
|
||||
const imageRect = photoImage.getBoundingClientRect()
|
||||
const pageRect = pageContainer.getBoundingClientRect()
|
||||
|
||||
// Calculate default positions relative to the image
|
||||
// Add 24px (half button width) since we're using translate(-50%, -50%)
|
||||
defaultLeftX = imageRect.left - pageRect.left - 24 - 16 // half button width + gap
|
||||
defaultRightX = imageRect.right - pageRect.left + 24 + 16 // half button width + gap
|
||||
|
||||
// Set initial positions at the vertical center of the image
|
||||
const centerY = imageRect.top - pageRect.top + imageRect.height / 2
|
||||
leftButtonCoords.set({ x: defaultLeftX, y: centerY }, { hard: true })
|
||||
rightButtonCoords.set({ x: defaultRightX, y: centerY }, { hard: true })
|
||||
|
||||
// Check if mouse is already in a hover zone
|
||||
// Small delay to ensure mouse store is initialized
|
||||
setTimeout(() => {
|
||||
checkInitialMousePosition(pageContainer, imageRect, pageRect)
|
||||
}, 10)
|
||||
} else {
|
||||
// If image not loaded yet, try again
|
||||
setTimeout(checkAndSetPositions, 50)
|
||||
}
|
||||
}
|
||||
|
||||
checkAndSetPositions()
|
||||
})
|
||||
|
||||
// Check mouse position on load
|
||||
function checkInitialMousePosition(
|
||||
pageContainer: HTMLElement,
|
||||
imageRect: DOMRect,
|
||||
pageRect: DOMRect
|
||||
) {
|
||||
// Get current mouse position from store
|
||||
const currentPos = getCurrentMousePosition()
|
||||
|
||||
// If no mouse position tracked yet, try to trigger one
|
||||
if (currentPos.x === 0 && currentPos.y === 0) {
|
||||
// Set up a one-time listener for the first mouse move
|
||||
const handleFirstMove = (e: MouseEvent) => {
|
||||
const x = e.clientX
|
||||
const mouseX = e.clientX - pageRect.left
|
||||
const mouseY = e.clientY - pageRect.top
|
||||
|
||||
// Check if mouse is in hover zones
|
||||
if (x < imageRect.left) {
|
||||
isHoveringLeft = true
|
||||
leftButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
|
||||
} else if (x > imageRect.right) {
|
||||
isHoveringRight = true
|
||||
rightButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
|
||||
}
|
||||
|
||||
// Remove the listener
|
||||
window.removeEventListener('mousemove', handleFirstMove)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleFirstMove)
|
||||
return
|
||||
}
|
||||
|
||||
// We have a mouse position, check if it's in a hover zone
|
||||
const x = currentPos.x
|
||||
const mouseX = currentPos.x - pageRect.left
|
||||
const mouseY = currentPos.y - pageRect.top
|
||||
|
||||
// Store client coordinates for scroll updates
|
||||
lastClientX = currentPos.x
|
||||
lastClientY = currentPos.y
|
||||
|
||||
// Check if mouse is in hover zones
|
||||
if (x < imageRect.left) {
|
||||
isHoveringLeft = true
|
||||
leftButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
|
||||
} else if (x > imageRect.right) {
|
||||
isHoveringRight = true
|
||||
rightButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Store last mouse client position for scroll updates
|
||||
let lastClientX = 0
|
||||
let lastClientY = 0
|
||||
|
||||
// Update button positions during scroll
|
||||
function handleScroll() {
|
||||
if (!isHoveringLeft && !isHoveringRight) return
|
||||
|
||||
const pageContainer = document.querySelector('.photo-page') as HTMLElement
|
||||
if (!pageContainer) return
|
||||
|
||||
// Use last known mouse position (which is viewport-relative)
|
||||
// and recalculate relative to the page container's new position
|
||||
const pageRect = pageContainer.getBoundingClientRect()
|
||||
const mouseX = lastClientX - pageRect.left
|
||||
const mouseY = lastClientY - pageRect.top
|
||||
|
||||
// Update button positions
|
||||
if (isHoveringLeft) {
|
||||
leftButtonCoords.set({ x: mouseX, y: mouseY })
|
||||
}
|
||||
|
||||
if (isHoveringRight) {
|
||||
rightButtonCoords.set({ x: mouseX, y: mouseY })
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse tracking for hover areas
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
const pageContainer = event.currentTarget as HTMLElement
|
||||
const photoWrapper = pageContainer.querySelector('.photo-content-wrapper') as HTMLElement
|
||||
|
||||
if (!photoWrapper) return
|
||||
|
||||
// Get the actual image element inside PhotoView
|
||||
const photoImage = photoWrapper.querySelector('img') as HTMLElement
|
||||
if (!photoImage) return
|
||||
|
||||
const pageRect = pageContainer.getBoundingClientRect()
|
||||
const photoRect = photoImage.getBoundingClientRect()
|
||||
|
||||
const x = event.clientX
|
||||
const mouseX = event.clientX - pageRect.left
|
||||
const mouseY = event.clientY - pageRect.top
|
||||
|
||||
// Store last mouse position for scroll updates
|
||||
lastClientX = event.clientX
|
||||
lastClientY = event.clientY
|
||||
|
||||
// Check if mouse is in the left or right margin (outside the photo)
|
||||
const wasHoveringLeft = isHoveringLeft
|
||||
const wasHoveringRight = isHoveringRight
|
||||
|
||||
isHoveringLeft = x < photoRect.left
|
||||
isHoveringRight = x > photoRect.right
|
||||
|
||||
// Calculate image center Y position
|
||||
const imageCenterY = photoRect.top - pageRect.top + photoRect.height / 2
|
||||
|
||||
// Update button positions
|
||||
if (isHoveringLeft) {
|
||||
leftButtonCoords.set({ x: mouseX, y: mouseY })
|
||||
} else if (wasHoveringLeft && !isHoveringLeft) {
|
||||
// Reset left button to default
|
||||
leftButtonCoords.set({ x: defaultLeftX, y: imageCenterY })
|
||||
}
|
||||
|
||||
if (isHoveringRight) {
|
||||
rightButtonCoords.set({ x: mouseX, y: mouseY })
|
||||
} else if (wasHoveringRight && !isHoveringRight) {
|
||||
// Reset right button to default
|
||||
rightButtonCoords.set({ x: defaultRightX, y: imageCenterY })
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
isHoveringLeft = false
|
||||
isHoveringRight = false
|
||||
|
||||
// Reset buttons to default positions
|
||||
const pageContainer = document.querySelector('.photo-page') as HTMLElement
|
||||
const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement
|
||||
|
||||
if (photoImage && pageContainer) {
|
||||
const imageRect = photoImage.getBoundingClientRect()
|
||||
const pageRect = pageContainer.getBoundingClientRect()
|
||||
const centerY = imageRect.top - pageRect.top + imageRect.height / 2
|
||||
|
||||
leftButtonCoords.set({ x: defaultLeftX, y: centerY })
|
||||
rightButtonCoords.set({ x: defaultRightX, y: centerY })
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowLeft' && navigation?.prevPhoto) {
|
||||
goto(`/photos/${album.slug}/${navigation.prevPhoto.id}`)
|
||||
} else if (e.key === 'ArrowRight' && navigation?.nextPhoto) {
|
||||
goto(`/photos/${album.slug}/${navigation.nextPhoto.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Set up keyboard and scroll listeners
|
||||
$effect(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
@ -119,163 +324,71 @@
|
|||
|
||||
{#if error || !photo || !album}
|
||||
<div class="error-container">
|
||||
<div class="error-content">
|
||||
<div class="error-message">
|
||||
<h1>Photo Not Found</h1>
|
||||
<p>{error || "The photo you're looking for doesn't exist."}</p>
|
||||
<BackButton href="/photos" label="Back to Photos" />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="photo-page">
|
||||
<!-- Navigation Header -->
|
||||
<header class="photo-header">
|
||||
<nav class="breadcrumb">
|
||||
<a href="/photos">Photos</a>
|
||||
<span class="separator">→</span>
|
||||
<a href="/photos/{album.slug}">{album.title}</a>
|
||||
<span class="separator">→</span>
|
||||
<span class="current">Photo {navigation.currentIndex} of {navigation.totalCount}</span>
|
||||
</nav>
|
||||
<div class="photo-page" onmousemove={handleMouseMove} onmouseleave={handleMouseLeave}>
|
||||
<div class="photo-content-wrapper">
|
||||
<PhotoView src={photo.url} alt={photo.caption} title={photo.title} id={photo.id} />
|
||||
</div>
|
||||
|
||||
<div class="photo-nav">
|
||||
{#if navigation.prevPhoto}
|
||||
<a href="/photos/{album.slug}/{navigation.prevPhoto.id}" class="nav-btn prev">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M12.5 15L7.5 10L12.5 5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Previous
|
||||
</a>
|
||||
{:else}
|
||||
<div class="nav-btn disabled">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M12.5 15L7.5 10L12.5 5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Previous
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Adjacent Photos Navigation -->
|
||||
<div class="adjacent-navigation">
|
||||
{#if navigation.prevPhoto}
|
||||
<button
|
||||
class="nav-button prev"
|
||||
class:hovering={isHoveringLeft}
|
||||
style="
|
||||
left: {$leftButtonCoords.x}px;
|
||||
top: {$leftButtonCoords.y}px;
|
||||
transform: translate(-50%, -50%);
|
||||
"
|
||||
onclick={() => goto(`/photos/${album.slug}/${navigation.prevPhoto.id}`)}
|
||||
type="button"
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<ArrowLeft class="nav-icon" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if navigation.nextPhoto}
|
||||
<a href="/photos/{album.slug}/{navigation.nextPhoto.id}" class="nav-btn next">
|
||||
Next
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M7.5 5L12.5 10L7.5 15"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="nav-btn disabled">
|
||||
Next
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M7.5 5L12.5 10L7.5 15"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
{#if navigation.nextPhoto}
|
||||
<button
|
||||
class="nav-button next"
|
||||
class:hovering={isHoveringRight}
|
||||
style="
|
||||
left: {$rightButtonCoords.x}px;
|
||||
top: {$rightButtonCoords.y}px;
|
||||
transform: translate(-50%, -50%);
|
||||
"
|
||||
onclick={() => goto(`/photos/${album.slug}/${navigation.nextPhoto.id}`)}
|
||||
type="button"
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<ArrowRight class="nav-icon" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Photo Display -->
|
||||
<main class="photo-main">
|
||||
<div class="photo-container">
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.caption || photo.title || 'Photo'}
|
||||
class="main-photo"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Photo Details -->
|
||||
<aside class="photo-details">
|
||||
<div class="details-content">
|
||||
{#if photo.title}
|
||||
<h1 class="photo-title">{photo.title}</h1>
|
||||
{/if}
|
||||
|
||||
{#if photo.caption}
|
||||
<p class="photo-caption">{photo.caption}</p>
|
||||
{/if}
|
||||
|
||||
{#if photo.description}
|
||||
<p class="photo-description">{photo.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if exif}
|
||||
<div class="photo-exif">
|
||||
<h3>Photo Details</h3>
|
||||
|
||||
{#if exif.camera}
|
||||
<div class="exif-item">
|
||||
<span class="label">Camera</span>
|
||||
<span class="value">{exif.camera}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exif.lens}
|
||||
<div class="exif-item">
|
||||
<span class="label">Lens</span>
|
||||
<span class="value">{exif.lens}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exif.settings}
|
||||
<div class="exif-item">
|
||||
<span class="label">Settings</span>
|
||||
<span class="value">{exif.settings}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exif.location}
|
||||
<div class="exif-item">
|
||||
<span class="label">Location</span>
|
||||
<span class="value">{exif.location}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exif.dateTaken}
|
||||
<div class="exif-item">
|
||||
<span class="label">Date Taken</span>
|
||||
<span class="value">{formatDate(exif.dateTaken)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="photo-actions">
|
||||
<a href="/photos/{album.slug}" class="back-to-album">← Back to {album.title}</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<PhotoMetadata
|
||||
title={photo.title}
|
||||
caption={photo.caption}
|
||||
description={photo.description}
|
||||
{exifData}
|
||||
createdAt={photo.createdAt}
|
||||
backHref={`/photos/${album.slug}`}
|
||||
backLabel={`Back to ${album.title}`}
|
||||
showBackButton={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
:global(main) {
|
||||
padding: 0;
|
||||
}
|
||||
@import '$styles/variables.scss';
|
||||
@import '$styles/mixins.scss';
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
|
|
@ -285,7 +398,7 @@
|
|||
padding: $unit-6x $unit-3x;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
.error-message {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
|
||||
|
|
@ -304,234 +417,100 @@
|
|||
}
|
||||
|
||||
.photo-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'header header'
|
||||
'main details';
|
||||
grid-template-columns: 1fr 400px;
|
||||
grid-template-rows: auto 1fr;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 $unit-3x $unit-4x;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
grid-template-areas:
|
||||
'header'
|
||||
'main'
|
||||
'details';
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: 0 $unit-2x $unit-2x;
|
||||
gap: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-header {
|
||||
grid-area: header;
|
||||
background: $grey-100;
|
||||
border-bottom: 1px solid $grey-90;
|
||||
padding: $unit-3x $unit-4x;
|
||||
.photo-content-wrapper {
|
||||
position: relative;
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// Adjacent Navigation
|
||||
.adjacent-navigation {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-2x;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
align-items: stretch;
|
||||
// Hide on mobile and tablet
|
||||
@include breakpoint('tablet') {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
|
||||
a {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 $unit;
|
||||
}
|
||||
|
||||
.current {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-nav {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
.nav-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: $grey-100;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
padding: $unit $unit-2x;
|
||||
border-radius: $unit;
|
||||
border: 1px solid $grey-85;
|
||||
background: $grey-100;
|
||||
color: $grey-20;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
border-color: $grey-70;
|
||||
&:hover {
|
||||
background: $grey-95;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
&.hovering {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&.prev svg {
|
||||
order: -1;
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 3px $red-60,
|
||||
0 0 0 5px $grey-100;
|
||||
}
|
||||
|
||||
&.next svg {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-main {
|
||||
grid-area: main;
|
||||
background: $grey-95;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $unit-4x;
|
||||
min-height: 60vh;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-container {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main-photo {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: $unit;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
object-fit: contain;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-details {
|
||||
grid-area: details;
|
||||
background: $grey-100;
|
||||
border-left: 1px solid $grey-90;
|
||||
overflow-y: auto;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
border-left: none;
|
||||
border-top: 1px solid $grey-90;
|
||||
}
|
||||
}
|
||||
|
||||
.details-content {
|
||||
padding: $unit-4x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
font-size: 1rem;
|
||||
color: $grey-20;
|
||||
margin: 0 0 $unit-3x;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.photo-description {
|
||||
font-size: 1rem;
|
||||
color: $grey-30;
|
||||
margin: 0 0 $unit-4x;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.photo-exif {
|
||||
margin-bottom: $unit-4x;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.exif-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $unit;
|
||||
gap: $unit-2x;
|
||||
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-50;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-20;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-actions {
|
||||
padding-top: $unit-3x;
|
||||
border-top: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
.back-to-album {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
:global(svg) {
|
||||
stroke: $grey-10;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,19 @@ import type { PageLoad } from './$types'
|
|||
|
||||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
try {
|
||||
const response = await fetch(`/api/photos/${params.albumSlug}/${params.photoId}`)
|
||||
const { albumSlug, photoId } = params
|
||||
const mediaId = parseInt(photoId)
|
||||
|
||||
if (isNaN(mediaId)) {
|
||||
throw new Error('Invalid photo ID')
|
||||
}
|
||||
|
||||
// Fetch the photo and album data with navigation
|
||||
const response = await fetch(`/api/photos/${albumSlug}/${mediaId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Photo not found')
|
||||
throw new Error('Photo or album not found')
|
||||
}
|
||||
throw new Error('Failed to fetch photo')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
const type = $derived(data.type)
|
||||
const album = $derived(data.album)
|
||||
const photo = $derived(data.photo)
|
||||
const error = $derived(data.error)
|
||||
|
||||
// Transform album data to PhotoItem format for PhotoGrid
|
||||
|
|
@ -35,7 +37,7 @@
|
|||
|
||||
// Generate metadata
|
||||
const metaTags = $derived(
|
||||
album
|
||||
type === 'album' && album
|
||||
? generateMetaTags({
|
||||
title: album.title,
|
||||
description:
|
||||
|
|
@ -45,17 +47,25 @@
|
|||
image: album.photos?.[0]?.url,
|
||||
titleFormat: { type: 'by' }
|
||||
})
|
||||
: generateMetaTags({
|
||||
title: 'Album Not Found',
|
||||
description: 'The album you are looking for could not be found.',
|
||||
url: pageUrl,
|
||||
noindex: true
|
||||
})
|
||||
: type === 'photo' && photo
|
||||
? generateMetaTags({
|
||||
title: photo.title || 'Photo',
|
||||
description: photo.description || photo.caption || 'A photograph',
|
||||
url: pageUrl,
|
||||
image: photo.url,
|
||||
titleFormat: { type: 'by' }
|
||||
})
|
||||
: generateMetaTags({
|
||||
title: 'Not Found',
|
||||
description: 'The content you are looking for could not be found.',
|
||||
url: pageUrl,
|
||||
noindex: true
|
||||
})
|
||||
)
|
||||
|
||||
// Generate image gallery JSON-LD
|
||||
const galleryJsonLd = $derived(
|
||||
album
|
||||
type === 'album' && album
|
||||
? generateImageGalleryJsonLd({
|
||||
name: album.title,
|
||||
description: album.description,
|
||||
|
|
@ -66,7 +76,16 @@
|
|||
caption: photo.caption
|
||||
})) || []
|
||||
})
|
||||
: null
|
||||
: type === 'photo' && photo
|
||||
? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ImageObject',
|
||||
name: photo.title || 'Photo',
|
||||
description: photo.description || photo.caption,
|
||||
contentUrl: photo.url,
|
||||
url: pageUrl
|
||||
}
|
||||
: null
|
||||
)
|
||||
</script>
|
||||
|
||||
|
|
@ -96,12 +115,12 @@
|
|||
{#if error}
|
||||
<div class="error-container">
|
||||
<div class="error-message">
|
||||
<h1>Album Not Found</h1>
|
||||
<h1>Not Found</h1>
|
||||
<p>{error}</p>
|
||||
<BackButton href="/photos" label="Back to Photos" />
|
||||
</div>
|
||||
</div>
|
||||
{:else if album}
|
||||
{:else if type === 'album' && album}
|
||||
<div class="album-page">
|
||||
<!-- Album Card -->
|
||||
<div class="album-card">
|
||||
|
|
@ -133,6 +152,32 @@
|
|||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if type === 'photo' && photo}
|
||||
<div class="photo-page">
|
||||
<div class="photo-header">
|
||||
<BackButton href="/photos" label="Back to Photos" />
|
||||
</div>
|
||||
|
||||
<div class="photo-container">
|
||||
<img src={photo.url} alt={photo.title || photo.caption || 'Photo'} class="photo-image" />
|
||||
</div>
|
||||
|
||||
<div class="photo-info">
|
||||
{#if photo.title}
|
||||
<h1 class="photo-title">{photo.title}</h1>
|
||||
{/if}
|
||||
|
||||
{#if photo.caption || photo.description}
|
||||
<p class="photo-description">{photo.caption || photo.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if photo.exifData}
|
||||
<div class="photo-exif">
|
||||
<!-- EXIF data could be displayed here -->
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
|
|
@ -166,7 +211,7 @@
|
|||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: $unit-4x $unit-3x;
|
||||
padding: 0 $unit-3x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x $unit-2x;
|
||||
|
|
@ -240,4 +285,55 @@
|
|||
padding: $unit-6x $unit-3x;
|
||||
color: $grey-40;
|
||||
}
|
||||
|
||||
.photo-page {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: $unit-4x $unit-3x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-header {
|
||||
margin-bottom: $unit-3x;
|
||||
}
|
||||
|
||||
.photo-container {
|
||||
margin-bottom: $unit-4x;
|
||||
text-align: center;
|
||||
|
||||
.photo-image {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: $card-corner-radius;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.photo-info {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
|
||||
.photo-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-10;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-description {
|
||||
font-size: 1rem;
|
||||
color: $grey-30;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 $unit-3x;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -2,30 +2,41 @@ import type { PageLoad } from './$types'
|
|||
|
||||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
try {
|
||||
// Fetch the specific album using the individual album endpoint which includes photos
|
||||
const response = await fetch(`/api/albums/by-slug/${params.slug}`)
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Album not found')
|
||||
// First try to fetch as an album
|
||||
const albumResponse = await fetch(`/api/albums/by-slug/${params.slug}`)
|
||||
if (albumResponse.ok) {
|
||||
const album = await albumResponse.json()
|
||||
|
||||
// Check if this is a photography album and published
|
||||
if (album.isPhotography && album.status === 'published') {
|
||||
return {
|
||||
type: 'album' as const,
|
||||
album,
|
||||
photo: null
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to fetch album')
|
||||
}
|
||||
|
||||
const album = await response.json()
|
||||
|
||||
// Check if this is a photography album and published
|
||||
if (!album.isPhotography || album.status !== 'published') {
|
||||
throw new Error('Album not found')
|
||||
// If not found as album or not a photography album, try as individual photo
|
||||
const photoResponse = await fetch(`/api/photos/by-slug/${params.slug}`)
|
||||
if (photoResponse.ok) {
|
||||
const photo = await photoResponse.json()
|
||||
return {
|
||||
type: 'photo' as const,
|
||||
album: null,
|
||||
photo
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
album
|
||||
}
|
||||
// Neither album nor photo found
|
||||
throw new Error('Content not found')
|
||||
} catch (error) {
|
||||
console.error('Error loading album:', error)
|
||||
console.error('Error loading content:', error)
|
||||
return {
|
||||
type: null,
|
||||
album: null,
|
||||
error: error instanceof Error ? error.message : 'Failed to load album'
|
||||
photo: null,
|
||||
error: error instanceof Error ? error.message : 'Failed to load content'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
537
src/routes/photos/p/[id]/+page.svelte
Normal file
537
src/routes/photos/p/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
<script lang="ts">
|
||||
import BackButton from '$components/BackButton.svelte'
|
||||
import PhotoView from '$components/PhotoView.svelte'
|
||||
import PhotoMetadata from '$components/PhotoMetadata.svelte'
|
||||
import { generateMetaTags } from '$lib/utils/metadata'
|
||||
import { page } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
import { spring } from 'svelte/motion'
|
||||
import { getCurrentMousePosition } from '$lib/stores/mouse'
|
||||
import type { PageData } from './$types'
|
||||
import { isAlbum } from '$lib/types/photos'
|
||||
import ArrowLeft from '$icons/arrow-left.svg'
|
||||
import ArrowRight from '$icons/arrow-right.svg'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
const photo = $derived(data.photo)
|
||||
const error = $derived(data.error)
|
||||
const photoItems = $derived(data.photoItems || [])
|
||||
const currentPhotoId = $derived(data.currentPhotoId)
|
||||
|
||||
// Hover tracking for arrow buttons
|
||||
let isHoveringLeft = $state(false)
|
||||
let isHoveringRight = $state(false)
|
||||
|
||||
// Spring stores for smooth button movement
|
||||
const leftButtonCoords = spring(
|
||||
{ x: 0, y: 0 },
|
||||
{
|
||||
stiffness: 0.3,
|
||||
damping: 0.8
|
||||
}
|
||||
)
|
||||
|
||||
const rightButtonCoords = spring(
|
||||
{ x: 0, y: 0 },
|
||||
{
|
||||
stiffness: 0.3,
|
||||
damping: 0.8
|
||||
}
|
||||
)
|
||||
|
||||
// Default button positions (will be set once photo loads)
|
||||
let defaultLeftX = 0
|
||||
let defaultRightX = 0
|
||||
|
||||
const pageUrl = $derived($page.url.href)
|
||||
|
||||
// Generate metadata
|
||||
const metaTags = $derived(
|
||||
photo
|
||||
? generateMetaTags({
|
||||
title: photo.title || 'Photo',
|
||||
description: photo.description || photo.caption || 'A photograph',
|
||||
url: pageUrl,
|
||||
image: photo.url,
|
||||
titleFormat: { type: 'by' }
|
||||
})
|
||||
: generateMetaTags({
|
||||
title: 'Photo Not Found',
|
||||
description: 'The photo you are looking for could not be found.',
|
||||
url: pageUrl,
|
||||
noindex: true
|
||||
})
|
||||
)
|
||||
|
||||
// Generate JSON-LD for photo
|
||||
const photoJsonLd = $derived(
|
||||
photo
|
||||
? {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ImageObject',
|
||||
name: photo.title || 'Photo',
|
||||
description: photo.description || photo.caption,
|
||||
contentUrl: photo.url,
|
||||
url: pageUrl,
|
||||
dateCreated: photo.createdAt,
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: '@jedmund'
|
||||
}
|
||||
}
|
||||
: null
|
||||
)
|
||||
|
||||
// Parse EXIF data if available
|
||||
const exifData = $derived(
|
||||
photo?.exifData && typeof photo.exifData === 'object' ? photo.exifData : null
|
||||
)
|
||||
|
||||
// Get previous and next photos (excluding albums)
|
||||
const adjacentPhotos = $derived(() => {
|
||||
if (!photoItems.length || !currentPhotoId) return { prev: null, next: null }
|
||||
|
||||
// Filter out albums - we only want photos
|
||||
const photosOnly = photoItems.filter((item) => !isAlbum(item))
|
||||
const currentIndex = photosOnly.findIndex((item) => item.id === currentPhotoId)
|
||||
|
||||
if (currentIndex === -1) return { prev: null, next: null }
|
||||
|
||||
return {
|
||||
prev: currentIndex > 0 ? photosOnly[currentIndex - 1] : null,
|
||||
next: currentIndex < photosOnly.length - 1 ? photosOnly[currentIndex + 1] : null
|
||||
}
|
||||
})
|
||||
|
||||
// Handle photo navigation
|
||||
function navigateToPhoto(item: any) {
|
||||
if (!item) return
|
||||
// Extract media ID from item.id (could be 'media-123' or 'photo-123')
|
||||
const mediaId = item.id.replace(/^(media|photo)-/, '')
|
||||
goto(`/photos/p/${mediaId}`)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Arrow key navigation for photos
|
||||
if (e.key === 'ArrowLeft' && adjacentPhotos().prev) {
|
||||
navigateToPhoto(adjacentPhotos().prev)
|
||||
} else if (e.key === 'ArrowRight' && adjacentPhotos().next) {
|
||||
navigateToPhoto(adjacentPhotos().next)
|
||||
}
|
||||
}
|
||||
|
||||
// Set default button positions when component mounts
|
||||
$effect(() => {
|
||||
if (!photo) return
|
||||
|
||||
// Wait for DOM to update and image to load
|
||||
const checkAndSetPositions = () => {
|
||||
const pageContainer = document.querySelector('.photo-page') as HTMLElement
|
||||
const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement
|
||||
|
||||
if (photoImage && photoImage.complete) {
|
||||
const imageRect = photoImage.getBoundingClientRect()
|
||||
const pageRect = pageContainer.getBoundingClientRect()
|
||||
|
||||
// Calculate default positions relative to the image
|
||||
// Add 24px (half button width) since we're using translate(-50%, -50%)
|
||||
defaultLeftX = imageRect.left - pageRect.left - 24 - 16 // half button width + gap
|
||||
defaultRightX = imageRect.right - pageRect.left + 24 + 16 // half button width + gap
|
||||
|
||||
// Set initial positions at the vertical center of the image
|
||||
const centerY = imageRect.top - pageRect.top + imageRect.height / 2
|
||||
leftButtonCoords.set({ x: defaultLeftX, y: centerY }, { hard: true })
|
||||
rightButtonCoords.set({ x: defaultRightX, y: centerY }, { hard: true })
|
||||
|
||||
// Check if mouse is already in a hover zone
|
||||
// Small delay to ensure mouse store is initialized
|
||||
setTimeout(() => {
|
||||
checkInitialMousePosition(pageContainer, imageRect, pageRect)
|
||||
}, 10)
|
||||
} else {
|
||||
// If image not loaded yet, try again
|
||||
setTimeout(checkAndSetPositions, 50)
|
||||
}
|
||||
}
|
||||
|
||||
checkAndSetPositions()
|
||||
})
|
||||
|
||||
// Check mouse position on load
|
||||
function checkInitialMousePosition(
|
||||
pageContainer: HTMLElement,
|
||||
imageRect: DOMRect,
|
||||
pageRect: DOMRect
|
||||
) {
|
||||
// Get current mouse position from store
|
||||
const currentPos = getCurrentMousePosition()
|
||||
|
||||
// If no mouse position tracked yet, try to trigger one
|
||||
if (currentPos.x === 0 && currentPos.y === 0) {
|
||||
// Set up a one-time listener for the first mouse move
|
||||
const handleFirstMove = (e: MouseEvent) => {
|
||||
const x = e.clientX
|
||||
const mouseX = e.clientX - pageRect.left
|
||||
const mouseY = e.clientY - pageRect.top
|
||||
|
||||
// Check if mouse is in hover zones
|
||||
if (x < imageRect.left) {
|
||||
isHoveringLeft = true
|
||||
leftButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
|
||||
} else if (x > imageRect.right) {
|
||||
isHoveringRight = true
|
||||
rightButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
|
||||
}
|
||||
|
||||
// Remove the listener
|
||||
window.removeEventListener('mousemove', handleFirstMove)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', handleFirstMove)
|
||||
return
|
||||
}
|
||||
|
||||
// We have a mouse position, check if it's in a hover zone
|
||||
const x = currentPos.x
|
||||
const mouseX = currentPos.x - pageRect.left
|
||||
const mouseY = currentPos.y - pageRect.top
|
||||
|
||||
// Store client coordinates for scroll updates
|
||||
lastClientX = currentPos.x
|
||||
lastClientY = currentPos.y
|
||||
|
||||
// Check if mouse is in hover zones
|
||||
if (x < imageRect.left) {
|
||||
isHoveringLeft = true
|
||||
leftButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
|
||||
} else if (x > imageRect.right) {
|
||||
isHoveringRight = true
|
||||
rightButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Track last known mouse position for scroll updates
|
||||
let lastMouseX = 0
|
||||
let lastMouseY = 0
|
||||
|
||||
// Store last mouse client position for scroll updates
|
||||
let lastClientX = 0
|
||||
let lastClientY = 0
|
||||
|
||||
// Update button positions during scroll
|
||||
function handleScroll() {
|
||||
if (!isHoveringLeft && !isHoveringRight) return
|
||||
|
||||
const pageContainer = document.querySelector('.photo-page') as HTMLElement
|
||||
if (!pageContainer) return
|
||||
|
||||
// Use last known mouse position (which is viewport-relative)
|
||||
// and recalculate relative to the page container's new position
|
||||
const pageRect = pageContainer.getBoundingClientRect()
|
||||
const mouseX = lastClientX - pageRect.left
|
||||
const mouseY = lastClientY - pageRect.top
|
||||
|
||||
// Update button positions
|
||||
if (isHoveringLeft) {
|
||||
leftButtonCoords.set({ x: mouseX, y: mouseY })
|
||||
}
|
||||
|
||||
if (isHoveringRight) {
|
||||
rightButtonCoords.set({ x: mouseX, y: mouseY })
|
||||
}
|
||||
}
|
||||
|
||||
// Mouse tracking for hover areas
|
||||
function handleMouseMove(event: MouseEvent) {
|
||||
const pageContainer = event.currentTarget as HTMLElement
|
||||
const photoWrapper = pageContainer.querySelector('.photo-content-wrapper') as HTMLElement
|
||||
|
||||
if (!photoWrapper) return
|
||||
|
||||
// Get the actual image element inside PhotoView
|
||||
const photoImage = photoWrapper.querySelector('img') as HTMLElement
|
||||
if (!photoImage) return
|
||||
|
||||
const pageRect = pageContainer.getBoundingClientRect()
|
||||
const photoRect = photoImage.getBoundingClientRect()
|
||||
|
||||
const x = event.clientX
|
||||
const mouseX = event.clientX - pageRect.left
|
||||
const mouseY = event.clientY - pageRect.top
|
||||
|
||||
// Store last mouse position for scroll updates
|
||||
lastClientX = event.clientX
|
||||
lastClientY = event.clientY
|
||||
|
||||
// Check if mouse is in the left or right margin (outside the photo)
|
||||
const wasHoveringLeft = isHoveringLeft
|
||||
const wasHoveringRight = isHoveringRight
|
||||
|
||||
isHoveringLeft = x < photoRect.left
|
||||
isHoveringRight = x > photoRect.right
|
||||
|
||||
// Calculate image center Y position
|
||||
const imageCenterY = photoRect.top - pageRect.top + photoRect.height / 2
|
||||
|
||||
// Update button positions
|
||||
if (isHoveringLeft) {
|
||||
leftButtonCoords.set({ x: mouseX, y: mouseY })
|
||||
} else if (wasHoveringLeft && !isHoveringLeft) {
|
||||
// Reset left button to default
|
||||
leftButtonCoords.set({ x: defaultLeftX, y: imageCenterY })
|
||||
}
|
||||
|
||||
if (isHoveringRight) {
|
||||
rightButtonCoords.set({ x: mouseX, y: mouseY })
|
||||
} else if (wasHoveringRight && !isHoveringRight) {
|
||||
// Reset right button to default
|
||||
rightButtonCoords.set({ x: defaultRightX, y: imageCenterY })
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
isHoveringLeft = false
|
||||
isHoveringRight = false
|
||||
|
||||
// Reset buttons to default positions
|
||||
const pageContainer = document.querySelector('.photo-page') as HTMLElement
|
||||
const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement
|
||||
|
||||
if (photoImage && pageContainer) {
|
||||
const imageRect = photoImage.getBoundingClientRect()
|
||||
const pageRect = pageContainer.getBoundingClientRect()
|
||||
const centerY = imageRect.top - pageRect.top + imageRect.height / 2
|
||||
|
||||
leftButtonCoords.set({ x: defaultLeftX, y: centerY })
|
||||
rightButtonCoords.set({ x: defaultRightX, y: centerY })
|
||||
}
|
||||
}
|
||||
|
||||
// Set up keyboard and scroll listeners
|
||||
$effect(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{metaTags.title}</title>
|
||||
<meta name="description" content={metaTags.description} />
|
||||
|
||||
<!-- OpenGraph -->
|
||||
{#each Object.entries(metaTags.openGraph) as [property, content]}
|
||||
<meta property="og:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Twitter Card -->
|
||||
{#each Object.entries(metaTags.twitter) as [property, content]}
|
||||
<meta name="twitter:{property}" {content} />
|
||||
{/each}
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={metaTags.other.canonical} />
|
||||
|
||||
<!-- JSON-LD -->
|
||||
{#if photoJsonLd}
|
||||
{@html `<script type="application/ld+json">${JSON.stringify(photoJsonLd)}</script>`}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
{#if error}
|
||||
<div class="error-container">
|
||||
<div class="error-message">
|
||||
<h1>Photo Not Found</h1>
|
||||
<p>{error}</p>
|
||||
<BackButton href="/photos" label="Back to Photos" />
|
||||
</div>
|
||||
</div>
|
||||
{:else if photo}
|
||||
<div class="photo-page" onmousemove={handleMouseMove} onmouseleave={handleMouseLeave}>
|
||||
<div class="photo-content-wrapper">
|
||||
<PhotoView src={photo.url} alt={photo.caption} title={photo.title} id={photo.id} />
|
||||
</div>
|
||||
|
||||
<!-- Adjacent Photos Navigation -->
|
||||
<div class="adjacent-navigation">
|
||||
{#if adjacentPhotos().prev}
|
||||
<button
|
||||
class="nav-button prev"
|
||||
class:hovering={isHoveringLeft}
|
||||
style="
|
||||
left: {$leftButtonCoords.x}px;
|
||||
top: {$leftButtonCoords.y}px;
|
||||
transform: translate(-50%, -50%);
|
||||
"
|
||||
onclick={() => navigateToPhoto(adjacentPhotos().prev)}
|
||||
type="button"
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<ArrowLeft class="nav-icon" />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if adjacentPhotos().next}
|
||||
<button
|
||||
class="nav-button next"
|
||||
class:hovering={isHoveringRight}
|
||||
style="
|
||||
left: {$rightButtonCoords.x}px;
|
||||
top: {$rightButtonCoords.y}px;
|
||||
transform: translate(-50%, -50%);
|
||||
"
|
||||
onclick={() => navigateToPhoto(adjacentPhotos().next)}
|
||||
type="button"
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<ArrowRight class="nav-icon" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<PhotoMetadata
|
||||
title={photo.title}
|
||||
caption={photo.caption}
|
||||
description={photo.description}
|
||||
{exifData}
|
||||
createdAt={photo.createdAt}
|
||||
backHref={photo.album ? `/photos/${photo.album.slug}` : '/photos'}
|
||||
backLabel={photo.album ? `Back to ${photo.album.title}` : 'Back to Photos'}
|
||||
showBackButton={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
@import '$styles/mixins.scss';
|
||||
.error-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
padding: $unit-6x $unit-3x;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $red-60;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-40;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-page {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 $unit-3x $unit-4x;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: 0 $unit-2x $unit-2x;
|
||||
gap: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-content-wrapper {
|
||||
position: relative;
|
||||
max-width: 700px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// Adjacent Navigation
|
||||
.adjacent-navigation {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
|
||||
// Hide on mobile and tablet
|
||||
@include breakpoint('tablet') {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
pointer-events: auto;
|
||||
position: absolute;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: $grey-100;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-95;
|
||||
}
|
||||
|
||||
&.hovering {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 3px $red-60,
|
||||
0 0 0 5px $grey-100;
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
stroke: $grey-10;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke-width: 2px;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
42
src/routes/photos/p/[id]/+page.ts
Normal file
42
src/routes/photos/p/[id]/+page.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
try {
|
||||
const mediaId = parseInt(params.id)
|
||||
if (isNaN(mediaId)) {
|
||||
throw new Error('Invalid media ID')
|
||||
}
|
||||
|
||||
// Fetch the photo by media ID
|
||||
const photoResponse = await fetch(`/api/photos/${mediaId}`)
|
||||
if (!photoResponse.ok) {
|
||||
if (photoResponse.status === 404) {
|
||||
throw new Error('Photo not found')
|
||||
}
|
||||
throw new Error('Failed to fetch photo')
|
||||
}
|
||||
|
||||
const photo = await photoResponse.json()
|
||||
|
||||
// Fetch all photos for the filmstrip navigation
|
||||
const allPhotosResponse = await fetch('/api/photos?limit=100')
|
||||
let photoItems = []
|
||||
if (allPhotosResponse.ok) {
|
||||
const data = await allPhotosResponse.json()
|
||||
photoItems = data.photoItems || []
|
||||
}
|
||||
|
||||
return {
|
||||
photo,
|
||||
photoItems,
|
||||
currentPhotoId: `media-${mediaId}` // Updated to use media prefix
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading photo:', error)
|
||||
return {
|
||||
photo: null,
|
||||
photoItems: [],
|
||||
error: error instanceof Error ? error.message : 'Failed to load photo'
|
||||
}
|
||||
}
|
||||
}
|
||||
15
test-db.ts
Normal file
15
test-db.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
async function testDb() {
|
||||
try {
|
||||
const count = await prisma.media.count()
|
||||
console.log('Total media entries:', count)
|
||||
await prisma.$disconnect()
|
||||
} catch (error) {
|
||||
console.error('Database error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
testDb()
|
||||
|
|
@ -60,10 +60,7 @@ export default defineConfig({
|
|||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `
|
||||
@import './src/assets/styles/variables.scss';
|
||||
@import './src/assets/styles/fonts.scss';
|
||||
@import './src/assets/styles/themes.scss';
|
||||
@import './src/assets/styles/globals.scss';
|
||||
@import './src/assets/styles/imports.scss';
|
||||
`,
|
||||
api: 'modern-compiler'
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue