Merge pull request #6 from jedmund/refine/admin

Refine admin interface
This commit is contained in:
Justin Edmund 2025-06-13 12:16:14 -07:00 committed by GitHub
commit 0a12fe0d39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 5747 additions and 1925 deletions

15
package-lock.json generated
View file

@ -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",

View file

@ -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",

View 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

View 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)

View file

@ -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;

View file

@ -0,0 +1,2 @@
-- This migration was already applied manually or is empty
-- Placeholder file to satisfy Prisma migration requirements

View 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

View file

@ -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])
}

View 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
View 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)

View 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" != '';

View 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
View 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()

View 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
View file

@ -0,0 +1,3 @@
/* Global styles for the entire application */
@import './assets/styles/reset.css';
@import './assets/styles/globals.scss';

View 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';

View file

@ -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;

View file

@ -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

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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}

View file

@ -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}`)
}
}
}

View 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>

View 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>

View file

@ -17,7 +17,7 @@
</script>
<a {href} class="pill {variant}" class:active>
<svelte:component this={icon} />
<icon />
<span>{text}</span>
</a>

View file

@ -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>

View file

@ -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>

View file

@ -14,7 +14,7 @@
let {
media,
alt = media.altText || media.filename || '',
alt = media.description || media.filename || '',
class: className = '',
containerWidth,
loading = 'lazy',

View file

@ -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">

View file

@ -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;

View file

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

View file

@ -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>

View file

@ -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;

View file

@ -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%;

View file

@ -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;

View file

@ -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>

View file

@ -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;

View file

@ -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>

View file

@ -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;

View file

@ -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 {

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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
View 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)
}

View file

@ -17,6 +17,7 @@ export interface Photo {
width: number
height: number
exif?: ExifData
createdAt?: string
}
export interface PhotoAlbum {

View file

@ -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);

View file

@ -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;

View file

@ -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;

View file

@ -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>

View file

@ -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;
}
}

View file

@ -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';

View file

@ -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;

View file

@ -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;

View file

@ -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 -->

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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)

View file

@ -65,7 +65,9 @@ export const GET: RequestHandler = async (event) => {
height: true,
usedIn: true,
isPhotography: true,
createdAt: true
createdAt: true,
description: true,
exifData: true
}
})

View file

@ -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)

View file

@ -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({

View file

@ -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

View file

@ -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()
})

View file

@ -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) {

View file

@ -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) {

View 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
)
}
}

View file

@ -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>

View file

@ -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')
}

View file

@ -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>

View file

@ -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'
}
}
}

View 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>

View 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
View 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()

View file

@ -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'
}