diff --git a/package-lock.json b/package-lock.json index 2f2d8f3..0c1c7eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 5d67226..3bc6a95 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prd/PRD-dominant-color-extraction.md b/prd/PRD-dominant-color-extraction.md new file mode 100644 index 0000000..7b482f1 --- /dev/null +++ b/prd/PRD-dominant-color-extraction.md @@ -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 diff --git a/prd/PRD-og-image-generation.md b/prd/PRD-og-image-generation.md new file mode 100644 index 0000000..1d939ef --- /dev/null +++ b/prd/PRD-og-image-generation.md @@ -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) diff --git a/prisma/migrations/20250612104142_add_media_reference_to_photo/migration.sql b/prisma/migrations/20250612104142_add_media_reference_to_photo/migration.sql new file mode 100644 index 0000000..d53f996 --- /dev/null +++ b/prisma/migrations/20250612104142_add_media_reference_to_photo/migration.sql @@ -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; diff --git a/prisma/migrations/20250613060835_simplify_photo_architecture/migration.sql b/prisma/migrations/20250613060835_simplify_photo_architecture/migration.sql new file mode 100644 index 0000000..5990d5a --- /dev/null +++ b/prisma/migrations/20250613060835_simplify_photo_architecture/migration.sql @@ -0,0 +1,2 @@ +-- This migration was already applied manually or is empty +-- Placeholder file to satisfy Prisma migration requirements \ No newline at end of file diff --git a/prisma/migrations/migrate-photos-to-media.sql b/prisma/migrations/migrate-photos-to-media.sql new file mode 100644 index 0000000..d2e44bf --- /dev/null +++ b/prisma/migrations/migrate-photos-to-media.sql @@ -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 \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cfa7d1f..42ef646 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) } \ No newline at end of file diff --git a/scripts/check-photos-display.ts b/scripts/check-photos-display.ts new file mode 100644 index 0000000..9514e8c --- /dev/null +++ b/scripts/check-photos-display.ts @@ -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 + ) + + 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 + ) + + 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() diff --git a/scripts/debug-photos.md b/scripts/debug-photos.md new file mode 100644 index 0000000..74ecf0c --- /dev/null +++ b/scripts/debug-photos.md @@ -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) diff --git a/scripts/migrate-alttext-to-description.sql b/scripts/migrate-alttext-to-description.sql new file mode 100644 index 0000000..85611fc --- /dev/null +++ b/scripts/migrate-alttext-to-description.sql @@ -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" != ''; \ No newline at end of file diff --git a/scripts/migrate-photos-to-media.ts b/scripts/migrate-photos-to-media.ts new file mode 100644 index 0000000..474687e --- /dev/null +++ b/scripts/migrate-photos-to-media.ts @@ -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() diff --git a/scripts/test-media-sharing.ts b/scripts/test-media-sharing.ts new file mode 100755 index 0000000..6caf9e9 --- /dev/null +++ b/scripts/test-media-sharing.ts @@ -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() diff --git a/scripts/test-photos-query.ts b/scripts/test-photos-query.ts new file mode 100644 index 0000000..f229105 --- /dev/null +++ b/scripts/test-photos-query.ts @@ -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() diff --git a/src/app.css b/src/app.css new file mode 100644 index 0000000..447401c --- /dev/null +++ b/src/app.css @@ -0,0 +1,3 @@ +/* Global styles for the entire application */ +@import './assets/styles/reset.css'; +@import './assets/styles/globals.scss'; \ No newline at end of file diff --git a/src/assets/styles/imports.scss b/src/assets/styles/imports.scss new file mode 100644 index 0000000..5b45e44 --- /dev/null +++ b/src/assets/styles/imports.scss @@ -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'; \ No newline at end of file diff --git a/src/assets/styles/variables.scss b/src/assets/styles/variables.scss index ea65a88..c2786cc 100644 --- a/src/assets/styles/variables.scss +++ b/src/assets/styles/variables.scss @@ -58,13 +58,15 @@ $mention-padding: $unit-3x; $font-stack: 'Circular Std', 'Helvetica Neue', Helvetica, Arial, sans-serif; -$font-unit: 14px; +$font-unit: 18px; +$font-unit-mobile: 16px; -$font-size-small: 0.7rem; // 10 -$font-size: 1rem; // 14 -$font-size-med: 1.25rem; // 16 -$font-size-large: 1.4rem; // 18 -$font-size-xlarge: 1.65rem; // 22 +$font-size-extra-small: 0.75rem; // 12 +$font-size-small: 0.875rem; // 14 +$font-size: 1rem; // 18 +$font-size-med: 1.25rem; // 20 +$font-size-large: 1.4rem; // 22 +$font-size-xlarge: 1.65rem; // 26 $font-weight: 400; $font-weight-med: 500; diff --git a/src/lib/admin-auth.ts b/src/lib/admin-auth.ts index a89ff9b..c33ccf2 100644 --- a/src/lib/admin-auth.ts +++ b/src/lib/admin-auth.ts @@ -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 diff --git a/src/lib/components/Album.svelte b/src/lib/components/Album.svelte index de62b78..02624fb 100644 --- a/src/lib/components/Album.svelte +++ b/src/lib/components/Album.svelte @@ -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; } diff --git a/src/lib/components/Game.svelte b/src/lib/components/Game.svelte index cb76743..214faf1 100644 --- a/src/lib/components/Game.svelte +++ b/src/lib/components/Game.svelte @@ -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; } diff --git a/src/lib/components/NavDropdown.svelte b/src/lib/components/NavDropdown.svelte index 57f4996..427b7ae 100644 --- a/src/lib/components/NavDropdown.svelte +++ b/src/lib/components/NavDropdown.svelte @@ -112,7 +112,7 @@ aria-haspopup="true" style="color: {getTextColor(activeItem.variant)};" > - + {activeItem.text} @@ -126,7 +126,7 @@ class:active={item === activeItem} onclick={() => (isOpen = false)} > - + {item.text} {/each} diff --git a/src/lib/components/PhotoItem.svelte b/src/lib/components/PhotoItem.svelte index 604ecf7..8671d88 100644 --- a/src/lib/components/PhotoItem.svelte +++ b/src/lib/components/PhotoItem.svelte @@ -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}`) } } } diff --git a/src/lib/components/PhotoMetadata.svelte b/src/lib/components/PhotoMetadata.svelte new file mode 100644 index 0000000..c649fd6 --- /dev/null +++ b/src/lib/components/PhotoMetadata.svelte @@ -0,0 +1,230 @@ + + + + + diff --git a/src/lib/components/PhotoView.svelte b/src/lib/components/PhotoView.svelte new file mode 100644 index 0000000..e22996d --- /dev/null +++ b/src/lib/components/PhotoView.svelte @@ -0,0 +1,55 @@ + + +
+ {#key id || src} + + {title + + {/key} +
+ + diff --git a/src/lib/components/Pill.svelte b/src/lib/components/Pill.svelte index 956f340..85cd789 100644 --- a/src/lib/components/Pill.svelte +++ b/src/lib/components/Pill.svelte @@ -17,7 +17,7 @@ - + {text} diff --git a/src/lib/components/SVGHoverEffect.svelte b/src/lib/components/SVGHoverEffect.svelte index 5319ae2..61d9b53 100644 --- a/src/lib/components/SVGHoverEffect.svelte +++ b/src/lib/components/SVGHoverEffect.svelte @@ -62,7 +62,7 @@ style="position: relative; overflow: hidden; background-color: {backgroundColor}; height: {containerHeight}; display: flex; justify-content: center; align-items: center;" >
- +
diff --git a/src/lib/components/SegmentedController.svelte b/src/lib/components/SegmentedController.svelte index 8b93256..c6a28ba 100644 --- a/src/lib/components/SegmentedController.svelte +++ b/src/lib/components/SegmentedController.svelte @@ -119,8 +119,7 @@ onmouseenter={() => (hoveredIndex = index)} onmouseleave={() => (hoveredIndex = null)} > - {item.text} diff --git a/src/lib/components/SmartImage.svelte b/src/lib/components/SmartImage.svelte index 31a56fe..d67489b 100644 --- a/src/lib/components/SmartImage.svelte +++ b/src/lib/components/SmartImage.svelte @@ -14,7 +14,7 @@ let { media, - alt = media.altText || media.filename || '', + alt = media.description || media.filename || '', class: className = '', containerWidth, loading = 'lazy', diff --git a/src/lib/components/admin/AlbumForm.svelte b/src/lib/components/admin/AlbumForm.svelte index 1e0e6b8..9af4d65 100644 --- a/src/lib/components/admin/AlbumForm.svelte +++ b/src/lib/components/admin/AlbumForm.svelte @@ -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({ type: 'doc', content: [] }) let gallery = $state([]) 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} /> + +
diff --git a/src/lib/components/admin/Button.svelte b/src/lib/components/admin/Button.svelte index 55f9aaf..00b34b0 100644 --- a/src/lib/components/admin/Button.svelte +++ b/src/lib/components/admin/Button.svelte @@ -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; diff --git a/src/lib/components/admin/CaseStudyEditor.svelte b/src/lib/components/admin/CaseStudyEditor.svelte new file mode 100644 index 0000000..09aab5a --- /dev/null +++ b/src/lib/components/admin/CaseStudyEditor.svelte @@ -0,0 +1,162 @@ + + +
+ +
+ + diff --git a/src/lib/components/admin/DataTable.svelte b/src/lib/components/admin/DataTable.svelte deleted file mode 100644 index f8de45c..0000000 --- a/src/lib/components/admin/DataTable.svelte +++ /dev/null @@ -1,170 +0,0 @@ - - -
- {#if isLoading} -
-
-

Loading...

-
- {:else if data.length === 0} -
-

{emptyMessage}

-
- {:else} - - - - {#each columns as column} - - {/each} - - - - {#each data as item} - onRowClick?.(item)}> - {#each columns as column} - - {/each} - - {/each} - -
- {column.label} -
- {#if column.component} - - {:else} - {getCellValue(item, column)} - {/if} -
- {/if} -
- - diff --git a/src/lib/components/admin/Editor.svelte b/src/lib/components/admin/Editor.svelte index 18e7d34..d829141 100644 --- a/src/lib/components/admin/Editor.svelte +++ b/src/lib/components/admin/Editor.svelte @@ -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; diff --git a/src/lib/components/admin/EditorWithUpload.svelte b/src/lib/components/admin/EditorWithUpload.svelte index 554580c..6dc0074 100644 --- a/src/lib/components/admin/EditorWithUpload.svelte +++ b/src/lib/components/admin/EditorWithUpload.svelte @@ -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} >
@@ -724,7 +729,7 @@ {/if} - diff --git a/src/lib/components/admin/Modal.svelte b/src/lib/components/admin/Modal.svelte index 23bfeee..92932fd 100644 --- a/src/lib/components/admin/Modal.svelte +++ b/src/lib/components/admin/Modal.svelte @@ -1,19 +1,29 @@ {#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; diff --git a/src/lib/components/admin/PostListItem.svelte b/src/lib/components/admin/PostListItem.svelte index 48bff7a..76c016c 100644 --- a/src/lib/components/admin/PostListItem.svelte +++ b/src/lib/components/admin/PostListItem.svelte @@ -1,5 +1,6 @@
- {#if post.title} -

{post.title}

- {/if} +
+ {#if post.title} +

{post.title}

+ {/if} -
-

{getPostSnippet(post)}

+
+

{getPostSnippet(post)}

+
+ +
- +
diff --git a/src/lib/components/admin/SimplePostForm.svelte b/src/lib/components/admin/SimplePostForm.svelte index 833f7b1..2b1c0a5 100644 --- a/src/lib/components/admin/SimplePostForm.svelte +++ b/src/lib/components/admin/SimplePostForm.svelte @@ -336,7 +336,7 @@ .title-input { width: 100%; - padding: $unit-3x; + padding: $unit-4x; border: none; background: transparent; font-size: 1rem; diff --git a/src/lib/components/admin/StatusDropdown.svelte b/src/lib/components/admin/StatusDropdown.svelte index 1c12b2e..34f9129 100644 --- a/src/lib/components/admin/StatusDropdown.svelte +++ b/src/lib/components/admin/StatusDropdown.svelte @@ -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)
@@ -74,7 +79,7 @@ {isLoading ? `${primaryAction.label.replace(/e$/, 'ing')}...` : primaryAction.label} - {#if availableActions.length > 0} + {#if hasDropdownContent}
- { @@ -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 @@
{:else}
- { @@ -448,9 +447,9 @@ characterCount = getTextFromContent(newContent) }} placeholder="Start writing your essay..." - simpleMode={false} - autofocus={true} minHeight={500} + autofocus={true} + mode="default" />
{/if} @@ -484,7 +483,7 @@
- { @@ -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; diff --git a/src/lib/stores/mouse.ts b/src/lib/stores/mouse.ts new file mode 100644 index 0000000..6d906a0 --- /dev/null +++ b/src/lib/stores/mouse.ts @@ -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) +} diff --git a/src/lib/types/photos.ts b/src/lib/types/photos.ts index fe03a98..3acbbc0 100644 --- a/src/lib/types/photos.ts +++ b/src/lib/types/photos.ts @@ -17,6 +17,7 @@ export interface Photo { width: number height: number exif?: ExifData + createdAt?: string } export interface PhotoAlbum { diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 3d6cf30..aadbbf4 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,23 +1,28 @@ @@ -31,7 +36,7 @@ {/if}
- + {@render children()}
{#if !isAdminRoute} @@ -40,9 +45,6 @@
diff --git a/src/routes/admin/media/upload/+page.svelte b/src/routes/admin/media/upload/+page.svelte index 32514b9..ade69cc 100644 --- a/src/routes/admin/media/upload/+page.svelte +++ b/src/routes/admin/media/upload/+page.svelte @@ -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([]) @@ -147,11 +146,116 @@
+ + {#if files.length > 0} +
+
+

Files to Upload

+
+ + +
+
+ +
+ {#each files as file, index} +
+
+ {#if file.type.startsWith('image/')} + {file.name} + {:else} +
📄
+ {/if} +
+ +
+
{file.name}
+
{formatFileSize(file.size)}
+ + {#if isUploading} +
+
+
+
+ {#if uploadProgress[file.name] === 100} + ✓ Complete + {:else if uploadProgress[file.name] > 0} + {Math.round(uploadProgress[file.name] || 0)}% + {:else} + Waiting... + {/if} +
+ {/if} +
+ + {#if !isUploading} + + {/if} +
+ {/each} +
+
+ {/if} +
0} + class:compact={files.length > 0} + class:uploading={isUploading} ondragover={handleDragOver} ondragleave={handleDragLeave} ondrop={handleDrop} @@ -213,9 +317,35 @@

or click to browse and select files

Supports JPG, PNG, GIF, WebP, and SVG files

{:else} -
- {files.length} file{files.length !== 1 ? 's' : ''} selected -

Drop more files to add them, or click to browse

+
+ + + + + Add more files or drop them here
{/if}
@@ -239,84 +369,6 @@
- - {#if files.length > 0} -
-
-

Files to Upload

-
- - -
-
- -
- {#each files as file, index} -
-
- {#if file.type.startsWith('image/')} - {file.name} - {:else} -
📄
- {/if} -
- -
-
{file.name}
-
{formatFileSize(file.size)}
- - {#if uploadProgress[file.name]} -
-
-
- {/if} -
- - {#if !isUploading} - - {/if} -
- {/each} -
-
- {/if} - {#if successCount > 0 || uploadErrors.length > 0}
@@ -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; } } diff --git a/src/routes/admin/posts/+page.svelte b/src/routes/admin/posts/+page.svelte index d4bd43e..3747458 100644 --- a/src/routes/admin/posts/+page.svelte +++ b/src/routes/admin/posts/+page.svelte @@ -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('all') let selectedStatusFilter = $state('all') + let sortBy = $state('newest') // Composer state let showInlineComposer = $state(true) let isInteractingWithFilters = $state(false) + // Delete confirmation state + let showDeleteConfirmation = $state(false) + let postToDelete = $state(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 = { 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) + } + } - + + {#snippet actions()} + + {/snippet} + {#if error}
{error}
@@ -192,6 +309,15 @@ onchange={handleStatusFilterChange} /> {/snippet} + {#snippet right()} + + {/snippet} diff --git a/src/routes/api/albums/[id]/+server.ts b/src/routes/api/albums/[id]/+server.ts index b034c62..d2f62bc 100644 --- a/src/routes/api/albums/[id]/+server.ts +++ b/src/routes/api/albums/[id]/+server.ts @@ -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) { diff --git a/src/routes/api/albums/[id]/photos/+server.ts b/src/routes/api/albums/[id]/photos/+server.ts index 57fe259..996d362 100644 --- a/src/routes/api/albums/[id]/photos/+server.ts +++ b/src/routes/api/albums/[id]/photos/+server.ts @@ -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) } } diff --git a/src/routes/api/albums/by-slug/[slug]/+server.ts b/src/routes/api/albums/by-slug/[slug]/+server.ts index 8937091..0546763 100644 --- a/src/routes/api/albums/by-slug/[slug]/+server.ts +++ b/src/routes/api/albums/by-slug/[slug]/+server.ts @@ -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) diff --git a/src/routes/api/media/+server.ts b/src/routes/api/media/+server.ts index 86f520c..35f85ec 100644 --- a/src/routes/api/media/+server.ts +++ b/src/routes/api/media/+server.ts @@ -65,7 +65,9 @@ export const GET: RequestHandler = async (event) => { height: true, usedIn: true, isPhotography: true, - createdAt: true + createdAt: true, + description: true, + exifData: true } }) diff --git a/src/routes/api/media/[id]/+server.ts b/src/routes/api/media/[id]/+server.ts index 2cdd381..44c1761 100644 --- a/src/routes/api/media/[id]/+server.ts +++ b/src/routes/api/media/[id]/+server.ts @@ -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) diff --git a/src/routes/api/media/bulk-upload/+server.ts b/src/routes/api/media/bulk-upload/+server.ts index 066c697..f521e5b 100644 --- a/src/routes/api/media/bulk-upload/+server.ts +++ b/src/routes/api/media/bulk-upload/+server.ts @@ -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({ diff --git a/src/routes/api/media/upload/+server.ts b/src/routes/api/media/upload/+server.ts index 9eac994..2106f0c 100644 --- a/src/routes/api/media/upload/+server.ts +++ b/src/routes/api/media/upload/+server.ts @@ -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 diff --git a/src/routes/api/photos/+server.ts b/src/routes/api/photos/+server.ts index 9f646cc..5a5b4e1 100644 --- a/src/routes/api/photos/+server.ts +++ b/src/routes/api/photos/+server.ts @@ -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() }) diff --git a/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts b/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts index 53942f2..81a3c9f 100644 --- a/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts +++ b/src/routes/api/photos/[albumSlug]/[photoId]/+server.ts @@ -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) { diff --git a/src/routes/api/photos/[id]/+server.ts b/src/routes/api/photos/[id]/+server.ts index 3ecccdc..4667e5b 100644 --- a/src/routes/api/photos/[id]/+server.ts +++ b/src/routes/api/photos/[id]/+server.ts @@ -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) { diff --git a/src/routes/api/test-photos/+server.ts b/src/routes/api/test-photos/+server.ts new file mode 100644 index 0000000..8870ae4 --- /dev/null +++ b/src/routes/api/test-photos/+server.ts @@ -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 + ) + } +} diff --git a/src/routes/photos/[albumSlug]/[photoId]/+page.svelte b/src/routes/photos/[albumSlug]/[photoId]/+page.svelte index 84825dd..ca87807 100644 --- a/src/routes/photos/[albumSlug]/[photoId]/+page.svelte +++ b/src/routes/photos/[albumSlug]/[photoId]/+page.svelte @@ -1,8 +1,15 @@ @@ -119,163 +324,71 @@ {#if error || !photo || !album}
-
+

Photo Not Found

{error || "The photo you're looking for doesn't exist."}

{:else} -
- -
- +
+
+ +
-
- {#if navigation.prevPhoto} - - {:else} - - {/if} + +
+ {#if navigation.prevPhoto} + + {/if} - {#if navigation.nextPhoto} - - {:else} - - {/if} -
-
+ {#if navigation.nextPhoto} + + {/if} +
- -
-
- {photo.caption -
-
- - - +
{/if} diff --git a/src/routes/photos/[albumSlug]/[photoId]/+page.ts b/src/routes/photos/[albumSlug]/[photoId]/+page.ts index e22c5c2..f129ca1 100644 --- a/src/routes/photos/[albumSlug]/[photoId]/+page.ts +++ b/src/routes/photos/[albumSlug]/[photoId]/+page.ts @@ -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') } diff --git a/src/routes/photos/[slug]/+page.svelte b/src/routes/photos/[slug]/+page.svelte index fe5575c..db1e9b3 100644 --- a/src/routes/photos/[slug]/+page.svelte +++ b/src/routes/photos/[slug]/+page.svelte @@ -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 ) @@ -96,12 +115,12 @@ {#if error}
-

Album Not Found

+

Not Found

{error}

-{:else if album} +{:else if type === 'album' && album}
@@ -133,6 +152,32 @@
{/if}
+{:else if type === 'photo' && photo} +
+
+ +
+ +
+ {photo.title +
+ +
+ {#if photo.title} +

{photo.title}

+ {/if} + + {#if photo.caption || photo.description} +

{photo.caption || photo.description}

+ {/if} + + {#if photo.exifData} +
+ +
+ {/if} +
+
{/if} diff --git a/src/routes/photos/[slug]/+page.ts b/src/routes/photos/[slug]/+page.ts index e3ef1b1..d72ba79 100644 --- a/src/routes/photos/[slug]/+page.ts +++ b/src/routes/photos/[slug]/+page.ts @@ -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' } } } diff --git a/src/routes/photos/p/[id]/+page.svelte b/src/routes/photos/p/[id]/+page.svelte new file mode 100644 index 0000000..719bcbb --- /dev/null +++ b/src/routes/photos/p/[id]/+page.svelte @@ -0,0 +1,537 @@ + + + + {metaTags.title} + + + + {#each Object.entries(metaTags.openGraph) as [property, content]} + + {/each} + + + {#each Object.entries(metaTags.twitter) as [property, content]} + + {/each} + + + + + + {#if photoJsonLd} + {@html ``} + {/if} + + +{#if error} +
+
+

Photo Not Found

+

{error}

+ +
+
+{:else if photo} +
+
+ +
+ + +
+ {#if adjacentPhotos().prev} + + {/if} + + {#if adjacentPhotos().next} + + {/if} +
+ + +
+{/if} + + diff --git a/src/routes/photos/p/[id]/+page.ts b/src/routes/photos/p/[id]/+page.ts new file mode 100644 index 0000000..33f320a --- /dev/null +++ b/src/routes/photos/p/[id]/+page.ts @@ -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' + } + } +} diff --git a/test-db.ts b/test-db.ts new file mode 100644 index 0000000..136d6c6 --- /dev/null +++ b/test-db.ts @@ -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() diff --git a/vite.config.ts b/vite.config.ts index 97fee1e..86576e4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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' }