big update
This commit is contained in:
parent
b314be59f4
commit
2f504abb57
86 changed files with 10275 additions and 1557 deletions
|
|
@ -47,22 +47,17 @@ CREATE TABLE projects (
|
|||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Posts table (for /universe)
|
||||
-- Posts table (for /universe) - Simplified to 2 types
|
||||
CREATE TABLE posts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||
post_type VARCHAR(50) NOT NULL, -- blog, microblog, link, photo, album
|
||||
title VARCHAR(255), -- Optional for microblog posts
|
||||
content JSONB, -- Edra JSON for blog/microblog, optional for others
|
||||
excerpt TEXT,
|
||||
|
||||
-- Type-specific fields
|
||||
link_url VARCHAR(500), -- For link posts
|
||||
link_description TEXT, -- For link posts
|
||||
photo_id INTEGER REFERENCES photos(id), -- For photo posts
|
||||
album_id INTEGER REFERENCES albums(id), -- For album posts
|
||||
post_type VARCHAR(50) NOT NULL, -- 'post' or 'essay'
|
||||
title VARCHAR(255), -- Required for essays, optional for posts
|
||||
content JSONB, -- Edra JSON content
|
||||
excerpt TEXT, -- For essays
|
||||
|
||||
featured_image VARCHAR(500),
|
||||
attachments JSONB, -- Array of media IDs for any attachments
|
||||
tags JSONB, -- Array of tags
|
||||
status VARCHAR(50) DEFAULT 'draft',
|
||||
published_at TIMESTAMP,
|
||||
|
|
@ -70,7 +65,7 @@ CREATE TABLE posts (
|
|||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Albums table
|
||||
-- Albums table - Enhanced with photography curation
|
||||
CREATE TABLE albums (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||
|
|
@ -79,6 +74,7 @@ CREATE TABLE albums (
|
|||
date DATE,
|
||||
location VARCHAR(255),
|
||||
cover_photo_id INTEGER REFERENCES photos(id),
|
||||
is_photography BOOLEAN DEFAULT false, -- Show in photos experience
|
||||
status VARCHAR(50) DEFAULT 'draft',
|
||||
show_in_universe BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
|
@ -109,17 +105,35 @@ CREATE TABLE photos (
|
|||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Media table (general uploads)
|
||||
-- Media table (general uploads) - Enhanced with photography curation
|
||||
CREATE TABLE media (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_name VARCHAR(255), -- Original filename from user
|
||||
mime_type VARCHAR(100) NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
thumbnail_url TEXT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
alt_text TEXT, -- Alt text for accessibility
|
||||
description TEXT, -- Optional description
|
||||
is_photography BOOLEAN DEFAULT false, -- Star for photos experience
|
||||
used_in JSONB DEFAULT '[]', -- Legacy tracking field
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Media usage tracking table
|
||||
CREATE TABLE media_usage (
|
||||
id SERIAL PRIMARY KEY,
|
||||
media_id INTEGER REFERENCES media(id) ON DELETE CASCADE,
|
||||
content_type VARCHAR(50) NOT NULL, -- 'project', 'post', 'album'
|
||||
content_id INTEGER NOT NULL,
|
||||
field_name VARCHAR(100) NOT NULL, -- 'featuredImage', 'logoUrl', 'gallery', 'content', 'attachments'
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(media_id, content_type, content_id, field_name)
|
||||
);
|
||||
```
|
||||
|
||||
|
|
@ -154,14 +168,23 @@ CREATE TABLE media (
|
|||
}
|
||||
```
|
||||
|
||||
#### Media Table Enhancement
|
||||
#### Media Usage Tracking System
|
||||
|
||||
The system now uses a dedicated `media_usage` table for robust tracking:
|
||||
|
||||
```sql
|
||||
-- Add content associations to media table
|
||||
ALTER TABLE media ADD COLUMN used_in JSONB DEFAULT '[]';
|
||||
-- Example: [{ "type": "post", "id": 1 }, { "type": "project", "id": 3 }]
|
||||
-- MediaUsage tracks where each media file is used
|
||||
-- Replaces the simple used_in JSONB field with proper relational tracking
|
||||
-- Enables complex queries like "show all projects using this media"
|
||||
-- Supports bulk operations and reference cleanup
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Accurate usage tracking across all content types
|
||||
- Efficient queries for usage information
|
||||
- Safe bulk deletion with automatic reference cleanup
|
||||
- Detailed tracking by field (featuredImage, gallery, content, etc.)
|
||||
|
||||
### 3. Content Type Editors
|
||||
|
||||
- **Projects**: Form-based editor with:
|
||||
|
|
@ -221,18 +244,30 @@ const ImageBlock = {
|
|||
}
|
||||
```
|
||||
|
||||
### 5. Media Library Component
|
||||
### 5. Media Library System
|
||||
|
||||
- **Modal Interface**: Opens from Edra toolbar or form fields
|
||||
#### Media Library Component
|
||||
|
||||
- **Modal Interface**: Opens from Edra toolbar, form fields, or Browse Library buttons
|
||||
- **Features**:
|
||||
- Grid view of all uploaded media
|
||||
- Search by filename
|
||||
- Filter by type (image/video)
|
||||
- Filter by usage (unused/used)
|
||||
- Upload new files
|
||||
- Select existing media
|
||||
- Grid and list view modes for uploaded media
|
||||
- Search by filename and filter by type (image/video/audio/pdf)
|
||||
- Usage information showing where each media is used
|
||||
- Alt text editing and accessibility features
|
||||
- Upload new files directly from modal
|
||||
- Single and multi-select functionality
|
||||
- **Returns**: Media object with ID and URLs
|
||||
|
||||
#### Multiselect & Bulk Operations
|
||||
|
||||
- **Selection Interface**: Checkbox-based selection in both grid and list views
|
||||
- **Bulk Actions**:
|
||||
- Select All / Clear Selection controls
|
||||
- Bulk delete with confirmation
|
||||
- Progress indicators and loading states
|
||||
- **Safe Deletion**: Automatic reference cleanup across all content types
|
||||
- **Reference Tracking**: Shows exactly where each media file is used before deletion
|
||||
|
||||
### 6. Image Processing Pipeline
|
||||
|
||||
1. **Upload**: User drops/selects image
|
||||
|
|
@ -252,45 +287,58 @@ const ImageBlock = {
|
|||
|
||||
```typescript
|
||||
// Projects
|
||||
GET / api / projects
|
||||
POST / api / projects
|
||||
GET / api / projects / [slug]
|
||||
PUT / api / projects / [id]
|
||||
DELETE / api / projects / [id]
|
||||
GET /api/projects
|
||||
POST /api/projects
|
||||
GET /api/projects/[slug]
|
||||
PUT /api/projects/[id]
|
||||
DELETE /api/projects/[id]
|
||||
|
||||
// Posts
|
||||
GET / api / posts
|
||||
POST / api / posts
|
||||
GET / api / posts / [slug]
|
||||
PUT / api / posts / [id]
|
||||
DELETE / api / posts / [id]
|
||||
GET /api/posts
|
||||
POST /api/posts
|
||||
GET /api/posts/[slug]
|
||||
PUT /api/posts/[id]
|
||||
DELETE /api/posts/[id]
|
||||
|
||||
// Albums & Photos
|
||||
GET / api / albums
|
||||
POST / api / albums
|
||||
GET / api / albums / [slug]
|
||||
PUT / api / albums / [id]
|
||||
DELETE / api / albums / [id]
|
||||
POST / api / albums / [id] / photos
|
||||
DELETE / api / photos / [id]
|
||||
PUT / api / photos / [id] / order
|
||||
GET /api/albums
|
||||
POST /api/albums
|
||||
GET /api/albums/[slug]
|
||||
PUT /api/albums/[id]
|
||||
DELETE /api/albums/[id]
|
||||
POST /api/albums/[id]/photos
|
||||
DELETE /api/photos/[id]
|
||||
PUT /api/photos/[id]/order
|
||||
|
||||
// Media upload
|
||||
POST / api / media / upload
|
||||
POST / api / media / bulk - upload
|
||||
GET / api / media // Browse with filters
|
||||
DELETE / api / media / [id] // Delete if unused
|
||||
GET / api / media / [id] / usage // Check where media is used
|
||||
// Media Management
|
||||
POST /api/media/upload // Single file upload
|
||||
POST /api/media/bulk-upload // Multiple file upload
|
||||
GET /api/media // Browse with filters, pagination
|
||||
GET /api/media/[id] // Get single media item
|
||||
PUT /api/media/[id] // Update media (alt text, description)
|
||||
DELETE /api/media/[id] // Delete single media item
|
||||
DELETE /api/media/bulk-delete // Delete multiple media items
|
||||
GET /api/media/[id]/usage // Check where media is used
|
||||
POST /api/media/backfill-usage // Backfill usage tracking for existing content
|
||||
```
|
||||
|
||||
### 8. Media Management & Cleanup
|
||||
|
||||
#### Orphaned Media Prevention
|
||||
#### Advanced Usage Tracking
|
||||
|
||||
- **Reference Tracking**: `used_in` field tracks all content using each media item
|
||||
- **On Save**: Update media associations when content is saved
|
||||
- **On Delete**: Remove associations when content is deleted
|
||||
- **Cleanup Task**: Periodic job to identify truly orphaned media
|
||||
- **MediaUsage Table**: Dedicated table for precise tracking of media usage
|
||||
- **Automatic Tracking**: All content saves automatically update usage references
|
||||
- **Field-Level Tracking**: Tracks specific fields (featuredImage, gallery, content, attachments)
|
||||
- **Content Type Support**: Projects, Posts, Albums with full reference tracking
|
||||
- **Real-time Usage Display**: Shows exactly where each media file is used
|
||||
|
||||
#### Safe Deletion System
|
||||
|
||||
- **Usage Validation**: Prevents deletion if media is in use (unless forced)
|
||||
- **Reference Cleanup**: Automatically removes deleted media from all content
|
||||
- **Bulk Operations**: Multi-select deletion with comprehensive reference cleanup
|
||||
- **Rich Text Cleanup**: Removes deleted media from Edra editor content (images, galleries)
|
||||
- **Atomic Operations**: All-or-nothing deletion ensures data consistency
|
||||
|
||||
#### Edra Integration Details
|
||||
|
||||
|
|
@ -322,13 +370,19 @@ const handleImageUpload = async (file) => {
|
|||
### 9. Admin Interface
|
||||
|
||||
- **Route**: `/admin` (completely separate from public routes)
|
||||
- **Dashboard**: Overview of all content types
|
||||
- **Dashboard**: Overview of all content types with quick stats
|
||||
- **Content Lists**:
|
||||
- Projects with preview thumbnails
|
||||
- Posts with publish status
|
||||
- Albums with photo counts
|
||||
- **Content Editors**: Type-specific editing interfaces
|
||||
- **Media Library**: Browse all uploaded files
|
||||
- Projects with preview thumbnails and status indicators
|
||||
- Posts with publish status and type badges
|
||||
- Albums with photo counts and metadata
|
||||
- **Content Editors**: Type-specific editing interfaces with rich text support
|
||||
- **Media Library**: Comprehensive media management with:
|
||||
- Grid and list view modes
|
||||
- Advanced search and filtering
|
||||
- Usage tracking and reference display
|
||||
- Alt text editing and accessibility features
|
||||
- Bulk operations with multiselect interface
|
||||
- Safe deletion with reference cleanup
|
||||
|
||||
### 10. Public Display Integration
|
||||
|
||||
|
|
@ -462,60 +516,93 @@ Based on requirements discussion:
|
|||
4. **Project Templates**: Defer case study layout templates for later phase
|
||||
5. **Scheduled Publishing**: Not needed initially
|
||||
6. **RSS Feeds**: Required for all content types (projects, posts, photos)
|
||||
7. **Post Types**: Universe will support multiple post types:
|
||||
- **Blog Post**: Title + long-form Edra content
|
||||
- **Microblog**: No title, short-form Edra content
|
||||
- **Link Post**: URL + optional commentary
|
||||
- **Photo Post**: Single photo + caption
|
||||
- **Album Post**: Reference to photo album
|
||||
7. **Post Types**: Simplified to two main types:
|
||||
- **Post**: Simple content with optional attachments (replaces microblog, link, photo posts)
|
||||
- **Essay**: Full editor with title/metadata + optional attachments (replaces blog posts)
|
||||
8. **Albums & Photo Curation**: Albums serve dual purposes:
|
||||
- **Regular Albums**: Collections for case studies, UI galleries, design process
|
||||
- **Photography Albums**: Curated collections for photo-centric experience
|
||||
- Both album and media levels have `isPhotography` flags for flexible curation
|
||||
9. **Photo Curation Strategy**: Media items can be "starred for photos" regardless of usage context
|
||||
- Same photo can exist in posts AND photo collections
|
||||
- Editorial control over what constitutes "photography" vs "UI screenshots/sketches"
|
||||
- Photography albums can contain mixed content if editorially appropriate
|
||||
|
||||
## Current Status (December 2024)
|
||||
## Current Status (June 2024)
|
||||
|
||||
### Completed
|
||||
|
||||
- ✅ Database setup with Prisma and PostgreSQL
|
||||
- ✅ Media management system with Cloudinary integration
|
||||
- ✅ Admin foundation (layout, navigation, auth, forms, data tables)
|
||||
- ✅ Edra rich text editor integration for case studies
|
||||
- ✅ Edra image uploads configured to use media API
|
||||
- ✅ Edra rich text editor integration for case studies and posts
|
||||
- ✅ Edra image and gallery extensions with MediaLibraryModal integration
|
||||
- ✅ Local development mode for media uploads (no Cloudinary usage)
|
||||
- ✅ Project CRUD system with metadata fields and enhanced schema
|
||||
- ✅ Project list view in admin with enhanced UI
|
||||
- ✅ Project forms with branding (logo, colors) and styling
|
||||
- ✅ Posts CRUD system with all post types (blog, microblog, link, photo, album)
|
||||
- ✅ Posts attachments field for multiple image support
|
||||
- ✅ Posts list view and editor in admin
|
||||
- ✅ Complete database schema matching PRD requirements
|
||||
- ✅ Complete database schema with MediaUsage tracking table
|
||||
- ✅ Media API endpoints with upload, bulk upload, and usage tracking
|
||||
- ✅ Component library for admin interface (buttons, inputs, modals, etc.)
|
||||
- ✅ Test page for verifying upload functionality
|
||||
- ✅ MediaLibraryModal for browsing and selecting media
|
||||
- ✅ Media details modal with alt text editing and usage information
|
||||
- ✅ Multiselect interface for bulk media operations
|
||||
- ✅ Safe bulk deletion with automatic reference cleanup
|
||||
- ✅ UniverseComposer with photo attachment support
|
||||
- ✅ Form integration with Browse Library functionality (ImageUploader, GalleryUploader)
|
||||
- ✅ Usage tracking backfill system for existing content
|
||||
- ✅ **Project Password Protection & Visibility System** (June 2024)
|
||||
- ✅ Four project states: Published, List-only, Password-protected, Draft
|
||||
- ✅ Password protection with session storage
|
||||
- ✅ Visual indicators in project lists
|
||||
- ✅ Admin interface updates with status dropdown
|
||||
- ✅ API filtering for different visibility states
|
||||
- ✅ **RSS Feed Best Practices Implementation** (June 2024)
|
||||
- ✅ Updated all RSS feeds with proper XML namespaces
|
||||
- ✅ Full content support via content:encoded
|
||||
- ✅ Enhanced HTTP headers with ETag and caching
|
||||
- ✅ RFC 822 date formatting throughout
|
||||
|
||||
### In Progress
|
||||
|
||||
- 🔄 Albums/Photos System - Schema implemented, UI components needed
|
||||
- 🔄 Content Simplification & Photo Curation System
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. **Media Library System** (Critical dependency for other features)
|
||||
1. **Content Model Updates** (Immediate Priority)
|
||||
|
||||
- Media library modal component
|
||||
- Integration with existing media APIs
|
||||
- Search and filter functionality within media browser
|
||||
- Add `isPhotography` field to Media and Album tables via migration
|
||||
- Simplify post types to just "post" and "essay"
|
||||
- Update post creation UI to use simplified types
|
||||
- Add photography toggle to media details modal
|
||||
- Add photography indicator pills in admin interface
|
||||
|
||||
2. **Albums & Photos Management Interface**
|
||||
2. **Albums & Photos Management Interface**
|
||||
|
||||
- Album creation and management UI
|
||||
- Album creation and management UI with photography toggle
|
||||
- Bulk photo upload interface with progress
|
||||
- Photo ordering within albums
|
||||
- Album cover selection
|
||||
- EXIF data extraction and display
|
||||
- Photography album filtering and management
|
||||
|
||||
3. **Enhanced Content Features**
|
||||
|
||||
- Photo/album post selectors using media library
|
||||
- Featured image picker for projects
|
||||
- Featured image picker for projects (using MediaLibraryModal)
|
||||
- Technology tag selector for projects
|
||||
- Auto-save functionality for all editors
|
||||
- Gallery manager for project images
|
||||
- Gallery manager for project images with drag-and-drop
|
||||
|
||||
4. **Public Display Integration**
|
||||
|
||||
- Dynamic Work page displaying projects from database
|
||||
- Universe page with mixed content feed (posts + essays)
|
||||
- Photos page with photography albums only
|
||||
- Individual content detail pages
|
||||
- SEO meta tags and OpenGraph integration
|
||||
|
||||
## Phased Implementation Plan
|
||||
|
||||
|
|
@ -542,10 +629,14 @@ Based on requirements discussion:
|
|||
|
||||
- [x] Create media upload endpoint with Cloudinary integration
|
||||
- [x] Implement image processing pipeline (multiple sizes)
|
||||
- [x] Build media library API endpoints
|
||||
- [x] Create media association tracking system
|
||||
- [x] Build media library API endpoints with pagination and filtering
|
||||
- [x] Create advanced MediaUsage tracking system
|
||||
- [x] Add bulk upload endpoint for photos
|
||||
- [x] Create media usage tracking queries
|
||||
- [x] Build MediaLibraryModal component with search and selection
|
||||
- [x] Implement media details modal with alt text editing
|
||||
- [x] Create multiselect interface for bulk operations
|
||||
- [x] Add safe bulk deletion with reference cleanup
|
||||
- [x] Build usage tracking queries and backfill system
|
||||
|
||||
### Phase 3: Admin Foundation
|
||||
|
||||
|
|
@ -556,21 +647,22 @@ Based on requirements discussion:
|
|||
- [x] Build data table component for list views
|
||||
- [x] Add loading and error states
|
||||
- [x] Create comprehensive admin UI component library
|
||||
- [ ] Create media library modal component
|
||||
- [x] Build complete media library system with modals and management
|
||||
|
||||
### Phase 4: Posts System (All Types)
|
||||
|
||||
- [x] Create Edra Svelte wrapper component
|
||||
- [x] Implement custom image block for Edra
|
||||
- [x] Implement custom image and gallery blocks for Edra
|
||||
- [x] Build post type selector UI
|
||||
- [x] Create blog/microblog post editor
|
||||
- [x] Build link post form
|
||||
- [x] Create posts list view in admin
|
||||
- [x] Implement post CRUD APIs
|
||||
- [x] Implement post CRUD APIs with attachments support
|
||||
- [x] Post editor page with type-specific fields
|
||||
- [x] Complete posts database schema with all post types
|
||||
- [x] Complete posts database schema with attachments field
|
||||
- [x] Posts administration interface
|
||||
- [ ] Create photo post selector (needs media library modal)
|
||||
- [x] UniverseComposer with photo attachment support
|
||||
- [x] Integrate MediaLibraryModal with Edra editor
|
||||
- [ ] Build album post selector (needs albums system)
|
||||
- [ ] Add auto-save functionality
|
||||
|
||||
|
|
@ -578,40 +670,56 @@ Based on requirements discussion:
|
|||
|
||||
- [x] Build project form with all metadata fields
|
||||
- [x] Enhanced schema with branding fields (logo, colors)
|
||||
- [x] Project branding and styling forms
|
||||
- [x] Add optional Edra editor for case studies
|
||||
- [x] Create project CRUD APIs
|
||||
- [x] Project branding and styling forms with ImageUploader and GalleryUploader
|
||||
- [x] Add optional Edra editor for case studies with media support
|
||||
- [x] Create project CRUD APIs with usage tracking
|
||||
- [x] Build project list view with enhanced UI
|
||||
- [x] Integrate Browse Library functionality in project forms
|
||||
- [ ] Create technology tag selector
|
||||
- [ ] Implement featured image picker (needs media library modal)
|
||||
- [ ] Build gallery manager with drag-and-drop ordering
|
||||
- [ ] Add project ordering functionality
|
||||
|
||||
### Phase 6: Photos & Albums System
|
||||
### Phase 6: Content Simplification & Photo Curation
|
||||
|
||||
- [x] Add `isPhotography` field to Media table (migration)
|
||||
- [x] Add `isPhotography` field to Album table (migration)
|
||||
- [x] Simplify post types to "post" and "essay" only
|
||||
- [x] Update UniverseComposer to use simplified post types
|
||||
- [x] Add photography toggle to MediaDetailsModal
|
||||
- [x] Add photography indicator pills throughout admin interface
|
||||
- [x] Update media and album APIs to handle photography flags
|
||||
|
||||
### Phase 7: Photos & Albums System
|
||||
|
||||
- [x] Complete database schema for albums and photos
|
||||
- [x] Photo/album CRUD API endpoints (albums endpoint exists)
|
||||
- [ ] Create album management interface
|
||||
- [x] Create album management interface with photography toggle
|
||||
- [x] **Album Photo Management** (Core functionality complete)
|
||||
- [x] Add photos to albums interface using MediaLibraryModal
|
||||
- [x] Remove photos from albums with confirmation
|
||||
- [x] Photo grid display with hover overlays
|
||||
- [x] Album-photo relationship API endpoints (POST /api/albums/[id]/photos, DELETE /api/photos/[id])
|
||||
- [ ] Photo reordering within albums (drag-and-drop)
|
||||
- [ ] Album cover photo selection
|
||||
- [ ] Build bulk photo uploader with progress
|
||||
- [ ] Implement EXIF data extraction for photos
|
||||
- [ ] Implement drag-and-drop photo ordering
|
||||
- [ ] Add individual photo publishing UI
|
||||
- [ ] Build photo metadata editor
|
||||
- [ ] Implement album cover selection
|
||||
- [ ] Add photography album filtering and management
|
||||
- [ ] Add "show in universe" toggle for albums
|
||||
|
||||
### Phase 7: Public Display Updates
|
||||
### Phase 8: Public Display Updates
|
||||
|
||||
- [ ] Replace static Work page with dynamic data
|
||||
- [ ] Update project detail pages
|
||||
- [ ] Build Universe mixed feed component
|
||||
- [ ] Create different card types for each post type
|
||||
- [ ] Update Photos page with dynamic albums/photos
|
||||
- [ ] Implement individual photo pages
|
||||
- [ ] Add Universe post detail pages
|
||||
- [x] Replace static Work page with dynamic data
|
||||
- [x] Update project detail pages
|
||||
- [x] Build Universe mixed feed component
|
||||
- [x] Create different card types for each post type
|
||||
- [x] Update Photos page with dynamic albums/photos
|
||||
- [x] Implement individual photo pages
|
||||
- [x] Add Universe post detail pages
|
||||
- [ ] Ensure responsive design throughout
|
||||
|
||||
### Phase 8: RSS Feeds & Final Polish
|
||||
### Phase 9: RSS Feeds & Final Polish
|
||||
|
||||
- [ ] Implement RSS feed for projects
|
||||
- [ ] Create RSS feed for Universe posts
|
||||
|
|
@ -622,7 +730,7 @@ Based on requirements discussion:
|
|||
- [ ] Add search functionality to admin
|
||||
- [ ] Performance optimization pass
|
||||
|
||||
### Phase 9: Production Deployment
|
||||
### Phase 10: Production Deployment
|
||||
|
||||
- [ ] Set up PostgreSQL on Railway
|
||||
- [ ] Run migrations on production database
|
||||
|
|
@ -642,6 +750,85 @@ Based on requirements discussion:
|
|||
- [ ] Analytics integration
|
||||
- [ ] Backup system
|
||||
|
||||
## Albums & Photos System Implementation
|
||||
|
||||
### Design Decisions Made (May 2024)
|
||||
|
||||
1. **Simplified Post Types**: Reduced from 5 types (blog, microblog, link, photo, album) to 2 types:
|
||||
- **Post**: Simple content with optional attachments (handles previous microblog, link, photo use cases)
|
||||
- **Essay**: Full editor with title/metadata + attachments (handles previous blog use cases)
|
||||
|
||||
2. **Photo Curation Strategy**: Dual-level curation system:
|
||||
- **Media Level**: `isPhotography` boolean - stars individual media for photo experience
|
||||
- **Album Level**: `isPhotography` boolean - marks entire albums for photo experience
|
||||
- **Mixed Content**: Photography albums can contain non-photography media (Option A)
|
||||
- **Default Behavior**: Both flags default to `false` to prevent accidental photo inclusion
|
||||
|
||||
3. **Visual Indicators**: Pill-shaped tags to indicate photography status in admin interface
|
||||
|
||||
4. **Album Flexibility**: Albums serve multiple purposes:
|
||||
- Regular albums for case studies, UI collections, design process
|
||||
- Photography albums for curated photo experience (Japan Trip, Street Photography)
|
||||
- Same album system, different curation flags
|
||||
|
||||
### Implementation Task List
|
||||
|
||||
#### Phase 1: Database Updates
|
||||
- [x] Create migration to add `isPhotography` field to Media table
|
||||
- [x] Create migration to add `isPhotography` field to Album table
|
||||
- [x] Update Prisma schema with new fields
|
||||
- [x] Test migrations on local database
|
||||
|
||||
#### Phase 2: API Updates
|
||||
- [x] Update Media API endpoints to handle `isPhotography` flag
|
||||
- [x] Update Album API endpoints to handle `isPhotography` flag
|
||||
- [x] Update media usage tracking to work with new flags
|
||||
- [x] Add filtering capabilities for photography content
|
||||
|
||||
#### Phase 3: Admin Interface Updates
|
||||
- [x] Add photography toggle to MediaDetailsModal
|
||||
- [x] Add photography indicator pills for media items (grid and list views)
|
||||
- [x] Add photography indicator pills for albums
|
||||
- [x] Update media library filtering to include photography status
|
||||
- [x] Add bulk photography operations (mark/unmark multiple items)
|
||||
|
||||
#### Phase 4: Post Type Simplification
|
||||
- [x] Update UniverseComposer to use only "post" and "essay" types
|
||||
- [x] Remove complex post type selector UI
|
||||
- [x] Update post creation flows
|
||||
- [x] Migrate existing posts to simplified types (if needed)
|
||||
- [x] Update post display logic to handle simplified types
|
||||
|
||||
#### Phase 5: Album Management System
|
||||
- [x] Create album creation/editing interface with photography toggle
|
||||
- [x] Build album list view with photography indicators
|
||||
- [ ] **Critical Missing Feature: Album Photo Management**
|
||||
- [ ] Add photo management section to album edit page
|
||||
- [ ] Implement "Add Photos from Library" functionality using MediaLibraryModal
|
||||
- [ ] Create photo grid display within album editor
|
||||
- [ ] Add remove photo functionality (individual photos)
|
||||
- [ ] Implement drag-and-drop photo reordering within albums
|
||||
- [ ] Add album cover photo selection interface
|
||||
- [ ] Update album API to handle photo associations
|
||||
- [ ] Create album-photo relationship endpoints
|
||||
- [ ] Add bulk photo upload to albums with automatic photography detection
|
||||
|
||||
#### Phase 6: Photography Experience
|
||||
- [ ] Build photography album filtering in admin
|
||||
- [ ] Create photography-focused views and workflows
|
||||
- [ ] Add batch operations for photo curation
|
||||
- [ ] Implement photography album public display
|
||||
- [ ] Add photography vs regular album distinction in frontend
|
||||
|
||||
### Success Criteria
|
||||
|
||||
- Admin can quickly toggle media items between regular and photography status
|
||||
- Albums can be easily marked for photography experience
|
||||
- Post creation is simplified to 2 clear choices
|
||||
- Photography albums display correctly in public photos section
|
||||
- Mixed content albums (photography + other) display all content as intended
|
||||
- Pill indicators clearly show photography status throughout admin interface
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- Can create and publish any content type within 2-3 minutes
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
# Product Requirements Document: Media Library Modal System
|
||||
|
||||
## 🎉 **PROJECT STATUS: CORE IMPLEMENTATION COMPLETE!**
|
||||
|
||||
We have successfully implemented a comprehensive Media Library system with both direct upload workflows and library browsing capabilities. **All major components are functional and integrated throughout the admin interface.**
|
||||
|
||||
### 🏆 Major Achievements
|
||||
- **✅ Complete MediaLibraryModal system** with single/multiple selection
|
||||
- **✅ Enhanced upload components** (ImageUploader, GalleryUploader) with MediaLibraryModal integration
|
||||
- **✅ Full form integration** across projects, posts, albums, and editor
|
||||
- **✅ Alt text support** throughout upload and editing workflows
|
||||
- **✅ Edra editor integration** with `/image` and `/gallery` slash commands
|
||||
- **✅ Media Library management** with clickable editing and metadata support
|
||||
|
||||
## Overview
|
||||
|
||||
Implement a comprehensive Media Library modal system that provides a unified interface for browsing, selecting, and managing media across all admin forms. **The primary workflow is direct upload from computer within forms**, with the Media Library serving as a secondary browsing interface and management tool for previously uploaded content.
|
||||
|
|
@ -40,23 +52,30 @@ Implement a comprehensive Media Library modal system that provides a unified int
|
|||
- Complete admin UI component library (Button, Input, etc.)
|
||||
- Media upload infrastructure with Cloudinary integration
|
||||
- Pagination and search functionality
|
||||
- **✅ Database schema with alt text support** (altText field in Media table)
|
||||
- **✅ MediaLibraryModal component** with single/multiple selection modes
|
||||
- **✅ ImageUploader and GalleryUploader components** with MediaLibraryModal integration
|
||||
- **✅ Enhanced admin form components** with Browse Library functionality
|
||||
- **✅ Media details editing** with alt text support in Media Library page
|
||||
- **✅ Edra editor integration** with image and gallery support via slash commands
|
||||
|
||||
### 🎯 What We Need
|
||||
|
||||
#### High Priority (Direct Upload Focus)
|
||||
- **Enhanced upload components** with immediate preview and metadata capture
|
||||
- **Alt text input fields** for accessibility compliance
|
||||
- **Direct upload integration** in form components (ImagePicker, GalleryManager)
|
||||
- **Metadata management** during upload process
|
||||
#### High Priority (Remaining Tasks)
|
||||
- **Enhanced upload features** with drag & drop zones in all upload components
|
||||
- **Bulk alt text editing** in Media Library for existing content
|
||||
- **Usage tracking display** showing where media is referenced
|
||||
- **Performance optimizations** for large media libraries
|
||||
|
||||
#### Medium Priority (Media Library Browser)
|
||||
- Reusable MediaLibraryModal component for browsing existing content
|
||||
- Selection state management for previously uploaded files
|
||||
- Usage tracking and reference management
|
||||
#### Medium Priority (Polish & Advanced Features)
|
||||
- **Image optimization options** during upload
|
||||
- **Advanced search capabilities** (by alt text, usage, etc.)
|
||||
- **Bulk operations** (delete multiple, bulk metadata editing)
|
||||
|
||||
#### Database Updates Required
|
||||
- Add `alt_text` field to Media table
|
||||
- Add `usage_references` or similar tracking for where media is used
|
||||
#### Low Priority (Future Enhancements)
|
||||
- **AI-powered alt text suggestions**
|
||||
- **Duplicate detection** and management
|
||||
- **Advanced analytics** and usage reporting
|
||||
|
||||
## Workflow Priorities
|
||||
|
||||
|
|
@ -356,114 +375,130 @@ interface GalleryManagerProps {
|
|||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Database Schema Updates (Required First)
|
||||
1. **Add Alt Text Support**
|
||||
```sql
|
||||
ALTER TABLE media ADD COLUMN alt_text TEXT;
|
||||
ALTER TABLE media ADD COLUMN description TEXT;
|
||||
```
|
||||
### ✅ Phase 1: Database Schema Updates (COMPLETED)
|
||||
1. **✅ Alt Text Support**
|
||||
- Database schema includes `altText` and `description` fields
|
||||
- API endpoints support alt text in upload and update operations
|
||||
|
||||
2. **Add Usage Tracking (Optional)**
|
||||
```sql
|
||||
-- Track where media is referenced
|
||||
CREATE TABLE media_usage (
|
||||
id SERIAL PRIMARY KEY,
|
||||
media_id INTEGER REFERENCES media(id),
|
||||
content_type VARCHAR(50), -- 'project', 'post', 'album'
|
||||
content_id INTEGER,
|
||||
field_name VARCHAR(100), -- 'featured_image', 'gallery', etc.
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
2. **⏳ Usage Tracking (IN PROGRESS)**
|
||||
- Basic usage references working in forms
|
||||
- Need dedicated tracking table for comprehensive usage analytics
|
||||
|
||||
### Phase 2: Direct Upload Components (High Priority)
|
||||
1. **ImageUploader Component**
|
||||
### ✅ Phase 2: Direct Upload Components (COMPLETED)
|
||||
1. **✅ ImageUploader Component**
|
||||
- Drag-and-drop upload zone with visual feedback
|
||||
- Immediate upload and preview functionality
|
||||
- Alt text input integration
|
||||
- Replace existing ImagePicker with upload-first approach
|
||||
- MediaLibraryModal integration as secondary option
|
||||
|
||||
2. **GalleryUploader Component**
|
||||
- Multiple file drag-and-drop
|
||||
2. **✅ GalleryUploader Component**
|
||||
- Multiple file drag-and-drop support
|
||||
- Individual alt text inputs per image
|
||||
- Drag-and-drop reordering
|
||||
- Drag-and-drop reordering functionality
|
||||
- Remove individual images functionality
|
||||
- MediaLibraryModal integration for existing media selection
|
||||
|
||||
3. **Upload API Enhancement**
|
||||
- Accept alt text in upload request
|
||||
- Return complete media object with metadata
|
||||
- Handle batch uploads with individual alt text
|
||||
3. **✅ Upload API Enhancement**
|
||||
- Alt text accepted in upload requests
|
||||
- Complete media object returned with metadata
|
||||
- Batch uploads with individual alt text support
|
||||
|
||||
### Phase 3: Form Integration (High Priority)
|
||||
1. **Project Forms Enhancement**
|
||||
- Replace logo field with ImageUploader
|
||||
- Add featured image with ImageUploader
|
||||
- Implement gallery section with GalleryUploader
|
||||
- Add secondary "Browse Library" buttons
|
||||
### ✅ Phase 3: Form Integration (COMPLETED)
|
||||
1. **✅ Project Forms Enhancement**
|
||||
- Logo field enhanced with ImageUploader + Browse Library
|
||||
- Featured image support with ImageUploader
|
||||
- Gallery section implemented with GalleryUploader
|
||||
- Secondary "Browse Library" buttons throughout
|
||||
|
||||
2. **Post Forms Enhancement**
|
||||
- Photo post type with GalleryUploader
|
||||
- Album creation with GalleryUploader
|
||||
- Featured image selection for text posts
|
||||
2. **✅ Post Forms Enhancement**
|
||||
- Photo post creation with PhotoPostForm
|
||||
- Album creation with AlbumForm and GalleryUploader
|
||||
- Universe Composer with photo attachments
|
||||
- Enhanced Edra editor with inline image/gallery support
|
||||
|
||||
### Phase 4: Media Library Management (Medium Priority)
|
||||
1. **Enhanced Media Library Page**
|
||||
- Alt text editing for existing media
|
||||
- Usage tracking display (shows where media is used)
|
||||
- Bulk alt text editing
|
||||
- Search and filter by alt text
|
||||
### ✅ Phase 4: Media Library Management (MOSTLY COMPLETED)
|
||||
1. **✅ Enhanced Media Library Page**
|
||||
- Alt text editing for existing media via MediaDetailsModal
|
||||
- Clickable media items with edit functionality
|
||||
- Grid and list view toggles
|
||||
|
||||
2. **MediaLibraryModal for Selection**
|
||||
2. **✅ MediaLibraryModal for Selection**
|
||||
- Browse existing media interface
|
||||
- Single and multiple selection modes
|
||||
- Integration as secondary option in forms
|
||||
- Integration throughout all form components
|
||||
- File type filtering (image/video/all)
|
||||
|
||||
### Phase 5: Polish and Advanced Features (Low Priority)
|
||||
### 🎯 Phase 5: Remaining Enhancements (CURRENT PRIORITIES)
|
||||
|
||||
#### 🔥 High Priority (Next Sprint)
|
||||
1. **Enhanced Media Library Features**
|
||||
- **Bulk alt text editing** - Select multiple media items and edit alt text in batch
|
||||
- **Usage tracking display** - Show where each media item is referenced
|
||||
- **Advanced drag & drop zones** - More intuitive upload areas in all components
|
||||
|
||||
2. **Performance Optimizations**
|
||||
- **Lazy loading** for large media libraries
|
||||
- **Search optimization** with better indexing
|
||||
- **Thumbnail optimization** for faster loading
|
||||
|
||||
#### 🔥 Medium Priority (Future Sprints)
|
||||
1. **Advanced Upload Features**
|
||||
- Image resizing/optimization options
|
||||
- Automatic alt text suggestions (AI integration)
|
||||
- Bulk upload with CSV metadata import
|
||||
- **Image resizing/optimization** options during upload
|
||||
- **Duplicate detection** to prevent redundant uploads
|
||||
- **Bulk upload improvements** with better progress tracking
|
||||
|
||||
2. **Usage Analytics**
|
||||
- Dashboard showing media usage statistics
|
||||
- Unused media cleanup tools
|
||||
- Duplicate detection and management
|
||||
2. **Usage Analytics & Management**
|
||||
- **Usage analytics dashboard** showing media usage statistics
|
||||
- **Unused media cleanup** tools for storage optimization
|
||||
- **Advanced search** by alt text, usage status, date ranges
|
||||
|
||||
#### 🔥 Low Priority (Nice-to-Have)
|
||||
1. **AI Integration**
|
||||
- **Automatic alt text suggestions** using image recognition
|
||||
- **Smart tagging** for better organization
|
||||
- **Content-aware optimization** suggestions
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### Primary Workflow (Direct Upload)
|
||||
- [ ] **Drag-and-drop upload works** in all form components
|
||||
- [ ] **Click-to-browse file selection** works reliably
|
||||
- [ ] **Immediate upload and preview** happens without page navigation
|
||||
- [ ] **Alt text input appears** and saves with uploaded media
|
||||
- [ ] **Upload progress** is clearly indicated with percentage
|
||||
- [ ] **Error handling** provides helpful feedback for failed uploads
|
||||
- [ ] **Multiple file upload** works with individual progress tracking
|
||||
- [ ] **Gallery reordering** works with drag-and-drop after upload
|
||||
- [x] **Drag-and-drop upload works** in all form components
|
||||
- [x] **Click-to-browse file selection** works reliably
|
||||
- [x] **Immediate upload and preview** happens without page navigation
|
||||
- [x] **Alt text input appears** and saves with uploaded media
|
||||
- [x] **Upload progress** is clearly indicated with percentage
|
||||
- [x] **Error handling** provides helpful feedback for failed uploads
|
||||
- [x] **Multiple file upload** works with individual progress tracking
|
||||
- [x] **Gallery reordering** works with drag-and-drop after upload
|
||||
|
||||
#### Secondary Workflow (Media Library)
|
||||
- [ ] **Media Library Modal** opens and closes properly with smooth animations
|
||||
- [ ] **Single and multiple selection** modes work correctly
|
||||
- [ ] **Search and filtering** return accurate results
|
||||
- [ ] **Usage tracking** shows where media is referenced
|
||||
- [ ] **Alt text editing** works in Media Library management
|
||||
- [ ] **All components are keyboard accessible**
|
||||
- [x] **Media Library Modal** opens and closes properly with smooth animations
|
||||
- [x] **Single and multiple selection** modes work correctly
|
||||
- [x] **Search and filtering** return accurate results
|
||||
- [ ] **Usage tracking** shows where media is referenced (IN PROGRESS)
|
||||
- [x] **Alt text editing** works in Media Library management
|
||||
- [x] **All components are keyboard accessible**
|
||||
|
||||
#### Edra Editor Integration
|
||||
- [x] **Slash commands** work for image and gallery insertion
|
||||
- [x] **MediaLibraryModal integration** in editor placeholders
|
||||
- [x] **Gallery management** within rich text editor
|
||||
- [x] **Image replacement** functionality in editor
|
||||
|
||||
### Performance Requirements
|
||||
- [ ] Modal opens in under 200ms
|
||||
- [ ] Media grid loads in under 1 second
|
||||
- [ ] Search results appear in under 500ms
|
||||
- [ ] Upload progress updates in real-time
|
||||
- [ ] No memory leaks when opening/closing modal multiple times
|
||||
- [x] Modal opens in under 200ms
|
||||
- [x] Media grid loads in under 1 second
|
||||
- [x] Search results appear in under 500ms
|
||||
- [x] Upload progress updates in real-time
|
||||
- [x] No memory leaks when opening/closing modal multiple times
|
||||
|
||||
### UX Requirements
|
||||
- [ ] Interface is intuitive without instruction
|
||||
- [ ] Visual feedback is clear for all interactions
|
||||
- [ ] Error messages are helpful and actionable
|
||||
- [ ] Mobile/tablet interface is fully functional
|
||||
- [ ] Loading states prevent user confusion
|
||||
- [x] Interface is intuitive without instruction
|
||||
- [x] Visual feedback is clear for all interactions
|
||||
- [x] Error messages are helpful and actionable
|
||||
- [x] Mobile/tablet interface is fully functional
|
||||
- [x] Loading states prevent user confusion
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
|
|
@ -510,25 +545,32 @@ interface GalleryManagerProps {
|
|||
## Development Checklist
|
||||
|
||||
### Core Components
|
||||
- [ ] MediaLibraryModal base structure
|
||||
- [ ] MediaSelector with grid layout
|
||||
- [ ] MediaUploader with drag-and-drop
|
||||
- [ ] Search and filter interface
|
||||
- [ ] Pagination implementation
|
||||
- [x] MediaLibraryModal base structure
|
||||
- [x] MediaSelector with grid layout
|
||||
- [x] MediaUploader with drag-and-drop
|
||||
- [x] Search and filter interface
|
||||
- [x] Pagination implementation
|
||||
|
||||
### Form Integration
|
||||
- [ ] MediaInput generic component
|
||||
- [ ] ImagePicker specialized component
|
||||
- [ ] GalleryManager with reordering
|
||||
- [ ] Integration with existing project forms
|
||||
- [ ] Integration with post forms
|
||||
- [x] MediaInput generic component (ImageUploader/GalleryUploader)
|
||||
- [x] ImagePicker specialized component (ImageUploader)
|
||||
- [x] GalleryManager with reordering (GalleryUploader)
|
||||
- [x] Integration with existing project forms
|
||||
- [x] Integration with post forms
|
||||
- [x] Integration with Edra editor
|
||||
|
||||
### Polish and Testing
|
||||
- [ ] Responsive design implementation
|
||||
- [ ] Accessibility testing and fixes
|
||||
- [ ] Performance optimization
|
||||
- [ ] Error state handling
|
||||
- [ ] Cross-browser testing
|
||||
- [ ] Mobile device testing
|
||||
- [x] Responsive design implementation
|
||||
- [x] Accessibility testing and fixes
|
||||
- [x] Performance optimization
|
||||
- [x] Error state handling
|
||||
- [x] Cross-browser testing
|
||||
- [x] Mobile device testing
|
||||
|
||||
### 🎯 Next Priority Items
|
||||
- [ ] **Bulk alt text editing** in Media Library
|
||||
- [ ] **Usage tracking display** for media references
|
||||
- [ ] **Advanced drag & drop zones** with better visual feedback
|
||||
- [ ] **Performance optimizations** for large libraries
|
||||
|
||||
This Media Library system will serve as the foundation for all media-related functionality in the CMS, enabling rich content creation across projects, posts, and albums.
|
||||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -41,6 +41,7 @@
|
|||
"@types/steamapi": "^2.2.5",
|
||||
"cloudinary": "^2.6.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"exifr": "^7.1.3",
|
||||
"giantbombing-api": "^1.0.4",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ioredis": "^5.4.1",
|
||||
|
|
@ -4805,6 +4806,12 @@
|
|||
"integrity": "sha512-QVtGvYTf9HvQyDjbBCwoDQPP9KMuVB56H8KalrkLsPPCQfngpVmkiIoxJ4FU/SVmlmhnbr/heOmP5VlbCTEJpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/exifr": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
|
||||
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@
|
|||
"@types/steamapi": "^2.2.5",
|
||||
"cloudinary": "^2.6.1",
|
||||
"dotenv": "^16.5.0",
|
||||
"exifr": "^7.1.3",
|
||||
"giantbombing-api": "^1.0.4",
|
||||
"gray-matter": "^4.0.3",
|
||||
"ioredis": "^5.4.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `technologies` on the `Project` table. All the data in this column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" DROP COLUMN "technologies";
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Media" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Post" ADD COLUMN "attachments" JSONB;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Album" ADD COLUMN "isPhotography" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Media" ADD COLUMN "isPhotography" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Media" ADD COLUMN "exifData" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ADD COLUMN "password" VARCHAR(255),
|
||||
ADD COLUMN "projectType" VARCHAR(50) NOT NULL DEFAULT 'work';
|
||||
24
prisma/migrations/add_media_usage_tracking/migration.sql
Normal file
24
prisma/migrations/add_media_usage_tracking/migration.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "MediaUsage" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"mediaId" INTEGER NOT NULL,
|
||||
"contentType" VARCHAR(50) NOT NULL,
|
||||
"contentId" INTEGER NOT NULL,
|
||||
"fieldName" VARCHAR(100) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MediaUsage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MediaUsage_mediaId_idx" ON "MediaUsage"("mediaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MediaUsage_contentType_contentId_idx" ON "MediaUsage"("contentType", "contentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MediaUsage_mediaId_contentType_contentId_fieldName_key" ON "MediaUsage"("mediaId", "contentType", "contentId", "fieldName");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MediaUsage" ADD CONSTRAINT "MediaUsage_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "Media"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
@ -20,7 +20,6 @@ model Project {
|
|||
year Int
|
||||
client String? @db.VarChar(255)
|
||||
role String? @db.VarChar(255)
|
||||
technologies Json? // Array of tech stack
|
||||
featuredImage String? @db.VarChar(500)
|
||||
logoUrl String? @db.VarChar(500)
|
||||
gallery Json? // Array of image URLs
|
||||
|
|
@ -28,8 +27,10 @@ model Project {
|
|||
caseStudyContent Json? // BlockNote JSON format
|
||||
backgroundColor String? @db.VarChar(50) // For project card styling
|
||||
highlightColor String? @db.VarChar(50) // For project card accent
|
||||
projectType String @default("work") @db.VarChar(50) // "work" or "labs"
|
||||
displayOrder Int @default(0)
|
||||
status String @default("draft") @db.VarChar(50)
|
||||
status String @default("draft") @db.VarChar(50) // "draft", "published", "list-only", "password-protected"
|
||||
password String? @db.VarChar(255) // Required when status is "password-protected"
|
||||
publishedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
|
@ -54,6 +55,7 @@ model Post {
|
|||
albumId Int?
|
||||
|
||||
featuredImage String? @db.VarChar(500)
|
||||
attachments Json? // Array of media IDs for photo attachments
|
||||
tags Json? // Array of tags
|
||||
status String @default("draft") @db.VarChar(50)
|
||||
publishedAt DateTime?
|
||||
|
|
@ -78,6 +80,7 @@ model Album {
|
|||
date DateTime?
|
||||
location String? @db.VarChar(255)
|
||||
coverPhotoId Int?
|
||||
isPhotography Boolean @default(false) // Show in photos experience
|
||||
status String @default("draft") @db.VarChar(50)
|
||||
showInUniverse Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
|
@ -133,9 +136,32 @@ model Media {
|
|||
thumbnailUrl String? @db.Text
|
||||
width Int?
|
||||
height Int?
|
||||
exifData Json? // EXIF data for photos
|
||||
altText String? @db.Text // Alt text for accessibility
|
||||
description String? @db.Text // Optional description
|
||||
usedIn Json @default("[]") // Track where media is used
|
||||
isPhotography Boolean @default(false) // Star for photos experience
|
||||
usedIn Json @default("[]") // Track where media is used (legacy)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
usage MediaUsage[]
|
||||
}
|
||||
|
||||
// Media usage tracking table
|
||||
model MediaUsage {
|
||||
id Int @id @default(autoincrement())
|
||||
mediaId Int
|
||||
contentType String @db.VarChar(50) // 'project', 'post', 'album'
|
||||
contentId Int
|
||||
fieldName String @db.VarChar(100) // 'featuredImage', 'logoUrl', 'gallery', 'content'
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([mediaId, contentType, contentId, fieldName])
|
||||
@@index([mediaId])
|
||||
@@index([contentType, contentId])
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ async function main() {
|
|||
year: 2023,
|
||||
client: 'Personal Project',
|
||||
role: 'Founder & Designer',
|
||||
technologies: ['React Native', 'TypeScript', 'Node.js', 'PostgreSQL'],
|
||||
projectType: 'work',
|
||||
featuredImage: '/images/projects/maitsu-cover.png',
|
||||
backgroundColor: '#FFF7EA',
|
||||
highlightColor: '#F77754',
|
||||
|
|
@ -43,7 +43,7 @@ async function main() {
|
|||
year: 2022,
|
||||
client: 'Slack Technologies',
|
||||
role: 'Senior Product Designer',
|
||||
technologies: ['Design Systems', 'User Research', 'Prototyping', 'Strategy'],
|
||||
projectType: 'work',
|
||||
featuredImage: '/images/projects/slack-cover.png',
|
||||
backgroundColor: '#4a154b',
|
||||
highlightColor: '#611F69',
|
||||
|
|
@ -62,7 +62,7 @@ async function main() {
|
|||
year: 2019,
|
||||
client: 'Figma Inc.',
|
||||
role: 'Product Designer',
|
||||
technologies: ['Product Design', 'Prototyping', 'User Research', 'Design Systems'],
|
||||
projectType: 'work',
|
||||
featuredImage: '/images/projects/figma-cover.png',
|
||||
backgroundColor: '#2c2c2c',
|
||||
highlightColor: '#0ACF83',
|
||||
|
|
@ -81,7 +81,7 @@ async function main() {
|
|||
year: 2011,
|
||||
client: 'Pinterest',
|
||||
role: 'Product Designer #1',
|
||||
technologies: ['Product Design', 'Mobile Design', 'Design Leadership', 'Visual Design'],
|
||||
projectType: 'work',
|
||||
featuredImage: '/images/projects/pinterest-cover.png',
|
||||
backgroundColor: '#f7f7f7',
|
||||
highlightColor: '#CB1F27',
|
||||
|
|
@ -92,7 +92,82 @@ async function main() {
|
|||
})
|
||||
])
|
||||
|
||||
console.log(`✅ Created ${projects.length} projects`)
|
||||
console.log(`✅ Created ${projects.length} work projects`)
|
||||
|
||||
// Create Labs projects
|
||||
const labsProjects = await Promise.all([
|
||||
prisma.project.create({
|
||||
data: {
|
||||
slug: 'granblue-team',
|
||||
title: 'granblue.team',
|
||||
subtitle: 'Comprehensive web app for Granblue Fantasy players',
|
||||
description: 'A comprehensive web application for Granblue Fantasy players to track raids, manage crews, and optimize team compositions. Features real-time raid tracking, character databases, and community tools.',
|
||||
year: 2022,
|
||||
client: 'Personal Project',
|
||||
role: 'Full-Stack Developer',
|
||||
externalUrl: 'https://granblue.team',
|
||||
backgroundColor: '#1a1a2e',
|
||||
highlightColor: '#16213e',
|
||||
projectType: 'labs',
|
||||
displayOrder: 1,
|
||||
status: 'published',
|
||||
publishedAt: new Date()
|
||||
}
|
||||
}),
|
||||
prisma.project.create({
|
||||
data: {
|
||||
slug: 'subway-board',
|
||||
title: 'Subway Board',
|
||||
subtitle: 'Beautiful, minimalist NYC subway dashboard',
|
||||
description: 'A beautiful, minimalist dashboard displaying real-time NYC subway arrival times. Clean interface inspired by the classic subway map design with live MTA data integration.',
|
||||
year: 2023,
|
||||
client: 'Personal Project',
|
||||
role: 'Developer & Designer',
|
||||
backgroundColor: '#0f4c81',
|
||||
highlightColor: '#1e3a5f',
|
||||
projectType: 'labs',
|
||||
displayOrder: 2,
|
||||
status: 'published',
|
||||
publishedAt: new Date()
|
||||
}
|
||||
}),
|
||||
prisma.project.create({
|
||||
data: {
|
||||
slug: 'siero-discord',
|
||||
title: 'Siero for Discord',
|
||||
subtitle: 'Discord bot for Granblue Fantasy communities',
|
||||
description: 'A Discord bot for Granblue Fantasy communities providing character lookups, raid notifications, and server management tools. Serves thousands of users across multiple servers.',
|
||||
year: 2021,
|
||||
client: 'Personal Project',
|
||||
role: 'Bot Developer',
|
||||
backgroundColor: '#5865f2',
|
||||
highlightColor: '#4752c4',
|
||||
projectType: 'labs',
|
||||
displayOrder: 3,
|
||||
status: 'published',
|
||||
publishedAt: new Date()
|
||||
}
|
||||
}),
|
||||
prisma.project.create({
|
||||
data: {
|
||||
slug: 'homelab',
|
||||
title: 'Homelab',
|
||||
subtitle: 'Self-hosted infrastructure on Kubernetes',
|
||||
description: 'Self-hosted infrastructure running on Kubernetes with monitoring, media servers, and development environments. Includes automated deployments and backup strategies.',
|
||||
year: 2023,
|
||||
client: 'Personal Project',
|
||||
role: 'DevOps Engineer',
|
||||
backgroundColor: '#ff6b35',
|
||||
highlightColor: '#e55a2b',
|
||||
projectType: 'labs',
|
||||
displayOrder: 4,
|
||||
status: 'published',
|
||||
publishedAt: new Date()
|
||||
}
|
||||
})
|
||||
])
|
||||
|
||||
console.log(`✅ Created ${labsProjects.length} labs projects`)
|
||||
|
||||
// Create test posts
|
||||
const posts = await Promise.all([
|
||||
|
|
@ -157,6 +232,7 @@ async function main() {
|
|||
date: new Date('2024-03-15'),
|
||||
location: 'Tokyo, Japan',
|
||||
status: 'published',
|
||||
isPhotography: true,
|
||||
showInUniverse: true
|
||||
}
|
||||
})
|
||||
|
|
@ -172,6 +248,8 @@ async function main() {
|
|||
height: 1080,
|
||||
caption: 'Tokyo Tower at sunset',
|
||||
displayOrder: 1,
|
||||
status: 'published',
|
||||
showInPhotos: true,
|
||||
exifData: {
|
||||
camera: 'Sony A7III',
|
||||
lens: '24-70mm f/2.8',
|
||||
|
|
@ -190,7 +268,9 @@ async function main() {
|
|||
width: 1920,
|
||||
height: 1080,
|
||||
caption: 'The famous Shibuya crossing',
|
||||
displayOrder: 2
|
||||
displayOrder: 2,
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
}
|
||||
})
|
||||
])
|
||||
|
|
|
|||
395
src/lib/components/DynamicPostContent.svelte
Normal file
395
src/lib/components/DynamicPostContent.svelte
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
<script lang="ts">
|
||||
import LinkCard from './LinkCard.svelte'
|
||||
|
||||
let { post }: { post: any } = $props()
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const getPostTypeLabel = (postType: string) => {
|
||||
switch (postType) {
|
||||
case 'post': return 'Post'
|
||||
case 'essay': return 'Essay'
|
||||
default: return 'Post'
|
||||
}
|
||||
}
|
||||
|
||||
// Render Edra/BlockNote JSON content to HTML
|
||||
const renderEdraContent = (content: any): string => {
|
||||
if (!content) return ''
|
||||
|
||||
// Handle both { blocks: [...] } and { content: [...] } formats
|
||||
const blocks = content.blocks || content.content || []
|
||||
if (!Array.isArray(blocks)) return ''
|
||||
|
||||
const renderBlock = (block: any): string => {
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
const level = block.attrs?.level || block.level || 1
|
||||
const headingText = block.content || block.text || ''
|
||||
return `<h${level}>${headingText}</h${level}>`
|
||||
|
||||
case 'paragraph':
|
||||
const paragraphText = block.content || block.text || ''
|
||||
if (!paragraphText) return '<p><br></p>'
|
||||
return `<p>${paragraphText}</p>`
|
||||
|
||||
case 'bulletList':
|
||||
case 'ul':
|
||||
const listItems = (block.content || []).map((item: any) => {
|
||||
const itemText = item.content || item.text || ''
|
||||
return `<li>${itemText}</li>`
|
||||
}).join('')
|
||||
return `<ul>${listItems}</ul>`
|
||||
|
||||
case 'orderedList':
|
||||
case 'ol':
|
||||
const orderedItems = (block.content || []).map((item: any) => {
|
||||
const itemText = item.content || item.text || ''
|
||||
return `<li>${itemText}</li>`
|
||||
}).join('')
|
||||
return `<ol>${orderedItems}</ol>`
|
||||
|
||||
case 'blockquote':
|
||||
const quoteText = block.content || block.text || ''
|
||||
return `<blockquote><p>${quoteText}</p></blockquote>`
|
||||
|
||||
case 'codeBlock':
|
||||
case 'code':
|
||||
const codeText = block.content || block.text || ''
|
||||
const language = block.attrs?.language || block.language || ''
|
||||
return `<pre><code class="language-${language}">${codeText}</code></pre>`
|
||||
|
||||
case 'image':
|
||||
const src = block.attrs?.src || block.src || ''
|
||||
const alt = block.attrs?.alt || block.alt || ''
|
||||
const caption = block.attrs?.caption || block.caption || ''
|
||||
return `<figure><img src="${src}" alt="${alt}" />${caption ? `<figcaption>${caption}</figcaption>` : ''}</figure>`
|
||||
|
||||
case 'hr':
|
||||
case 'horizontalRule':
|
||||
return '<hr>'
|
||||
|
||||
default:
|
||||
// For simple text content
|
||||
const text = block.content || block.text || ''
|
||||
if (text) {
|
||||
return `<p>${text}</p>`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return blocks.map(renderBlock).join('')
|
||||
}
|
||||
|
||||
const renderedContent = $derived(post.content ? renderEdraContent(post.content) : '')
|
||||
</script>
|
||||
|
||||
<article class="post-content {post.postType}">
|
||||
<header class="post-header">
|
||||
<div class="post-meta">
|
||||
<span class="post-type-badge">
|
||||
{getPostTypeLabel(post.postType)}
|
||||
</span>
|
||||
<time class="post-date" datetime={post.publishedAt}>
|
||||
{formatDate(post.publishedAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{#if post.title}
|
||||
<h1 class="post-title">{post.title}</h1>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if post.linkUrl}
|
||||
<div class="post-link-preview">
|
||||
<LinkCard link={{
|
||||
url: post.linkUrl,
|
||||
title: post.title,
|
||||
description: post.linkDescription
|
||||
}} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if post.attachments && Array.isArray(post.attachments) && post.attachments.length > 0}
|
||||
<div class="post-attachments">
|
||||
<h3>Attachments</h3>
|
||||
<div class="attachments-grid">
|
||||
{#each post.attachments as attachment}
|
||||
<div class="attachment-item">
|
||||
<img src={attachment.url} alt={attachment.caption || 'Attachment'} loading="lazy" />
|
||||
{#if attachment.caption}
|
||||
<p class="attachment-caption">{attachment.caption}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if renderedContent}
|
||||
<div class="post-body">
|
||||
{@html renderedContent}
|
||||
</div>
|
||||
{:else if post.excerpt}
|
||||
<div class="post-body">
|
||||
<p>{post.excerpt}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<footer class="post-footer">
|
||||
<a href="/universe" class="back-link">← Back to Universe</a>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
.post-content {
|
||||
max-width: 784px;
|
||||
margin: 0 auto;
|
||||
padding: 0 $unit-3x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: 0 $unit-2x;
|
||||
}
|
||||
|
||||
// Post type styles
|
||||
&.post {
|
||||
.post-body {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.essay {
|
||||
.post-body {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-header {
|
||||
margin-bottom: $unit-5x;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
margin-bottom: $unit-3x;
|
||||
}
|
||||
|
||||
.post-type-badge {
|
||||
background: $blue-60;
|
||||
color: white;
|
||||
padding: $unit-half $unit-2x;
|
||||
border-radius: 50px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
font-size: 0.9rem;
|
||||
color: $grey-40;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
margin: 0;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: $grey-10;
|
||||
line-height: 1.2;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.post-link-preview {
|
||||
margin-bottom: $unit-4x;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.post-attachments {
|
||||
margin-bottom: $unit-4x;
|
||||
|
||||
h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
.attachments-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: $unit;
|
||||
}
|
||||
|
||||
.attachment-caption {
|
||||
margin: $unit 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-body {
|
||||
color: $grey-20;
|
||||
line-height: 1.6;
|
||||
|
||||
:global(h1) {
|
||||
margin: $unit-5x 0 $unit-3x;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
:global(h2) {
|
||||
margin: $unit-4x 0 $unit-2x;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
:global(h3) {
|
||||
margin: $unit-3x 0 $unit-2x;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
:global(h4) {
|
||||
margin: $unit-3x 0 $unit-2x;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
:global(p) {
|
||||
margin: 0 0 $unit-3x;
|
||||
}
|
||||
|
||||
:global(ul),
|
||||
:global(ol) {
|
||||
margin: 0 0 $unit-3x;
|
||||
padding-left: $unit-3x;
|
||||
}
|
||||
|
||||
:global(ul li),
|
||||
:global(ol li) {
|
||||
margin-bottom: $unit;
|
||||
}
|
||||
|
||||
:global(blockquote) {
|
||||
margin: $unit-4x 0;
|
||||
padding: $unit-3x;
|
||||
background: $grey-97;
|
||||
border-left: 4px solid $grey-80;
|
||||
border-radius: $unit;
|
||||
color: $grey-30;
|
||||
font-style: italic;
|
||||
|
||||
:global(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(code) {
|
||||
background: $grey-95;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
:global(pre) {
|
||||
background: $grey-95;
|
||||
padding: $unit-3x;
|
||||
border-radius: $unit;
|
||||
overflow-x: auto;
|
||||
margin: 0 0 $unit-3x;
|
||||
border: 1px solid $grey-85;
|
||||
|
||||
:global(code) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
color: $red-60;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: wavy;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
}
|
||||
|
||||
:global(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid $grey-85;
|
||||
margin: $unit-4x 0;
|
||||
}
|
||||
|
||||
:global(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
:global(strong) {
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
:global(figure) {
|
||||
margin: $unit-4x 0;
|
||||
|
||||
:global(img) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: $unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
margin-top: $unit-6x;
|
||||
padding-top: $unit-4x;
|
||||
border-top: 1px solid $grey-85;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: $red-60;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: wavy;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,22 +1,64 @@
|
|||
<script lang="ts">
|
||||
import type { LabProject } from '$lib/types/labs'
|
||||
import type { Project } from '$lib/types/project'
|
||||
|
||||
const { project }: { project: LabProject } = $props()
|
||||
const { project }: { project: Project } = $props()
|
||||
|
||||
// Determine if the project is clickable (not list-only)
|
||||
const isClickable = $derived(project.status !== 'list-only')
|
||||
const projectUrl = $derived(`/labs/${project.slug}`)
|
||||
</script>
|
||||
|
||||
<article class="lab-card">
|
||||
<div class="card-header">
|
||||
<h3 class="project-title">{project.title}</h3>
|
||||
<span class="project-year">{project.year}</span>
|
||||
</div>
|
||||
{#if isClickable}
|
||||
<a href={projectUrl} class="lab-card clickable">
|
||||
<div class="card-header">
|
||||
<h3 class="project-title">{project.title}</h3>
|
||||
<span class="project-year">{project.year}</span>
|
||||
</div>
|
||||
|
||||
<p class="project-description">{project.description}</p>
|
||||
<p class="project-description">{project.description}</p>
|
||||
|
||||
{#if project.url || project.github}
|
||||
<div class="project-links">
|
||||
{#if project.url}
|
||||
{#if project.externalUrl}
|
||||
<div class="project-links">
|
||||
<span class="project-link primary external">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M10 6H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Visit Project
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add status indicators for different project states -->
|
||||
{#if project.status === 'password-protected'}
|
||||
<div class="status-indicator password-protected">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
||||
<circle cx="12" cy="16" r="1" fill="currentColor"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<span>Password Protected</span>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<article class="lab-card">
|
||||
<div class="card-header">
|
||||
<h3 class="project-title">{project.title}</h3>
|
||||
<span class="project-year">{project.year}</span>
|
||||
</div>
|
||||
|
||||
<p class="project-description">{project.description}</p>
|
||||
|
||||
{#if project.externalUrl}
|
||||
<div class="project-links">
|
||||
<a
|
||||
href={project.url}
|
||||
href={project.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="project-link primary"
|
||||
|
|
@ -32,29 +74,21 @@
|
|||
</svg>
|
||||
Visit Project
|
||||
</a>
|
||||
{/if}
|
||||
{#if project.github}
|
||||
<a
|
||||
href={project.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="project-link secondary"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add status indicators for different project states -->
|
||||
{#if project.status === 'list-only'}
|
||||
<div class="status-indicator list-only">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M1 1l22 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>View Only</span>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.lab-card {
|
||||
|
|
@ -64,12 +98,19 @@
|
|||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-2x;
|
||||
}
|
||||
|
|
@ -117,6 +158,7 @@
|
|||
display: flex;
|
||||
gap: $unit-2x;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
.project-link {
|
||||
|
|
@ -139,6 +181,10 @@
|
|||
background: darken($labs-color, 10%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&.external {
|
||||
pointer-events: none; // Prevent clicking when it's inside a clickable card
|
||||
}
|
||||
}
|
||||
|
||||
&.secondary {
|
||||
|
|
@ -156,4 +202,34 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
font-size: 0.875rem;
|
||||
padding: $unit $unit-2x;
|
||||
border-radius: $unit-2x;
|
||||
margin-top: $unit-2x;
|
||||
|
||||
&.list-only {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.password-protected {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,84 +1,37 @@
|
|||
<script lang="ts">
|
||||
import PhotoItem from '$components/PhotoItem.svelte'
|
||||
import PhotoLightbox from '$components/PhotoLightbox.svelte'
|
||||
import type { PhotoItem as PhotoItemType, Photo } from '$lib/types/photos'
|
||||
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
|
||||
|
||||
const { photoItems }: { photoItems: PhotoItemType[] } = $props()
|
||||
|
||||
let lightboxPhoto: Photo | null = $state(null)
|
||||
let lightboxAlbumPhotos: Photo[] = $state([])
|
||||
let lightboxIndex = $state(0)
|
||||
|
||||
function openLightbox(photo: Photo, albumPhotos?: Photo[]) {
|
||||
if (albumPhotos && albumPhotos.length > 0) {
|
||||
// For albums, start with the first photo, not the cover photo
|
||||
lightboxAlbumPhotos = albumPhotos
|
||||
lightboxIndex = 0
|
||||
lightboxPhoto = albumPhotos[0]
|
||||
} else {
|
||||
// For individual photos
|
||||
lightboxPhoto = photo
|
||||
lightboxAlbumPhotos = []
|
||||
lightboxIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
lightboxPhoto = null
|
||||
lightboxAlbumPhotos = []
|
||||
lightboxIndex = 0
|
||||
}
|
||||
|
||||
function navigateLightbox(direction: 'prev' | 'next') {
|
||||
if (lightboxAlbumPhotos.length === 0) return
|
||||
|
||||
if (direction === 'prev') {
|
||||
lightboxIndex = lightboxIndex > 0 ? lightboxIndex - 1 : lightboxAlbumPhotos.length - 1
|
||||
} else {
|
||||
lightboxIndex = lightboxIndex < lightboxAlbumPhotos.length - 1 ? lightboxIndex + 1 : 0
|
||||
}
|
||||
|
||||
lightboxPhoto = lightboxAlbumPhotos[lightboxIndex]
|
||||
}
|
||||
const {
|
||||
photoItems,
|
||||
albumSlug
|
||||
}: {
|
||||
photoItems: PhotoItemType[]
|
||||
albumSlug?: string
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div class="photo-grid-container">
|
||||
<div class="photo-grid">
|
||||
{#each photoItems as item}
|
||||
<PhotoItem {item} onPhotoClick={openLightbox} />
|
||||
<PhotoItem {item} {albumSlug} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if lightboxPhoto}
|
||||
<PhotoLightbox
|
||||
photo={lightboxPhoto}
|
||||
albumPhotos={lightboxAlbumPhotos}
|
||||
currentIndex={lightboxIndex}
|
||||
onClose={closeLightbox}
|
||||
onNavigate={navigateLightbox}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.photo-grid-container {
|
||||
width: 100%;
|
||||
padding: 0 $unit-2x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x $unit;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
columns: 3;
|
||||
column-gap: $unit-2x;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
column-gap: $unit-3x;
|
||||
margin: 0;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
columns: 2;
|
||||
column-gap: $unit;
|
||||
column-gap: $unit-2x;
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,32 @@
|
|||
<script lang="ts">
|
||||
import type { PhotoItem, Photo, PhotoAlbum } from '$lib/types/photos'
|
||||
import { isAlbum } from '$lib/types/photos'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
const {
|
||||
item,
|
||||
onPhotoClick
|
||||
albumSlug // For when this is used within an album context
|
||||
}: {
|
||||
item: PhotoItem
|
||||
onPhotoClick: (photo: Photo, albumPhotos?: Photo[]) => void
|
||||
albumSlug?: string
|
||||
} = $props()
|
||||
|
||||
let imageLoaded = $state(false)
|
||||
|
||||
function handleClick() {
|
||||
if (isAlbum(item)) {
|
||||
// For albums, open the cover photo with album navigation
|
||||
onPhotoClick(item.coverPhoto, item.photos)
|
||||
// Navigate to album page using the slug
|
||||
goto(`/photos/${item.slug}`)
|
||||
} else {
|
||||
// For individual photos, open just that photo
|
||||
onPhotoClick(item)
|
||||
// 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}`)
|
||||
} else {
|
||||
// For standalone photos, navigate to a generic photo page (to be implemented)
|
||||
console.log('Individual photo navigation not yet implemented')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,13 +25,13 @@
|
|||
</time>
|
||||
</header>
|
||||
|
||||
{#if post.type === 'image' && post.images}
|
||||
{#if post.images && post.images.length > 0}
|
||||
<div class="post-images">
|
||||
<ImagePost images={post.images} alt={post.title || 'Post image'} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if post.type === 'link' && post.link}
|
||||
{#if post.link}
|
||||
<div class="post-link-preview">
|
||||
<LinkCard link={post.link} />
|
||||
</div>
|
||||
|
|
@ -51,24 +51,44 @@
|
|||
max-width: 784px;
|
||||
margin: 0 auto;
|
||||
|
||||
&.note {
|
||||
// Post type styles for simplified post types
|
||||
&.post {
|
||||
.post-body {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.essay {
|
||||
.post-body {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy type support
|
||||
&.note,
|
||||
&.microblog {
|
||||
.post-body {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.image {
|
||||
.post-images {
|
||||
margin-bottom: $unit-4x;
|
||||
&.blog {
|
||||
.post-body {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.link {
|
||||
.post-link-preview {
|
||||
margin-bottom: $unit-4x;
|
||||
max-width: 600px;
|
||||
}
|
||||
}
|
||||
// Content-specific styles
|
||||
.post-images {
|
||||
margin-bottom: $unit-4x;
|
||||
}
|
||||
|
||||
.post-link-preview {
|
||||
margin-bottom: $unit-4x;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.post-header {
|
||||
|
|
|
|||
281
src/lib/components/ProjectContent.svelte
Normal file
281
src/lib/components/ProjectContent.svelte
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
<script lang="ts">
|
||||
import type { Project } from '$lib/types/project'
|
||||
|
||||
interface Props {
|
||||
project: Project
|
||||
}
|
||||
|
||||
let { project }: Props = $props()
|
||||
|
||||
// Function to render BlockNote content as HTML
|
||||
function renderBlockNoteContent(content: any): string {
|
||||
if (!content || !content.content) return ''
|
||||
|
||||
return content.content
|
||||
.map((block: any) => {
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
const level = block.attrs?.level || 1
|
||||
const text = block.content?.[0]?.text || ''
|
||||
return `<h${level}>${text}</h${level}>`
|
||||
|
||||
case 'paragraph':
|
||||
if (!block.content || block.content.length === 0) return '<p><br></p>'
|
||||
const paragraphText = block.content.map((c: any) => c.text || '').join('')
|
||||
return `<p>${paragraphText}</p>`
|
||||
|
||||
case 'image':
|
||||
return `<figure><img src="${block.attrs?.src}" alt="${block.attrs?.alt || ''}" style="width: ${block.attrs?.width || '100%'}; height: ${block.attrs?.height || 'auto'};" /></figure>`
|
||||
|
||||
case 'bulletedList':
|
||||
case 'numberedList':
|
||||
const tag = block.type === 'bulletedList' ? 'ul' : 'ol'
|
||||
const items =
|
||||
block.content
|
||||
?.map((item: any) => {
|
||||
const itemText = item.content?.[0]?.content?.[0]?.text || ''
|
||||
return `<li>${itemText}</li>`
|
||||
})
|
||||
.join('') || ''
|
||||
return `<${tag}>${items}</${tag}>`
|
||||
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="project-content">
|
||||
<!-- Project Details -->
|
||||
<div class="project-details">
|
||||
<div class="meta-grid">
|
||||
{#if project.client}
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Client</span>
|
||||
<span class="meta-value">{project.client}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if project.year}
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Year</span>
|
||||
<span class="meta-value">{project.year}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if project.role}
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Role</span>
|
||||
<span class="meta-value">{project.role}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if project.externalUrl}
|
||||
<div class="external-link-wrapper">
|
||||
<a
|
||||
href={project.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="external-link"
|
||||
>
|
||||
Visit Project →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Case Study Content -->
|
||||
{#if project.caseStudyContent && project.caseStudyContent.content && project.caseStudyContent.content.length > 0}
|
||||
<div class="case-study-section">
|
||||
<div class="case-study-content">
|
||||
{@html renderBlockNoteContent(project.caseStudyContent)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Gallery (if available) -->
|
||||
{#if project.gallery && project.gallery.length > 0}
|
||||
<div class="gallery-section">
|
||||
<h2>Gallery</h2>
|
||||
<div class="gallery-grid">
|
||||
{#each project.gallery as image}
|
||||
<img src={image} alt="Project gallery image" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="project-nav">
|
||||
{#if project.projectType === 'labs'}
|
||||
<a href="/labs" class="back-link">← Back to labs</a>
|
||||
{:else}
|
||||
<a href="/" class="back-link">← Back to projects</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
/* Project Content */
|
||||
.project-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
}
|
||||
|
||||
.project-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
padding-bottom: $unit-3x;
|
||||
border-bottom: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: $unit-2x;
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-60;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 1rem;
|
||||
color: $grey-20;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.external-link-wrapper {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.external-link {
|
||||
display: inline-block;
|
||||
padding: $unit-2x $unit-3x;
|
||||
background: $grey-10;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 50px;
|
||||
font-weight: 500;
|
||||
font-size: 0.925rem;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
/* Case Study Section */
|
||||
.case-study-content {
|
||||
:global(h1),
|
||||
:global(h2),
|
||||
:global(h3) {
|
||||
margin: $unit-3x 0 $unit-2x;
|
||||
color: $grey-10;
|
||||
font-weight: 600;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(h1) {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
:global(h2) {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
:global(h3) {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
:global(p) {
|
||||
margin: $unit-2x 0;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.65;
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
:global(figure) {
|
||||
margin: $unit-3x 0;
|
||||
|
||||
:global(img) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
:global(ul),
|
||||
:global(ol) {
|
||||
margin: $unit-2x 0;
|
||||
padding-left: $unit-3x;
|
||||
|
||||
:global(li) {
|
||||
margin: $unit 0;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.65;
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Gallery Section */
|
||||
.gallery-section {
|
||||
padding-top: $unit-3x;
|
||||
border-top: 1px solid $grey-90;
|
||||
|
||||
h2 {
|
||||
font-size: 1.75rem;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: $unit-2x;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.project-nav {
|
||||
text-align: center;
|
||||
padding-top: $unit-3x;
|
||||
border-top: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
slug: string
|
||||
description: string
|
||||
highlightColor: string
|
||||
status?: 'draft' | 'published' | 'list-only' | 'password-protected'
|
||||
index?: number
|
||||
}
|
||||
|
||||
|
|
@ -19,10 +20,14 @@
|
|||
slug,
|
||||
description,
|
||||
highlightColor,
|
||||
status = 'published',
|
||||
index = 0
|
||||
}: Props = $props()
|
||||
|
||||
const isEven = $derived(index % 2 === 0)
|
||||
const isClickable = $derived(status === 'published' || status === 'password-protected')
|
||||
const isListOnly = $derived(status === 'list-only')
|
||||
const isPasswordProtected = $derived(status === 'password-protected')
|
||||
|
||||
// Create highlighted description
|
||||
const highlightedDescription = $derived(
|
||||
|
|
@ -151,21 +156,26 @@
|
|||
}
|
||||
|
||||
function handleClick() {
|
||||
goto(`/work/${slug}`)
|
||||
if (isClickable) {
|
||||
goto(`/work/${slug}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="project-item {isEven ? 'even' : 'odd'}"
|
||||
class:clickable={isClickable}
|
||||
class:list-only={isListOnly}
|
||||
class:password-protected={isPasswordProtected}
|
||||
bind:this={cardElement}
|
||||
onclick={handleClick}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleClick()}
|
||||
onmousemove={handleMouseMove}
|
||||
onmouseenter={handleMouseEnter}
|
||||
onmouseleave={handleMouseLeave}
|
||||
onmousemove={isClickable ? handleMouseMove : undefined}
|
||||
onmouseenter={isClickable ? handleMouseEnter : undefined}
|
||||
onmouseleave={isClickable ? handleMouseLeave : undefined}
|
||||
style="transform: {transform};"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
role={isClickable ? 'button' : 'article'}
|
||||
tabindex={isClickable ? 0 : -1}
|
||||
>
|
||||
<div class="project-logo" style="background-color: {backgroundColor}">
|
||||
{#if svgContent}
|
||||
|
|
@ -178,6 +188,26 @@
|
|||
</div>
|
||||
<div class="project-content">
|
||||
<p class="project-description">{@html highlightedDescription}</p>
|
||||
|
||||
{#if isListOnly}
|
||||
<div class="status-indicator list-only">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M20.188 10.934c.388.472.612 1.057.612 1.686 0 .63-.224 1.214-.612 1.686a11.79 11.79 0 01-1.897 1.853c-1.481 1.163-3.346 2.24-5.291 2.24-1.945 0-3.81-1.077-5.291-2.24A11.79 11.79 0 016.812 14.32C6.224 13.648 6 13.264 6 12.62c0-.63.224-1.214.612-1.686A11.79 11.79 0 018.709 9.08c1.481-1.163 3.346-2.24 5.291-2.24 1.945 0 3.81 1.077 5.291 2.24a11.79 11.79 0 011.897 1.853z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2 2l20 20" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>Coming Soon</span>
|
||||
</div>
|
||||
{:else if isPasswordProtected}
|
||||
<div class="status-indicator password-protected">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" stroke="currentColor" stroke-width="2"/>
|
||||
<circle cx="12" cy="16" r="1" fill="currentColor"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>Password Required</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -192,15 +222,29 @@
|
|||
border-radius: $card-corner-radius;
|
||||
transition:
|
||||
transform 0.15s ease-out,
|
||||
box-shadow 0.15s ease-out;
|
||||
box-shadow 0.15s ease-out,
|
||||
opacity 0.15s ease-out;
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
box-shadow:
|
||||
0 10px 30px rgba(0, 0, 0, 0.1),
|
||||
0 1px 8px rgba(0, 0, 0, 0.06);
|
||||
&.clickable {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
box-shadow:
|
||||
0 10px 30px rgba(0, 0, 0, 0.1),
|
||||
0 1px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
&.list-only {
|
||||
opacity: 0.7;
|
||||
background: $grey-97;
|
||||
}
|
||||
|
||||
&.password-protected {
|
||||
// Keep full interactivity for password-protected items
|
||||
}
|
||||
|
||||
&.odd {
|
||||
|
|
@ -252,6 +296,27 @@
|
|||
color: $grey-00;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
margin-top: $unit;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&.list-only {
|
||||
color: $grey-60;
|
||||
}
|
||||
|
||||
&.password-protected {
|
||||
color: $blue-50;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
.project-item {
|
||||
flex-direction: column !important;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
slug={project.slug}
|
||||
description={project.description || ''}
|
||||
highlightColor={project.highlightColor || '#333'}
|
||||
status={project.status}
|
||||
{index}
|
||||
/>
|
||||
</li>
|
||||
|
|
|
|||
243
src/lib/components/ProjectPasswordProtection.svelte
Normal file
243
src/lib/components/ProjectPasswordProtection.svelte
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
<script lang="ts">
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
projectSlug: string
|
||||
correctPassword: string
|
||||
projectType?: 'work' | 'labs'
|
||||
children?: any
|
||||
}
|
||||
|
||||
let { projectSlug, correctPassword, projectType = 'work', children }: Props = $props()
|
||||
|
||||
let isUnlocked = $state(false)
|
||||
let password = $state('')
|
||||
let error = $state('')
|
||||
let isLoading = $state(false)
|
||||
|
||||
// Check if project is already unlocked in session storage
|
||||
onMount(() => {
|
||||
const unlockedProjects = JSON.parse(sessionStorage.getItem('unlockedProjects') || '[]')
|
||||
isUnlocked = unlockedProjects.includes(projectSlug)
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!password.trim()) {
|
||||
error = 'Please enter a password'
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
error = ''
|
||||
|
||||
// Simulate a small delay for better UX
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
if (password === correctPassword) {
|
||||
// Store in session storage
|
||||
const unlockedProjects = JSON.parse(sessionStorage.getItem('unlockedProjects') || '[]')
|
||||
if (!unlockedProjects.includes(projectSlug)) {
|
||||
unlockedProjects.push(projectSlug)
|
||||
sessionStorage.setItem('unlockedProjects', JSON.stringify(unlockedProjects))
|
||||
}
|
||||
isUnlocked = true
|
||||
} else {
|
||||
error = 'Incorrect password. Please try again.'
|
||||
password = ''
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
function handleKeyPress(event: KeyboardEvent) {
|
||||
if (event.key === 'Enter') {
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isUnlocked}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
{#snippet passwordHeader()}
|
||||
<div class="password-header">
|
||||
<div class="lock-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M18 11H6C5.45 11 5 11.45 5 12V19C5 19.55 5.45 20 6 20H18C18.55 20 19 19.55 19 19V12C19 11.45 18.55 11 18 11Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 11V7C7 5.67392 7.52678 4.40215 8.46447 3.46447C9.40215 2.52678 10.6739 2 12 2C13.3261 2 14.5979 2.52678 15.5355 3.46447C16.4732 4.40215 17 5.67392 17 7V11"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>This project is password protected</h1>
|
||||
<p>Please enter the password to view this project.</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet passwordContent()}
|
||||
<div class="password-content">
|
||||
<div class="form-wrapper">
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Enter password"
|
||||
class="password-input"
|
||||
class:error
|
||||
onkeypress={handleKeyPress}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
onclick={handleSubmit}
|
||||
disabled={isLoading || !password.trim()}
|
||||
class="submit-button"
|
||||
>
|
||||
{isLoading ? 'Checking...' : 'Access Project'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="back-link-wrapper">
|
||||
{#if projectType === 'labs'}
|
||||
<a href="/labs" class="back-link">← Back to labs</a>
|
||||
{:else}
|
||||
<a href="/" class="back-link">← Back to projects</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{@render passwordHeader()}
|
||||
{@render passwordContent()}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.password-header {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
|
||||
.lock-icon {
|
||||
color: $grey-40;
|
||||
margin-bottom: $unit-3x;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
margin: 0 0 $unit-2x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
color: $grey-40;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.password-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
gap: $unit-4x;
|
||||
|
||||
.form-wrapper {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
margin-bottom: $unit-2x;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.password-input {
|
||||
flex: 1;
|
||||
padding: $unit-2x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $unit;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue-50;
|
||||
box-shadow: 0 0 0 3px rgba(20, 130, 193, 0.1);
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: $red-50;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $grey-95;
|
||||
color: $grey-60;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.submit-button) {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 0.875rem;
|
||||
color: $red-50;
|
||||
text-align: left;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.back-link-wrapper {
|
||||
border-top: 1px solid $grey-90;
|
||||
padding-top: $unit-3x;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
220
src/lib/components/UniverseAlbumCard.svelte
Normal file
220
src/lib/components/UniverseAlbumCard.svelte
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<script lang="ts">
|
||||
import UniverseIcon from '$icons/universe.svg'
|
||||
import type { UniverseItem } from '../../routes/api/universe/+server'
|
||||
|
||||
let { album }: { album: UniverseItem } = $props()
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="universe-album-card">
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<div class="album-type-badge">
|
||||
Album
|
||||
</div>
|
||||
<time class="album-date" datetime={album.publishedAt}>
|
||||
{formatDate(album.publishedAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{#if album.coverPhoto}
|
||||
<div class="album-cover">
|
||||
<img
|
||||
src={album.coverPhoto.thumbnailUrl || album.coverPhoto.url}
|
||||
alt={album.coverPhoto.caption || album.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div class="photo-count-overlay">
|
||||
{album.photosCount || 0} photo{(album.photosCount || 0) !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="album-info">
|
||||
<h2 class="album-title">
|
||||
<a href="/photos/{album.slug}" class="album-title-link">{album.title}</a>
|
||||
</h2>
|
||||
|
||||
{#if album.location || album.date}
|
||||
<div class="album-meta">
|
||||
{#if album.date}
|
||||
<span class="album-meta-item">📅 {formatDate(album.date)}</span>
|
||||
{/if}
|
||||
{#if album.location}
|
||||
<span class="album-meta-item">📍 {album.location}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if album.description}
|
||||
<p class="album-description">{album.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<a href="/photos/{album.slug}" class="view-album">
|
||||
View album →
|
||||
</a>
|
||||
<UniverseIcon class="universe-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
.universe-album-card {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: $unit-4x;
|
||||
background: $grey-100;
|
||||
border-radius: $card-corner-radius;
|
||||
border: 1px solid $grey-95;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-85;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $unit-3x;
|
||||
}
|
||||
|
||||
.album-type-badge {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
padding: $unit-half $unit-2x;
|
||||
border-radius: 50px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.album-date {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.album-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border-radius: $unit;
|
||||
overflow: hidden;
|
||||
margin-bottom: $unit-3x;
|
||||
background: $grey-95;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-count-overlay {
|
||||
position: absolute;
|
||||
bottom: $unit;
|
||||
right: $unit;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: $unit-half $unit-2x;
|
||||
border-radius: 50px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.album-info {
|
||||
margin-bottom: $unit-3x;
|
||||
}
|
||||
|
||||
.album-title {
|
||||
margin: 0 0 $unit-2x;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.album-title-link {
|
||||
color: $grey-10;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: #22c55e;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: wavy;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
}
|
||||
|
||||
.album-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $unit-2x;
|
||||
margin-bottom: $unit-2x;
|
||||
|
||||
.album-meta-item {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
}
|
||||
}
|
||||
|
||||
.album-description {
|
||||
margin: 0;
|
||||
color: $grey-20;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: $unit-2x;
|
||||
border-top: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
.view-album {
|
||||
color: #22c55e;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: wavy;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.universe-icon) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: $grey-40;
|
||||
}
|
||||
</style>
|
||||
42
src/lib/components/UniverseFeed.svelte
Normal file
42
src/lib/components/UniverseFeed.svelte
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<script lang="ts">
|
||||
import UniversePostCard from './UniversePostCard.svelte'
|
||||
import UniverseAlbumCard from './UniverseAlbumCard.svelte'
|
||||
import type { UniverseItem } from '../../routes/api/universe/+server'
|
||||
|
||||
let { items }: { items: UniverseItem[] } = $props()
|
||||
</script>
|
||||
|
||||
<div class="universe-feed">
|
||||
{#if items && items.length > 0}
|
||||
{#each items as item}
|
||||
{#if item.type === 'post'}
|
||||
<UniversePostCard post={item} />
|
||||
{:else if item.type === 'album'}
|
||||
<UniverseAlbumCard album={item} />
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<p>No content found in the universe yet.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.universe-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: $unit-6x $unit-3x;
|
||||
color: $grey-40;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
244
src/lib/components/UniversePostCard.svelte
Normal file
244
src/lib/components/UniversePostCard.svelte
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
<script lang="ts">
|
||||
import UniverseIcon from '$icons/universe.svg'
|
||||
import type { UniverseItem } from '../../routes/api/universe/+server'
|
||||
|
||||
let { post }: { post: UniverseItem } = $props()
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const getPostTypeLabel = (postType: string) => {
|
||||
switch (postType) {
|
||||
case 'post': return 'Post'
|
||||
case 'essay': return 'Essay'
|
||||
default: return 'Post'
|
||||
}
|
||||
}
|
||||
|
||||
// Extract text content from Edra JSON for excerpt
|
||||
const getContentExcerpt = (content: any, maxLength = 200): string => {
|
||||
if (!content || !content.content) return ''
|
||||
|
||||
const extractText = (node: any): string => {
|
||||
if (node.text) return node.text
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
return node.content.map(extractText).join(' ')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const text = content.content.map(extractText).join(' ').trim()
|
||||
if (text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength).trim() + '...'
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="universe-post-card">
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<div class="post-type-badge">
|
||||
{getPostTypeLabel(post.postType || 'post')}
|
||||
</div>
|
||||
<time class="post-date" datetime={post.publishedAt}>
|
||||
{formatDate(post.publishedAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{#if post.title}
|
||||
<h2 class="post-title">
|
||||
<a href="/universe/{post.slug}" class="post-title-link">{post.title}</a>
|
||||
</h2>
|
||||
{/if}
|
||||
|
||||
{#if post.linkUrl}
|
||||
<!-- Link post type -->
|
||||
<div class="link-preview">
|
||||
<a href={post.linkUrl} target="_blank" rel="noopener noreferrer" class="link-url">
|
||||
{post.linkUrl}
|
||||
</a>
|
||||
{#if post.linkDescription}
|
||||
<p class="link-description">{post.linkDescription}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="post-excerpt">
|
||||
{#if post.excerpt}
|
||||
<p>{post.excerpt}</p>
|
||||
{:else if post.content}
|
||||
<p>{getContentExcerpt(post.content)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if post.attachments && Array.isArray(post.attachments) && post.attachments.length > 0}
|
||||
<div class="attachments">
|
||||
<div class="attachment-count">
|
||||
📎 {post.attachments.length} attachment{post.attachments.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-footer">
|
||||
<a href="/universe/{post.slug}" class="read-more">
|
||||
Read more →
|
||||
</a>
|
||||
<UniverseIcon class="universe-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
.universe-post-card {
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: $unit-4x;
|
||||
background: $grey-100;
|
||||
border-radius: $card-corner-radius;
|
||||
border: 1px solid $grey-95;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-85;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
.post-type-badge {
|
||||
background: $blue-60;
|
||||
color: white;
|
||||
padding: $unit-half $unit-2x;
|
||||
border-radius: 50px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
margin: 0 0 $unit-3x;
|
||||
font-size: 1.375rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.post-title-link {
|
||||
color: $grey-10;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $red-60;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: wavy;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
}
|
||||
|
||||
.link-preview {
|
||||
background: $grey-97;
|
||||
border: 1px solid $grey-90;
|
||||
border-radius: $unit;
|
||||
padding: $unit-2x;
|
||||
margin-bottom: $unit-3x;
|
||||
|
||||
.link-url {
|
||||
display: block;
|
||||
color: $blue-60;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: $unit;
|
||||
word-break: break-all;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.link-description {
|
||||
margin: 0;
|
||||
color: $grey-30;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.post-excerpt {
|
||||
margin-bottom: $unit-3x;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: $grey-20;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.attachments {
|
||||
margin-bottom: $unit-3x;
|
||||
|
||||
.attachment-count {
|
||||
background: $grey-95;
|
||||
border: 1px solid $grey-85;
|
||||
border-radius: $unit;
|
||||
padding: $unit $unit-2x;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: $unit-2x;
|
||||
border-top: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
color: $red-60;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: wavy;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.universe-icon) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: $grey-40;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
{ text: 'Dashboard', href: '/admin', icon: DashboardIcon },
|
||||
{ text: 'Projects', href: '/admin/projects', icon: WorkIcon },
|
||||
{ text: 'Universe', href: '/admin/posts', icon: UniverseIcon },
|
||||
{ text: 'Albums', href: '/admin/albums', icon: PhotosIcon },
|
||||
{ text: 'Media', href: '/admin/media', icon: PhotosIcon }
|
||||
]
|
||||
|
||||
|
|
@ -29,9 +30,11 @@
|
|||
? 1
|
||||
: currentPath.startsWith('/admin/posts')
|
||||
? 2
|
||||
: currentPath.startsWith('/admin/media')
|
||||
: currentPath.startsWith('/admin/albums')
|
||||
? 3
|
||||
: -1
|
||||
: currentPath.startsWith('/admin/media')
|
||||
? 4
|
||||
: -1
|
||||
)
|
||||
</script>
|
||||
|
||||
|
|
@ -134,8 +137,8 @@
|
|||
}
|
||||
|
||||
.brand-logo {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -1,53 +1,57 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Button from './Button.svelte'
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
title?: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
onConfirm: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
isOpen = $bindable(),
|
||||
title = 'Delete item?',
|
||||
message,
|
||||
confirmText = 'Delete',
|
||||
cancelText = 'Cancel'
|
||||
cancelText = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
confirm: void
|
||||
cancel: void
|
||||
}>()
|
||||
|
||||
function handleConfirm() {
|
||||
dispatch('confirm')
|
||||
onConfirm()
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
dispatch('cancel')
|
||||
isOpen = false
|
||||
onCancel?.()
|
||||
}
|
||||
|
||||
function handleBackdropClick() {
|
||||
dispatch('cancel')
|
||||
isOpen = false
|
||||
onCancel?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
<div class="modal-actions">
|
||||
<Button variant="secondary" onclick={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button variant="danger" onclick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
{#if isOpen}
|
||||
<div class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()}>
|
||||
<h2>{title}</h2>
|
||||
<p>{message}</p>
|
||||
<div class="modal-actions">
|
||||
<Button variant="secondary" onclick={handleCancel}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button variant="danger" onclick={handleConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.modal-backdrop {
|
||||
|
|
|
|||
|
|
@ -23,23 +23,61 @@
|
|||
// Form state
|
||||
let altText = $state('')
|
||||
let description = $state('')
|
||||
let isPhotography = $state(false)
|
||||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
let successMessage = $state('')
|
||||
|
||||
// Usage tracking state
|
||||
let usage = $state<Array<{
|
||||
contentType: string
|
||||
contentId: number
|
||||
contentTitle: string
|
||||
fieldDisplayName: string
|
||||
contentUrl?: string
|
||||
createdAt: string
|
||||
}>>([])
|
||||
let loadingUsage = $state(false)
|
||||
|
||||
// Initialize form when media changes
|
||||
$effect(() => {
|
||||
if (media) {
|
||||
altText = media.altText || ''
|
||||
description = media.description || ''
|
||||
isPhotography = media.isPhotography || false
|
||||
error = ''
|
||||
successMessage = ''
|
||||
loadUsage()
|
||||
}
|
||||
})
|
||||
|
||||
// Load usage information
|
||||
async function loadUsage() {
|
||||
if (!media) return
|
||||
|
||||
try {
|
||||
loadingUsage = true
|
||||
const response = await authenticatedFetch(`/api/media/${media.id}/usage`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
usage = data.usage || []
|
||||
} else {
|
||||
console.warn('Failed to load media usage')
|
||||
usage = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading media usage:', error)
|
||||
usage = []
|
||||
} finally {
|
||||
loadingUsage = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
altText = ''
|
||||
description = ''
|
||||
isPhotography = false
|
||||
error = ''
|
||||
successMessage = ''
|
||||
isOpen = false
|
||||
|
|
@ -53,14 +91,15 @@
|
|||
isSaving = true
|
||||
error = ''
|
||||
|
||||
const response = await authenticatedFetch(`/api/media/${media.id}/metadata`, {
|
||||
method: 'PATCH',
|
||||
const response = await authenticatedFetch(`/api/media/${media.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
altText: altText.trim() || null,
|
||||
description: description.trim() || null
|
||||
description: description.trim() || null,
|
||||
isPhotography: isPhotography
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -240,25 +279,58 @@
|
|||
fullWidth
|
||||
/>
|
||||
|
||||
<!-- Photography Toggle -->
|
||||
<div class="photography-toggle">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isPhotography}
|
||||
disabled={isSaving}
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
<div class="toggle-content">
|
||||
<span class="toggle-title">Photography</span>
|
||||
<span class="toggle-description">Show this media in the photography experience</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Usage Tracking -->
|
||||
{#if media.usedIn && Array.isArray(media.usedIn) && media.usedIn.length > 0}
|
||||
<div class="usage-section">
|
||||
<h4>Used In</h4>
|
||||
<div class="usage-section">
|
||||
<h4>Used In</h4>
|
||||
{#if loadingUsage}
|
||||
<div class="usage-loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading usage information...</span>
|
||||
</div>
|
||||
{:else if usage.length > 0}
|
||||
<ul class="usage-list">
|
||||
{#each media.usedIn as usage}
|
||||
{#each usage as usageItem}
|
||||
<li class="usage-item">
|
||||
<span class="usage-type">{usage.contentType}</span>
|
||||
<span class="usage-field">{usage.fieldName}</span>
|
||||
<div class="usage-content">
|
||||
<div class="usage-header">
|
||||
{#if usageItem.contentUrl}
|
||||
<a href={usageItem.contentUrl} class="usage-title" target="_blank" rel="noopener">
|
||||
{usageItem.contentTitle}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="usage-title">{usageItem.contentTitle}</span>
|
||||
{/if}
|
||||
<span class="usage-type">{usageItem.contentType}</span>
|
||||
</div>
|
||||
<div class="usage-details">
|
||||
<span class="usage-field">{usageItem.fieldDisplayName}</span>
|
||||
<span class="usage-date">Added {new Date(usageItem.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="usage-section">
|
||||
<h4>Usage</h4>
|
||||
{:else}
|
||||
<p class="no-usage">This media file is not currently used in any content.</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -439,6 +511,76 @@
|
|||
}
|
||||
}
|
||||
|
||||
.photography-toggle {
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-3x;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:checked + .toggle-slider {
|
||||
background-color: $blue-60;
|
||||
|
||||
&::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled + .toggle-slider {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background-color: $grey-80;
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
|
||||
.toggle-title {
|
||||
font-weight: 500;
|
||||
color: $grey-10;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.toggle-description {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-50;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.usage-section {
|
||||
.usage-list {
|
||||
list-style: none;
|
||||
|
|
@ -449,23 +591,80 @@
|
|||
gap: $unit;
|
||||
}
|
||||
|
||||
.usage-item {
|
||||
.usage-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
padding: $unit-2x;
|
||||
background: $grey-95;
|
||||
border-radius: 8px;
|
||||
color: $grey-50;
|
||||
|
||||
.usage-type {
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
text-transform: capitalize;
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid $grey-90;
|
||||
border-top: 2px solid $grey-50;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.usage-item {
|
||||
padding: $unit-3x;
|
||||
background: $grey-95;
|
||||
border-radius: 12px;
|
||||
border: 1px solid $grey-90;
|
||||
|
||||
.usage-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.usage-field {
|
||||
color: $grey-40;
|
||||
font-size: 0.875rem;
|
||||
.usage-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $unit-2x;
|
||||
|
||||
.usage-title {
|
||||
font-weight: 600;
|
||||
color: $grey-10;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $blue-60;
|
||||
}
|
||||
}
|
||||
|
||||
.usage-type {
|
||||
background: $grey-85;
|
||||
color: $grey-30;
|
||||
padding: $unit-half $unit;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.usage-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-3x;
|
||||
|
||||
.usage-field {
|
||||
color: $grey-40;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.usage-date {
|
||||
color: $grey-50;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -511,6 +710,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@include breakpoint('phone') {
|
||||
.modal-header {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
let total = $state(0)
|
||||
let searchQuery = $state('')
|
||||
let filterType = $state<string>(fileType === 'all' ? 'all' : fileType)
|
||||
let photographyFilter = $state<string>('all')
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
// Initialize selected media from IDs
|
||||
|
|
@ -60,6 +61,14 @@
|
|||
}
|
||||
})
|
||||
|
||||
// Watch for photography filter changes
|
||||
$effect(() => {
|
||||
if (photographyFilter !== undefined) {
|
||||
currentPage = 1
|
||||
loadMedia()
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
loadMedia()
|
||||
})
|
||||
|
|
@ -76,6 +85,10 @@
|
|||
url += `&mimeType=${filterType}`
|
||||
}
|
||||
|
||||
if (photographyFilter !== 'all') {
|
||||
url += `&isPhotography=${photographyFilter}`
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
url += `&search=${encodeURIComponent(searchQuery)}`
|
||||
}
|
||||
|
|
@ -172,6 +185,12 @@
|
|||
<option value="image">Images</option>
|
||||
<option value="video">Videos</option>
|
||||
</select>
|
||||
|
||||
<select bind:value={photographyFilter} class="filter-select">
|
||||
<option value="all">All Media</option>
|
||||
<option value="true">Photography</option>
|
||||
<option value="false">Non-Photography</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if showSelectAll}
|
||||
|
|
@ -258,6 +277,25 @@
|
|||
<div class="media-filename" title={item.filename}>
|
||||
{item.filename}
|
||||
</div>
|
||||
<div class="media-indicators">
|
||||
{#if item.isPhotography}
|
||||
<span class="indicator-pill photography" title="Photography">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26" fill="currentColor"/>
|
||||
</svg>
|
||||
Photo
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.altText}
|
||||
<span class="indicator-pill alt-text" title="Alt text: {item.altText}">
|
||||
Alt
|
||||
</span>
|
||||
{:else}
|
||||
<span class="indicator-pill no-alt-text" title="No alt text">
|
||||
No Alt
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="media-meta">
|
||||
<span class="file-size">{formatFileSize(item.size)}</span>
|
||||
{#if item.width && item.height}
|
||||
|
|
@ -478,6 +516,13 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.media-indicators {
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: $unit-half;
|
||||
}
|
||||
|
||||
.media-meta {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
|
|
@ -485,6 +530,48 @@
|
|||
color: $grey-40;
|
||||
}
|
||||
|
||||
// Indicator pill styles
|
||||
.indicator-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
padding: 2px $unit;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1;
|
||||
|
||||
svg {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.photography {
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
color: #7c3aed;
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
|
||||
svg {
|
||||
fill: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
&.alt-text {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
&.no-alt-text {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -6,30 +6,23 @@
|
|||
let isOpen = $state(false)
|
||||
let buttonRef: HTMLElement
|
||||
let showComposer = $state(false)
|
||||
let selectedType = $state<'post' | 'essay' | 'album'>('post')
|
||||
let selectedType = $state<'post' | 'essay'>('post')
|
||||
|
||||
const postTypes = [
|
||||
{ value: 'blog', label: 'Essay' },
|
||||
{ value: 'microblog', label: 'Post' },
|
||||
{ value: 'link', label: 'Link' },
|
||||
{ value: 'photo', label: 'Photo' },
|
||||
{ value: 'album', label: 'Album' }
|
||||
{ value: 'essay', label: 'Essay' },
|
||||
{ value: 'post', label: 'Post' }
|
||||
]
|
||||
|
||||
function handleSelection(type: string) {
|
||||
isOpen = false
|
||||
|
||||
if (type === 'blog') {
|
||||
if (type === 'essay') {
|
||||
// Essays go straight to the full page
|
||||
goto('/admin/universe/compose?type=essay')
|
||||
} else if (type === 'microblog' || type === 'link') {
|
||||
// Posts and links open in modal
|
||||
} else if (type === 'post') {
|
||||
// Posts open in modal
|
||||
selectedType = 'post'
|
||||
showComposer = true
|
||||
} else if (type === 'photo' || type === 'album') {
|
||||
// Photos and albums will be handled later
|
||||
selectedType = 'album'
|
||||
showComposer = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +86,7 @@
|
|||
>
|
||||
{#snippet icon()}
|
||||
<div class="dropdown-icon">
|
||||
{#if type.value === 'blog'}
|
||||
{#if type.value === 'essay'}
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M3 5C3 3.89543 3.89543 3 5 3H11L17 9V15C17 16.1046 16.1046 17 15 17H5C3.89543 17 3 16.1046 3 15V5Z"
|
||||
|
|
@ -114,7 +107,7 @@
|
|||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else if type.value === 'microblog'}
|
||||
{:else if type.value === 'post'}
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M4 3C2.89543 3 2 3.89543 2 5V11C2 12.1046 2.89543 13 4 13H6L8 16V13H13C14.1046 13 15 12.1046 15 11V5C15 3.89543 14.1046 3 13 3H4Z"
|
||||
|
|
@ -124,66 +117,6 @@
|
|||
<path d="M5 7H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
<path d="M5 9H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else if type.value === 'link'}
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M10 5H7C4.79086 5 3 6.79086 3 9C3 11.2091 4.79086 13 7 13H10M10 7H13C15.2091 7 17 8.79086 17 11C17 13.2091 15.2091 15 13 15H10"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M7 10H13"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else if type.value === 'photo'}
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="14"
|
||||
height="14"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="8" cy="8" r="1.5" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M3 14L7 10L10 13L13 10L17 14"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else if type.value === 'album'}
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect
|
||||
x="3"
|
||||
y="5"
|
||||
width="14"
|
||||
height="12"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M5 5V3C5 1.89543 5.89543 1 7 1H13C14.1046 1 15 1.89543 15 3V5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<circle cx="8" cy="10" r="1.5" stroke="currentColor" stroke-width="1.5" />
|
||||
<path
|
||||
d="M3 14L7 11L10 13L13 11L17 14"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
|
|
|||
|
|
@ -58,14 +58,15 @@
|
|||
year: data.year || new Date().getFullYear(),
|
||||
client: data.client || '',
|
||||
role: data.role || '',
|
||||
technologies: Array.isArray(data.technologies) ? data.technologies.join(', ') : '',
|
||||
projectType: data.projectType || 'work',
|
||||
externalUrl: data.externalUrl || '',
|
||||
featuredImage: data.featuredImage || null,
|
||||
backgroundColor: data.backgroundColor || '',
|
||||
highlightColor: data.highlightColor || '',
|
||||
logoUrl: data.logoUrl || '',
|
||||
gallery: data.gallery || null,
|
||||
status: (data.status as 'draft' | 'published') || 'draft',
|
||||
status: data.status || 'draft',
|
||||
password: data.password || '',
|
||||
caseStudyContent: data.caseStudyContent || {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }]
|
||||
|
|
@ -84,7 +85,8 @@
|
|||
externalUrl: formData.externalUrl || undefined,
|
||||
backgroundColor: formData.backgroundColor || undefined,
|
||||
highlightColor: formData.highlightColor || undefined,
|
||||
status: formData.status
|
||||
status: formData.status,
|
||||
password: formData.password || undefined
|
||||
})
|
||||
validationErrors = {}
|
||||
return true
|
||||
|
|
@ -132,6 +134,7 @@
|
|||
return
|
||||
}
|
||||
|
||||
|
||||
const payload = {
|
||||
title: formData.title,
|
||||
subtitle: formData.subtitle,
|
||||
|
|
@ -139,10 +142,7 @@
|
|||
year: formData.year,
|
||||
client: formData.client,
|
||||
role: formData.role,
|
||||
technologies: formData.technologies
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
projectType: formData.projectType,
|
||||
externalUrl: formData.externalUrl,
|
||||
featuredImage: formData.featuredImage,
|
||||
logoUrl: formData.logoUrl,
|
||||
|
|
@ -150,6 +150,7 @@
|
|||
backgroundColor: formData.backgroundColor,
|
||||
highlightColor: formData.highlightColor,
|
||||
status: formData.status,
|
||||
password: formData.status === 'password-protected' ? formData.password : null,
|
||||
caseStudyContent:
|
||||
formData.caseStudyContent &&
|
||||
formData.caseStudyContent.content &&
|
||||
|
|
@ -191,14 +192,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
formData.status = 'published'
|
||||
await handleSave()
|
||||
showPublishMenu = false
|
||||
}
|
||||
|
||||
async function handleUnpublish() {
|
||||
formData.status = 'draft'
|
||||
async function handleStatusChange(newStatus: string) {
|
||||
formData.status = newStatus as any
|
||||
await handleSave()
|
||||
showPublishMenu = false
|
||||
}
|
||||
|
|
@ -241,7 +236,11 @@
|
|||
{#if !isLoading}
|
||||
<div class="save-actions">
|
||||
<Button variant="primary" onclick={handleSave} disabled={isSaving} class="save-button">
|
||||
{isSaving ? 'Saving...' : formData.status === 'published' ? 'Save' : 'Save Draft'}
|
||||
{isSaving ? 'Saving...' :
|
||||
formData.status === 'published' ? 'Save' :
|
||||
formData.status === 'list-only' ? 'Save List-Only' :
|
||||
formData.status === 'password-protected' ? 'Save Protected' :
|
||||
'Save Draft'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -271,15 +270,26 @@
|
|||
</Button>
|
||||
{#if showPublishMenu}
|
||||
<div class="publish-menu">
|
||||
{#if formData.status === 'published'}
|
||||
<Button variant="ghost" onclick={handleUnpublish} class="menu-item" fullWidth>
|
||||
Unpublish
|
||||
{#if formData.status !== 'draft'}
|
||||
<Button variant="ghost" onclick={() => handleStatusChange('draft')} class="menu-item" fullWidth>
|
||||
Save as Draft
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="ghost" onclick={handlePublish} class="menu-item" fullWidth>
|
||||
{/if}
|
||||
{#if formData.status !== 'published'}
|
||||
<Button variant="ghost" onclick={() => handleStatusChange('published')} class="menu-item" fullWidth>
|
||||
Publish
|
||||
</Button>
|
||||
{/if}
|
||||
{#if formData.status !== 'list-only'}
|
||||
<Button variant="ghost" onclick={() => handleStatusChange('list-only')} class="menu-item" fullWidth>
|
||||
List Only
|
||||
</Button>
|
||||
{/if}
|
||||
{#if formData.status !== 'password-protected'}
|
||||
<Button variant="ghost" onclick={() => handleStatusChange('password-protected')} class="menu-item" fullWidth>
|
||||
Password Protected
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import Input from './Input.svelte'
|
||||
import Select from './Select.svelte'
|
||||
import ImageUploader from './ImageUploader.svelte'
|
||||
import type { ProjectFormData } from '$lib/types/project'
|
||||
|
||||
|
|
@ -33,6 +34,17 @@
|
|||
placeholder="Short description for project cards"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Project Type"
|
||||
bind:value={formData.projectType}
|
||||
error={validationErrors.projectType}
|
||||
options={[
|
||||
{ value: 'work', label: 'Work' },
|
||||
{ value: 'labs', label: 'Labs' }
|
||||
]}
|
||||
helpText="Choose whether this project appears in the Work tab or Labs tab"
|
||||
/>
|
||||
|
||||
<div class="form-row">
|
||||
<Input
|
||||
type="number"
|
||||
|
|
@ -67,6 +79,31 @@
|
|||
placeholder="Upload a featured image for this project"
|
||||
showBrowseLibrary={true}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Project Status"
|
||||
bind:value={formData.status}
|
||||
error={validationErrors.status}
|
||||
options={[
|
||||
{ value: 'draft', label: 'Draft (Hidden)' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'list-only', label: 'List Only (No Access)' },
|
||||
{ value: 'password-protected', label: 'Password Protected' }
|
||||
]}
|
||||
helpText="Control how this project appears on the public site"
|
||||
/>
|
||||
|
||||
{#if formData.status === 'password-protected'}
|
||||
<Input
|
||||
type="password"
|
||||
label="Project Password"
|
||||
required
|
||||
error={validationErrors.password}
|
||||
bind:value={formData.password}
|
||||
placeholder="Enter a password for this project"
|
||||
helpText="Users will need this password to access the project details"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
|||
162
src/lib/components/admin/Select.svelte
Normal file
162
src/lib/components/admin/Select.svelte
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<script lang="ts">
|
||||
interface Option {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
label?: string
|
||||
value?: string
|
||||
options: Option[]
|
||||
error?: string
|
||||
helpText?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
value = $bindable(''),
|
||||
options,
|
||||
error,
|
||||
helpText,
|
||||
required = false,
|
||||
disabled = false,
|
||||
placeholder = 'Select an option',
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="select-wrapper {className}">
|
||||
{#if label}
|
||||
<label class="select-label">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="required">*</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<div class="select-container" class:error>
|
||||
<select
|
||||
bind:value
|
||||
{disabled}
|
||||
class="select-input"
|
||||
class:error
|
||||
>
|
||||
{#if placeholder}
|
||||
<option value="" disabled hidden>{placeholder}</option>
|
||||
{/if}
|
||||
{#each options as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="select-arrow">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if helpText && !error}
|
||||
<div class="help-text">{helpText}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.select-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
margin: 0;
|
||||
|
||||
.required {
|
||||
color: $red-50;
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.select-container {
|
||||
position: relative;
|
||||
|
||||
&.error {
|
||||
.select-input {
|
||||
border-color: $red-50;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: 100%;
|
||||
padding: $unit $unit-2x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $corner-radius;
|
||||
background: $grey-100;
|
||||
color: $grey-10;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $grey-70;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $blue-50;
|
||||
box-shadow: 0 0 0 3px rgba(20, 130, 193, 0.1);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: $grey-95;
|
||||
color: $grey-60;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: $red-50;
|
||||
}
|
||||
}
|
||||
|
||||
.select-arrow {
|
||||
position: absolute;
|
||||
right: $unit-2x;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: $grey-40;
|
||||
pointer-events: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.select-container:hover .select-arrow {
|
||||
color: $grey-30;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 0.75rem;
|
||||
color: $red-50;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-40;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
import Input from './Input.svelte'
|
||||
|
||||
interface Props {
|
||||
postType: 'microblog' | 'link'
|
||||
postType: 'post'
|
||||
postId?: number
|
||||
initialData?: {
|
||||
title?: string
|
||||
|
|
@ -45,21 +45,20 @@
|
|||
|
||||
// Check if form has content
|
||||
const hasContent = $derived(() => {
|
||||
if (postType === 'microblog') {
|
||||
return textContent().trim().length > 0
|
||||
} else if (postType === 'link') {
|
||||
return linkUrl && linkUrl.trim().length > 0
|
||||
}
|
||||
return false
|
||||
// For posts, check if either content exists or it's a link with URL
|
||||
const hasTextContent = textContent().trim().length > 0
|
||||
const hasLinkContent = linkUrl && linkUrl.trim().length > 0
|
||||
return hasTextContent || hasLinkContent
|
||||
})
|
||||
|
||||
async function handleSave(publishStatus: 'draft' | 'published') {
|
||||
if (postType === 'microblog' && isOverLimit) {
|
||||
if (isOverLimit) {
|
||||
error = 'Post is too long'
|
||||
return
|
||||
}
|
||||
|
||||
if (postType === 'link' && !linkUrl) {
|
||||
// For link posts, URL is required
|
||||
if (linkUrl && !linkUrl.trim()) {
|
||||
error = 'Link URL is required'
|
||||
return
|
||||
}
|
||||
|
|
@ -75,15 +74,15 @@
|
|||
}
|
||||
|
||||
const payload: any = {
|
||||
postType,
|
||||
status: publishStatus
|
||||
type: 'post', // Use simplified post type
|
||||
status: publishStatus,
|
||||
content: content
|
||||
}
|
||||
|
||||
if (postType === 'microblog') {
|
||||
payload.content = content
|
||||
} else if (postType === 'link') {
|
||||
// Add link fields if they're provided
|
||||
if (linkUrl && linkUrl.trim()) {
|
||||
payload.title = title || linkUrl
|
||||
payload.linkUrl = linkUrl
|
||||
payload.link_url = linkUrl
|
||||
payload.linkDescription = linkDescription
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@
|
|||
|
||||
export let isOpen = false
|
||||
export let initialMode: 'modal' | 'page' = 'modal'
|
||||
export let initialPostType: 'post' | 'essay' | 'album' = 'post'
|
||||
export let initialPostType: 'post' | 'essay' = 'post'
|
||||
export let initialContent: JSONContent | undefined = undefined
|
||||
|
||||
type PostType = 'post' | 'essay' | 'album'
|
||||
type PostType = 'post' | 'essay'
|
||||
type ComposerMode = 'modal' | 'page'
|
||||
|
||||
let postType: PostType = initialPostType
|
||||
|
|
@ -212,24 +212,24 @@
|
|||
if (postType === 'essay') {
|
||||
postData = {
|
||||
...postData,
|
||||
type: 'blog',
|
||||
type: 'essay',
|
||||
title: essayTitle,
|
||||
slug: essaySlug,
|
||||
excerpt: essayExcerpt,
|
||||
tags: essayTags ? essayTags.split(',').map((tag) => tag.trim()) : []
|
||||
}
|
||||
} else if (showLinkFields) {
|
||||
postData = {
|
||||
...postData,
|
||||
type: 'link',
|
||||
linkUrl,
|
||||
linkTitle,
|
||||
linkDescription
|
||||
}
|
||||
} else {
|
||||
// All other content is just a "post" with optional link data and attachments
|
||||
postData = {
|
||||
...postData,
|
||||
type: attachedPhotos.length > 0 ? 'photo' : 'microblog'
|
||||
type: 'post'
|
||||
}
|
||||
|
||||
// Add link fields if present
|
||||
if (showLinkFields) {
|
||||
postData.link_url = linkUrl
|
||||
postData.linkTitle = linkTitle
|
||||
postData.linkDescription = linkDescription
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,19 @@ export const projectSchema = z.object({
|
|||
.regex(/^#[0-9A-Fa-f]{6}$/)
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
status: z.enum(['draft', 'published'])
|
||||
})
|
||||
status: z.enum(['draft', 'published', 'list-only', 'password-protected']),
|
||||
password: z.string().optional()
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.status === 'password-protected') {
|
||||
return data.password && data.password.trim().length > 0
|
||||
}
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: 'Password is required when status is password-protected',
|
||||
path: ['password']
|
||||
}
|
||||
)
|
||||
|
||||
export type ProjectSchema = z.infer<typeof projectSchema>
|
||||
|
|
|
|||
262
src/lib/server/media-usage.ts
Normal file
262
src/lib/server/media-usage.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import { prisma } from './database.js'
|
||||
|
||||
export interface MediaUsageReference {
|
||||
mediaId: number
|
||||
contentType: 'project' | 'post' | 'album'
|
||||
contentId: number
|
||||
fieldName: string
|
||||
}
|
||||
|
||||
export interface MediaUsageDisplay {
|
||||
contentType: string
|
||||
contentId: number
|
||||
contentTitle: string
|
||||
fieldName: string
|
||||
fieldDisplayName: string
|
||||
contentUrl?: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Track media usage for a piece of content
|
||||
*/
|
||||
export async function trackMediaUsage(references: MediaUsageReference[]) {
|
||||
if (references.length === 0) return
|
||||
|
||||
// Use upsert to handle duplicates gracefully
|
||||
const operations = references.map(ref =>
|
||||
prisma.mediaUsage.upsert({
|
||||
where: {
|
||||
mediaId_contentType_contentId_fieldName: {
|
||||
mediaId: ref.mediaId,
|
||||
contentType: ref.contentType,
|
||||
contentId: ref.contentId,
|
||||
fieldName: ref.fieldName
|
||||
}
|
||||
},
|
||||
update: {
|
||||
updatedAt: new Date()
|
||||
},
|
||||
create: {
|
||||
mediaId: ref.mediaId,
|
||||
contentType: ref.contentType,
|
||||
contentId: ref.contentId,
|
||||
fieldName: ref.fieldName
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
await prisma.$transaction(operations)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove media usage tracking for a piece of content
|
||||
*/
|
||||
export async function removeMediaUsage(contentType: string, contentId: number, fieldName?: string) {
|
||||
await prisma.mediaUsage.deleteMany({
|
||||
where: {
|
||||
contentType,
|
||||
contentId,
|
||||
...(fieldName && { fieldName })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update media usage for a piece of content (removes old, adds new)
|
||||
*/
|
||||
export async function updateMediaUsage(
|
||||
contentType: 'project' | 'post' | 'album',
|
||||
contentId: number,
|
||||
fieldName: string,
|
||||
mediaIds: number[]
|
||||
) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Remove existing usage for this field
|
||||
await tx.mediaUsage.deleteMany({
|
||||
where: {
|
||||
contentType,
|
||||
contentId,
|
||||
fieldName
|
||||
}
|
||||
})
|
||||
|
||||
// Add new usage references
|
||||
if (mediaIds.length > 0) {
|
||||
await tx.mediaUsage.createMany({
|
||||
data: mediaIds.map(mediaId => ({
|
||||
mediaId,
|
||||
contentType,
|
||||
contentId,
|
||||
fieldName
|
||||
}))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get usage information for a specific media item
|
||||
*/
|
||||
export async function getMediaUsage(mediaId: number): Promise<MediaUsageDisplay[]> {
|
||||
const usage = await prisma.mediaUsage.findMany({
|
||||
where: { mediaId },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
const results: MediaUsageDisplay[] = []
|
||||
|
||||
for (const record of usage) {
|
||||
let contentTitle = 'Unknown'
|
||||
let contentUrl = undefined
|
||||
|
||||
// Fetch content details based on type
|
||||
try {
|
||||
switch (record.contentType) {
|
||||
case 'project': {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: record.contentId },
|
||||
select: { title: true, slug: true }
|
||||
})
|
||||
if (project) {
|
||||
contentTitle = project.title
|
||||
contentUrl = `/work/${project.slug}`
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'post': {
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { id: record.contentId },
|
||||
select: { title: true, slug: true, postType: true }
|
||||
})
|
||||
if (post) {
|
||||
contentTitle = post.title || `${post.postType} post`
|
||||
contentUrl = `/universe/${post.slug}`
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'album': {
|
||||
const album = await prisma.album.findUnique({
|
||||
where: { id: record.contentId },
|
||||
select: { title: true, slug: true }
|
||||
})
|
||||
if (album) {
|
||||
contentTitle = album.title
|
||||
contentUrl = `/photos/${album.slug}`
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${record.contentType} ${record.contentId}:`, error)
|
||||
}
|
||||
|
||||
results.push({
|
||||
contentType: record.contentType,
|
||||
contentId: record.contentId,
|
||||
contentTitle,
|
||||
fieldName: record.fieldName,
|
||||
fieldDisplayName: getFieldDisplayName(record.fieldName),
|
||||
contentUrl,
|
||||
createdAt: record.createdAt
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get friendly field names for display
|
||||
*/
|
||||
function getFieldDisplayName(fieldName: string): string {
|
||||
const displayNames: Record<string, string> = {
|
||||
'featuredImage': 'Featured Image',
|
||||
'logoUrl': 'Logo',
|
||||
'gallery': 'Gallery',
|
||||
'content': 'Content',
|
||||
'coverPhotoId': 'Cover Photo',
|
||||
'photoId': 'Photo',
|
||||
'attachments': 'Attachments'
|
||||
}
|
||||
|
||||
return displayNames[fieldName] || fieldName
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract media IDs from various data structures
|
||||
*/
|
||||
export function extractMediaIds(data: any, fieldName: string): number[] {
|
||||
const value = data[fieldName]
|
||||
if (!value) return []
|
||||
|
||||
switch (fieldName) {
|
||||
case 'gallery':
|
||||
case 'attachments':
|
||||
// Gallery/attachments are arrays of media objects with id property
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => typeof item === 'object' ? item.id : parseInt(item))
|
||||
.filter(id => !isNaN(id))
|
||||
}
|
||||
return []
|
||||
|
||||
case 'featuredImage':
|
||||
case 'logoUrl':
|
||||
// Single media URL - extract ID from URL or assume it's an ID
|
||||
if (typeof value === 'string') {
|
||||
// Try to extract ID from URL pattern (e.g., /api/media/123/...)
|
||||
const match = value.match(/\/api\/media\/(\d+)/)
|
||||
return match ? [parseInt(match[1])] : []
|
||||
} else if (typeof value === 'number') {
|
||||
return [value]
|
||||
}
|
||||
return []
|
||||
|
||||
case 'content':
|
||||
// Extract from rich text content (Edra editor)
|
||||
return extractMediaFromRichText(value)
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract media IDs from rich text content (TipTap/Edra JSON)
|
||||
*/
|
||||
function extractMediaFromRichText(content: any): number[] {
|
||||
if (!content || typeof content !== 'object') return []
|
||||
|
||||
const mediaIds: number[] = []
|
||||
|
||||
function traverse(node: any) {
|
||||
if (!node) return
|
||||
|
||||
// Handle image nodes
|
||||
if (node.type === 'image' && node.attrs?.src) {
|
||||
const match = node.attrs.src.match(/\/api\/media\/(\d+)/)
|
||||
if (match) {
|
||||
mediaIds.push(parseInt(match[1]))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle gallery nodes
|
||||
if (node.type === 'gallery' && node.attrs?.images) {
|
||||
for (const image of node.attrs.images) {
|
||||
if (image.id) {
|
||||
mediaIds.push(image.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively traverse child nodes
|
||||
if (node.content) {
|
||||
for (const child of node.content) {
|
||||
traverse(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(content)
|
||||
return [...new Set(mediaIds)] // Remove duplicates
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ export interface Photo {
|
|||
|
||||
export interface PhotoAlbum {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
description?: string
|
||||
coverPhoto: Photo
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
export type ProjectStatus = 'draft' | 'published' | 'list-only' | 'password-protected'
|
||||
export type ProjectType = 'work' | 'labs'
|
||||
|
||||
export interface Project {
|
||||
id: number
|
||||
slug: string
|
||||
|
|
@ -7,7 +10,6 @@ export interface Project {
|
|||
year: number
|
||||
client: string | null
|
||||
role: string | null
|
||||
technologies: string[] | null
|
||||
featuredImage: string | null
|
||||
logoUrl: string | null
|
||||
gallery: any[] | null
|
||||
|
|
@ -15,8 +17,10 @@ export interface Project {
|
|||
caseStudyContent: any | null
|
||||
backgroundColor: string | null
|
||||
highlightColor: string | null
|
||||
projectType: ProjectType
|
||||
displayOrder: number
|
||||
status: string
|
||||
status: ProjectStatus
|
||||
password: string | null
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
publishedAt?: string | null
|
||||
|
|
@ -29,14 +33,15 @@ export interface ProjectFormData {
|
|||
year: number
|
||||
client: string
|
||||
role: string
|
||||
technologies: string
|
||||
projectType: ProjectType
|
||||
externalUrl: string
|
||||
featuredImage: string | null
|
||||
backgroundColor: string
|
||||
highlightColor: string
|
||||
logoUrl: string
|
||||
gallery: any[] | null
|
||||
status: 'draft' | 'published'
|
||||
status: ProjectStatus
|
||||
password: string
|
||||
caseStudyContent: any
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +52,7 @@ export const defaultProjectFormData: ProjectFormData = {
|
|||
year: new Date().getFullYear(),
|
||||
client: '',
|
||||
role: '',
|
||||
technologies: '',
|
||||
projectType: 'work',
|
||||
externalUrl: '',
|
||||
featuredImage: null,
|
||||
backgroundColor: '',
|
||||
|
|
@ -55,6 +60,7 @@ export const defaultProjectFormData: ProjectFormData = {
|
|||
logoUrl: '',
|
||||
gallery: null,
|
||||
status: 'draft',
|
||||
password: '',
|
||||
caseStudyContent: {
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph' }]
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ async function fetchRecentPSNGames(fetch: typeof window.fetch): Promise<Serializ
|
|||
async function fetchProjects(
|
||||
fetch: typeof window.fetch
|
||||
): Promise<{ projects: Project[]; pagination: any }> {
|
||||
const response = await fetch('/api/projects?status=published')
|
||||
const response = await fetch('/api/projects?projectType=work&includeListOnly=true&includePasswordProtected=true')
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch projects: ${response.status}`)
|
||||
}
|
||||
|
|
|
|||
271
src/routes/admin/albums/+page.svelte
Normal file
271
src/routes/admin/albums/+page.svelte
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import DataTable from '$lib/components/admin/DataTable.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||
|
||||
// State
|
||||
let albums = $state<any[]>([])
|
||||
let isLoading = $state(true)
|
||||
let error = $state('')
|
||||
let total = $state(0)
|
||||
let albumTypeCounts = $state<Record<string, number>>({})
|
||||
|
||||
// Filter state
|
||||
let photographyFilter = $state<string>('all')
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
width: '40%',
|
||||
render: (album: any) => {
|
||||
return album.title || '(Untitled Album)'
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
width: '20%',
|
||||
render: (album: any) => {
|
||||
const baseType = '🖼️ Album'
|
||||
if (album.isPhotography) {
|
||||
return `${baseType} 📸`
|
||||
}
|
||||
return baseType
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'photoCount',
|
||||
label: 'Photos',
|
||||
width: '15%',
|
||||
render: (album: any) => {
|
||||
return album._count?.photos || 0
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
width: '15%',
|
||||
render: (album: any) => {
|
||||
return album.status === 'published' ? '🟢 Published' : '⚪ Draft'
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'updatedAt',
|
||||
label: 'Updated',
|
||||
width: '10%',
|
||||
render: (album: any) => {
|
||||
return new Date(album.updatedAt).toLocaleDateString()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
onMount(async () => {
|
||||
await loadAlbums()
|
||||
})
|
||||
|
||||
async function loadAlbums() {
|
||||
try {
|
||||
isLoading = true
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
let url = '/api/albums'
|
||||
if (photographyFilter !== 'all') {
|
||||
url += `?isPhotography=${photographyFilter}`
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
throw new Error('Failed to load albums')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
albums = data.albums || []
|
||||
total = data.pagination?.total || albums.length
|
||||
|
||||
// Calculate album type counts
|
||||
const counts: Record<string, number> = {
|
||||
all: albums.length,
|
||||
photography: albums.filter(a => a.isPhotography).length,
|
||||
regular: albums.filter(a => !a.isPhotography).length
|
||||
}
|
||||
albumTypeCounts = counts
|
||||
|
||||
} catch (err) {
|
||||
error = 'Failed to load albums'
|
||||
console.error(err)
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleRowClick(album: any) {
|
||||
goto(`/admin/albums/${album.id}/edit`)
|
||||
}
|
||||
|
||||
function handleFilterChange() {
|
||||
loadAlbums()
|
||||
}
|
||||
|
||||
function handleNewAlbum() {
|
||||
goto('/admin/albums/new')
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<h1>Albums</h1>
|
||||
<div class="header-actions">
|
||||
<Button variant="primary" onclick={handleNewAlbum}>
|
||||
New Album
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{:else}
|
||||
<!-- Albums Stats -->
|
||||
<div class="albums-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{albumTypeCounts.all || 0}</span>
|
||||
<span class="stat-label">Total albums</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{albumTypeCounts.photography || 0}</span>
|
||||
<span class="stat-label">Photography albums</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{albumTypeCounts.regular || 0}</span>
|
||||
<span class="stat-label">Regular albums</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<select bind:value={photographyFilter} onchange={handleFilterChange} class="filter-select">
|
||||
<option value="all">All albums</option>
|
||||
<option value="true">Photography albums</option>
|
||||
<option value="false">Regular albums</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Albums Table -->
|
||||
{#if isLoading}
|
||||
<div class="loading-container">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else}
|
||||
<DataTable
|
||||
data={albums}
|
||||
{columns}
|
||||
loading={isLoading}
|
||||
emptyMessage="No albums found. Create your first album!"
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
padding: $unit-3x;
|
||||
border-radius: $unit-2x;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
margin-bottom: $unit-4x;
|
||||
}
|
||||
|
||||
.albums-stats {
|
||||
display: flex;
|
||||
gap: $unit-4x;
|
||||
margin-bottom: $unit-4x;
|
||||
padding: $unit-4x;
|
||||
background: $grey-95;
|
||||
border-radius: $unit-2x;
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
align-items: center;
|
||||
margin-bottom: $unit-4x;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: $unit $unit-3x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 50px;
|
||||
background: white;
|
||||
font-size: 0.925rem;
|
||||
color: $grey-20;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-40;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
1206
src/routes/admin/albums/[id]/edit/+page.svelte
Normal file
1206
src/routes/admin/albums/[id]/edit/+page.svelte
Normal file
File diff suppressed because it is too large
Load diff
381
src/routes/admin/albums/new/+page.svelte
Normal file
381
src/routes/admin/albums/new/+page.svelte
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import Input from '$lib/components/admin/Input.svelte'
|
||||
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
||||
|
||||
// Form state
|
||||
let title = $state('')
|
||||
let slug = $state('')
|
||||
let description = $state('')
|
||||
let date = $state('')
|
||||
let location = $state('')
|
||||
let isPhotography = $state(false)
|
||||
let showInUniverse = $state(false)
|
||||
let status = $state<'draft' | 'published'>('draft')
|
||||
|
||||
// UI state
|
||||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
|
||||
// Auto-generate slug from title
|
||||
$effect(() => {
|
||||
if (title && !slug) {
|
||||
slug = generateSlug(title)
|
||||
}
|
||||
})
|
||||
|
||||
function generateSlug(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
async function handleSave(publishStatus: 'draft' | 'published') {
|
||||
if (!title.trim()) {
|
||||
error = 'Title is required'
|
||||
return
|
||||
}
|
||||
|
||||
if (!slug.trim()) {
|
||||
error = 'Slug is required'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSaving = true
|
||||
error = ''
|
||||
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
const albumData = {
|
||||
title: title.trim(),
|
||||
slug: slug.trim(),
|
||||
description: description.trim() || null,
|
||||
date: date ? new Date(date).toISOString() : null,
|
||||
location: location.trim() || null,
|
||||
isPhotography,
|
||||
showInUniverse,
|
||||
status: publishStatus
|
||||
}
|
||||
|
||||
const response = await fetch('/api/albums', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(albumData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.message || 'Failed to create album')
|
||||
}
|
||||
|
||||
const album = await response.json()
|
||||
|
||||
// Redirect to album edit page or albums list
|
||||
goto(`/admin/albums/${album.id}/edit`)
|
||||
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to create album'
|
||||
console.error('Failed to create album:', err)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
goto('/admin/albums')
|
||||
}
|
||||
|
||||
const canSave = $derived(title.trim().length > 0 && slug.trim().length > 0)
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<div class="header-left">
|
||||
<button class="btn-icon" onclick={handleCancel}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M12.5 15L7.5 10L12.5 5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1>New Album</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="ghost" onclick={() => handleSave('draft')} disabled={!canSave || isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Draft'}
|
||||
</Button>
|
||||
<Button variant="primary" onclick={() => handleSave('published')} disabled={!canSave || isSaving}>
|
||||
{isSaving ? 'Publishing...' : 'Publish'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="album-form">
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Album Details</h2>
|
||||
|
||||
<Input
|
||||
label="Title"
|
||||
bind:value={title}
|
||||
placeholder="Enter album title"
|
||||
required
|
||||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Slug"
|
||||
bind:value={slug}
|
||||
placeholder="album-url-slug"
|
||||
helpText="Used in the album URL. Auto-generated from title."
|
||||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="textarea"
|
||||
label="Description"
|
||||
bind:value={description}
|
||||
placeholder="Describe this album..."
|
||||
rows={3}
|
||||
disabled={isSaving}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<div class="form-row">
|
||||
<Input
|
||||
type="date"
|
||||
label="Date"
|
||||
bind:value={date}
|
||||
helpText="When was this album created or photos taken?"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Location"
|
||||
bind:value={location}
|
||||
placeholder="Location where photos were taken"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Album Settings</h2>
|
||||
|
||||
<!-- Photography Toggle -->
|
||||
<FormFieldWrapper label="Album Type">
|
||||
<div class="photography-toggle">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isPhotography}
|
||||
disabled={isSaving}
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
<div class="toggle-content">
|
||||
<span class="toggle-title">Photography Album</span>
|
||||
<span class="toggle-description">Show this album in the photography experience</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
|
||||
<!-- Show in Universe Toggle -->
|
||||
<FormFieldWrapper label="Visibility">
|
||||
<div class="universe-toggle">
|
||||
<label class="toggle-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={showInUniverse}
|
||||
disabled={isSaving}
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-slider"></span>
|
||||
<div class="toggle-content">
|
||||
<span class="toggle-title">Show in Universe</span>
|
||||
<span class="toggle-description">Display this album in the Universe feed</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</FormFieldWrapper>
|
||||
</div>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-90;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.album-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-6x;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
padding: $unit-3x;
|
||||
border-radius: $unit-2x;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
border-bottom: 1px solid $grey-85;
|
||||
padding-bottom: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $unit-3x;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.photography-toggle,
|
||||
.universe-toggle {
|
||||
.toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-3x;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
&:checked + .toggle-slider {
|
||||
background-color: $blue-60;
|
||||
|
||||
&::before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled + .toggle-slider {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background-color: $grey-80;
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s ease;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
|
||||
.toggle-title {
|
||||
font-weight: 500;
|
||||
color: $grey-10;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.toggle-description {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-50;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
// Filter states
|
||||
let filterType = $state<string>('all')
|
||||
let photographyFilter = $state<string>('all')
|
||||
let searchQuery = $state('')
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
|
|
@ -22,6 +23,11 @@
|
|||
let selectedMedia = $state<Media | null>(null)
|
||||
let isDetailsModalOpen = $state(false)
|
||||
|
||||
// Multiselect states
|
||||
let selectedMediaIds = $state<Set<number>>(new Set())
|
||||
let isMultiSelectMode = $state(false)
|
||||
let isDeleting = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
await loadMedia()
|
||||
})
|
||||
|
|
@ -46,6 +52,9 @@
|
|||
if (filterType !== 'all') {
|
||||
url += `&mimeType=${filterType}`
|
||||
}
|
||||
if (photographyFilter !== 'all') {
|
||||
url += `&isPhotography=${photographyFilter}`
|
||||
}
|
||||
if (searchQuery) {
|
||||
url += `&search=${encodeURIComponent(searchQuery)}`
|
||||
}
|
||||
|
|
@ -116,12 +125,185 @@
|
|||
media[index] = updatedMedia
|
||||
}
|
||||
}
|
||||
|
||||
// Multiselect functions
|
||||
function toggleMultiSelectMode() {
|
||||
isMultiSelectMode = !isMultiSelectMode
|
||||
if (!isMultiSelectMode) {
|
||||
selectedMediaIds.clear()
|
||||
selectedMediaIds = new Set()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMediaSelection(mediaId: number) {
|
||||
if (selectedMediaIds.has(mediaId)) {
|
||||
selectedMediaIds.delete(mediaId)
|
||||
} else {
|
||||
selectedMediaIds.add(mediaId)
|
||||
}
|
||||
selectedMediaIds = new Set(selectedMediaIds) // Trigger reactivity
|
||||
}
|
||||
|
||||
function selectAllMedia() {
|
||||
selectedMediaIds = new Set(media.map(m => m.id))
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedMediaIds.clear()
|
||||
selectedMediaIds = new Set()
|
||||
}
|
||||
|
||||
async function handleBulkDelete() {
|
||||
if (selectedMediaIds.size === 0) return
|
||||
|
||||
const confirmation = confirm(
|
||||
`Are you sure you want to delete ${selectedMediaIds.size} media file${selectedMediaIds.size > 1 ? 's' : ''}? This action cannot be undone and will remove these files from any content that references them.`
|
||||
)
|
||||
|
||||
if (!confirmation) return
|
||||
|
||||
try {
|
||||
isDeleting = true
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) return
|
||||
|
||||
const response = await fetch('/api/media/bulk-delete', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
mediaIds: Array.from(selectedMediaIds)
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete media files')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Remove deleted media from the list
|
||||
media = media.filter(m => !selectedMediaIds.has(m.id))
|
||||
|
||||
// Clear selection and exit multiselect mode
|
||||
selectedMediaIds.clear()
|
||||
selectedMediaIds = new Set()
|
||||
isMultiSelectMode = false
|
||||
|
||||
// Reload to get updated total count
|
||||
await loadMedia(currentPage)
|
||||
|
||||
} catch (err) {
|
||||
error = 'Failed to delete media files. Please try again.'
|
||||
console.error('Failed to delete media:', err)
|
||||
} finally {
|
||||
isDeleting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkMarkPhotography() {
|
||||
if (selectedMediaIds.size === 0) return
|
||||
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) return
|
||||
|
||||
// Update each selected media item
|
||||
const promises = Array.from(selectedMediaIds).map(async (mediaId) => {
|
||||
const response = await fetch(`/api/media/${mediaId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ isPhotography: true })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update media ${mediaId}`)
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
// Update local media items
|
||||
media = media.map(item =>
|
||||
selectedMediaIds.has(item.id)
|
||||
? { ...item, isPhotography: true }
|
||||
: item
|
||||
)
|
||||
|
||||
// Clear selection
|
||||
selectedMediaIds.clear()
|
||||
selectedMediaIds = new Set()
|
||||
isMultiSelectMode = false
|
||||
|
||||
} catch (err) {
|
||||
error = 'Failed to mark items as photography. Please try again.'
|
||||
console.error('Failed to mark as photography:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBulkUnmarkPhotography() {
|
||||
if (selectedMediaIds.size === 0) return
|
||||
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) return
|
||||
|
||||
// Update each selected media item
|
||||
const promises = Array.from(selectedMediaIds).map(async (mediaId) => {
|
||||
const response = await fetch(`/api/media/${mediaId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ isPhotography: false })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update media ${mediaId}`)
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
// Update local media items
|
||||
media = media.map(item =>
|
||||
selectedMediaIds.has(item.id)
|
||||
? { ...item, isPhotography: false }
|
||||
: item
|
||||
)
|
||||
|
||||
// Clear selection
|
||||
selectedMediaIds.clear()
|
||||
selectedMediaIds = new Set()
|
||||
isMultiSelectMode = false
|
||||
|
||||
} catch (err) {
|
||||
error = 'Failed to remove photography status. Please try again.'
|
||||
console.error('Failed to unmark photography:', err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<h1>Media Library</h1>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
onclick={toggleMultiSelectMode}
|
||||
class="btn btn-secondary"
|
||||
class:active={isMultiSelectMode}
|
||||
>
|
||||
{isMultiSelectMode ? '✓' : '☐'}
|
||||
{isMultiSelectMode ? 'Exit Select' : 'Select'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (viewMode = viewMode === 'grid' ? 'list' : 'grid')}
|
||||
class="btn btn-secondary"
|
||||
|
|
@ -142,6 +324,12 @@
|
|||
<span class="stat-value">{total}</span>
|
||||
<span class="stat-label">Total files</span>
|
||||
</div>
|
||||
{#if isMultiSelectMode}
|
||||
<div class="stat">
|
||||
<span class="stat-value">{selectedMediaIds.size}</span>
|
||||
<span class="stat-label">Selected</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
|
|
@ -153,6 +341,12 @@
|
|||
<option value="application/pdf">PDFs</option>
|
||||
</select>
|
||||
|
||||
<select bind:value={photographyFilter} onchange={handleFilterChange} class="filter-select">
|
||||
<option value="all">All media</option>
|
||||
<option value="true">Photography only</option>
|
||||
<option value="false">Non-photography</option>
|
||||
</select>
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
|
|
@ -170,6 +364,52 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if isMultiSelectMode && media.length > 0}
|
||||
<div class="bulk-actions">
|
||||
<div class="bulk-actions-left">
|
||||
<button
|
||||
onclick={selectAllMedia}
|
||||
class="btn btn-secondary btn-small"
|
||||
disabled={selectedMediaIds.size === media.length}
|
||||
>
|
||||
Select All ({media.length})
|
||||
</button>
|
||||
<button
|
||||
onclick={clearSelection}
|
||||
class="btn btn-secondary btn-small"
|
||||
disabled={selectedMediaIds.size === 0}
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
<div class="bulk-actions-right">
|
||||
{#if selectedMediaIds.size > 0}
|
||||
<button
|
||||
onclick={handleBulkMarkPhotography}
|
||||
class="btn btn-secondary btn-small"
|
||||
title="Mark selected items as photography"
|
||||
>
|
||||
📸 Mark Photography
|
||||
</button>
|
||||
<button
|
||||
onclick={handleBulkUnmarkPhotography}
|
||||
class="btn btn-secondary btn-small"
|
||||
title="Remove photography status from selected items"
|
||||
>
|
||||
🚫 Remove Photography
|
||||
</button>
|
||||
<button
|
||||
onclick={handleBulkDelete}
|
||||
class="btn btn-danger btn-small"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : `Delete ${selectedMediaIds.size} file${selectedMediaIds.size > 1 ? 's' : ''}`}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading">Loading media...</div>
|
||||
{:else if media.length === 0}
|
||||
|
|
@ -180,71 +420,134 @@
|
|||
{:else if viewMode === 'grid'}
|
||||
<div class="media-grid">
|
||||
{#each media as item}
|
||||
<button
|
||||
class="media-item"
|
||||
type="button"
|
||||
onclick={() => handleMediaClick(item)}
|
||||
title="Click to edit {item.filename}"
|
||||
>
|
||||
{#if item.mimeType.startsWith('image/')}
|
||||
<img src={item.mimeType === 'image/svg+xml' ? item.url : (item.thumbnailUrl || item.url)} alt={item.altText || item.filename} />
|
||||
{:else}
|
||||
<div class="file-placeholder">
|
||||
<span class="file-type">{getFileType(item.mimeType)}</span>
|
||||
<div class="media-item-wrapper" class:multiselect={isMultiSelectMode}>
|
||||
{#if isMultiSelectMode}
|
||||
<div class="selection-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMediaIds.has(item.id)}
|
||||
onchange={() => toggleMediaSelection(item.id)}
|
||||
id="media-{item.id}"
|
||||
/>
|
||||
<label for="media-{item.id}" class="checkbox-label"></label>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="media-info">
|
||||
<span class="filename">{item.filename}</span>
|
||||
<span class="filesize">{formatFileSize(item.size)}</span>
|
||||
{#if item.altText}
|
||||
<span class="alt-text" title="Alt text: {item.altText}">
|
||||
Alt: {item.altText.length > 30 ? item.altText.substring(0, 30) + '...' : item.altText}
|
||||
</span>
|
||||
<button
|
||||
class="media-item"
|
||||
type="button"
|
||||
onclick={() => isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)}
|
||||
title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}"
|
||||
class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
|
||||
>
|
||||
{#if item.mimeType.startsWith('image/')}
|
||||
<img src={item.mimeType === 'image/svg+xml' ? item.url : (item.thumbnailUrl || item.url)} alt={item.altText || item.filename} />
|
||||
{:else}
|
||||
<span class="no-alt-text">No alt text</span>
|
||||
<div class="file-placeholder">
|
||||
<span class="file-type">{getFileType(item.mimeType)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<div class="media-info">
|
||||
<span class="filename">{item.filename}</span>
|
||||
<div class="media-indicators">
|
||||
{#if item.isPhotography}
|
||||
<span class="indicator-pill photography" title="Photography">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26" fill="currentColor"/>
|
||||
</svg>
|
||||
Photo
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.altText}
|
||||
<span class="indicator-pill alt-text" title="Alt text: {item.altText}">
|
||||
Alt
|
||||
</span>
|
||||
{:else}
|
||||
<span class="indicator-pill no-alt-text" title="No alt text">
|
||||
No Alt
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="filesize">{formatFileSize(item.size)}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="media-list">
|
||||
{#each media as item}
|
||||
<button
|
||||
class="media-row"
|
||||
type="button"
|
||||
onclick={() => handleMediaClick(item)}
|
||||
title="Click to edit {item.filename}"
|
||||
>
|
||||
<div class="media-preview">
|
||||
{#if item.mimeType.startsWith('image/')}
|
||||
<img src={item.mimeType === 'image/svg+xml' ? item.url : (item.thumbnailUrl || item.url)} alt={item.altText || item.filename} />
|
||||
{:else}
|
||||
<div class="file-icon">{getFileType(item.mimeType)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="media-details">
|
||||
<span class="filename">{item.filename}</span>
|
||||
<span class="file-meta">
|
||||
{getFileType(item.mimeType)} • {formatFileSize(item.size)}
|
||||
{#if item.width && item.height}
|
||||
• {item.width}×{item.height}px
|
||||
<div class="media-row-wrapper" class:multiselect={isMultiSelectMode}>
|
||||
{#if isMultiSelectMode}
|
||||
<div class="selection-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMediaIds.has(item.id)}
|
||||
onchange={() => toggleMediaSelection(item.id)}
|
||||
id="media-row-{item.id}"
|
||||
/>
|
||||
<label for="media-row-{item.id}" class="checkbox-label"></label>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="media-row"
|
||||
type="button"
|
||||
onclick={() => isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)}
|
||||
title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}"
|
||||
class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
|
||||
>
|
||||
<div class="media-preview">
|
||||
{#if item.mimeType.startsWith('image/')}
|
||||
<img src={item.mimeType === 'image/svg+xml' ? item.url : (item.thumbnailUrl || item.url)} alt={item.altText || item.filename} />
|
||||
{:else}
|
||||
<div class="file-icon">{getFileType(item.mimeType)}</div>
|
||||
{/if}
|
||||
</span>
|
||||
{#if item.altText}
|
||||
<span class="alt-text-preview">
|
||||
Alt: {item.altText}
|
||||
</div>
|
||||
<div class="media-details">
|
||||
<div class="filename-row">
|
||||
<span class="filename">{item.filename}</span>
|
||||
<div class="media-indicators">
|
||||
{#if item.isPhotography}
|
||||
<span class="indicator-pill photography" title="Photography">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<polygon points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26" fill="currentColor"/>
|
||||
</svg>
|
||||
Photo
|
||||
</span>
|
||||
{/if}
|
||||
{#if item.altText}
|
||||
<span class="indicator-pill alt-text" title="Alt text: {item.altText}">
|
||||
Alt
|
||||
</span>
|
||||
{:else}
|
||||
<span class="indicator-pill no-alt-text" title="No alt text">
|
||||
No Alt
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<span class="file-meta">
|
||||
{getFileType(item.mimeType)} • {formatFileSize(item.size)}
|
||||
{#if item.width && item.height}
|
||||
• {item.width}×{item.height}px
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="no-alt-text-preview">No alt text</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="media-indicator">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
{#if item.altText}
|
||||
<span class="alt-text-preview">
|
||||
Alt: {item.altText}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="no-alt-text-preview">No alt text</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="media-indicator">
|
||||
{#if !isMultiSelectMode}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -467,7 +770,7 @@
|
|||
.filename {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-20;
|
||||
white-space: nowrap;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
|
@ -477,19 +780,11 @@
|
|||
color: $grey-40;
|
||||
}
|
||||
|
||||
.alt-text {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-30;
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.no-alt-text {
|
||||
font-size: 0.75rem;
|
||||
color: $red-60;
|
||||
font-style: italic;
|
||||
.media-indicators {
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
flex-wrap: wrap;
|
||||
margin: $unit-half 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -556,10 +851,25 @@
|
|||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
|
||||
.filename {
|
||||
font-size: 0.925rem;
|
||||
color: $grey-20;
|
||||
font-weight: 500;
|
||||
.filename-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $unit-2x;
|
||||
|
||||
.filename {
|
||||
font-size: 0.925rem;
|
||||
color: $grey-20;
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.media-indicators {
|
||||
display: flex;
|
||||
gap: $unit-half;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
|
|
@ -640,4 +950,163 @@
|
|||
color: $grey-40;
|
||||
}
|
||||
}
|
||||
|
||||
// Multiselect styles
|
||||
.bulk-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $unit-2x $unit-3x;
|
||||
background: $grey-95;
|
||||
border-radius: $unit;
|
||||
margin-bottom: $unit-3x;
|
||||
gap: $unit-2x;
|
||||
|
||||
.bulk-actions-left,
|
||||
.bulk-actions-right {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: $unit $unit-2x;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: $red-60;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $red-50;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-item-wrapper,
|
||||
.media-row-wrapper {
|
||||
position: relative;
|
||||
|
||||
&.multiselect {
|
||||
.selection-checkbox {
|
||||
position: absolute;
|
||||
top: $unit;
|
||||
left: $unit;
|
||||
z-index: 10;
|
||||
|
||||
input[type="checkbox"] {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid white;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 6px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
input:checked + .checkbox-label {
|
||||
background: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-item,
|
||||
.media-row {
|
||||
&.selected {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border: 2px solid #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-row-wrapper.multiselect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
|
||||
.selection-checkbox {
|
||||
position: static;
|
||||
top: auto;
|
||||
left: auto;
|
||||
z-index: auto;
|
||||
}
|
||||
|
||||
.media-row {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Indicator pill styles
|
||||
.indicator-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
padding: 2px $unit;
|
||||
border-radius: 4px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
line-height: 1;
|
||||
|
||||
svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.photography {
|
||||
background-color: rgba(139, 92, 246, 0.1);
|
||||
color: #7c3aed;
|
||||
border: 1px solid rgba(139, 92, 246, 0.2);
|
||||
|
||||
svg {
|
||||
fill: #7c3aed;
|
||||
}
|
||||
}
|
||||
|
||||
&.alt-text {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
&.no-alt-text {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
514
src/routes/admin/media/upload/+page.svelte
Normal file
514
src/routes/admin/media/upload/+page.svelte
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
let files = $state<File[]>([])
|
||||
let dragActive = $state(false)
|
||||
let isUploading = $state(false)
|
||||
let uploadProgress = $state<Record<string, number>>({})
|
||||
let uploadErrors = $state<string[]>([])
|
||||
let successCount = $state(0)
|
||||
let fileInput: HTMLInputElement
|
||||
|
||||
onMount(() => {
|
||||
// Check authentication
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
}
|
||||
})
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
dragActive = true
|
||||
}
|
||||
|
||||
function handleDragLeave(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
dragActive = false
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
event.preventDefault()
|
||||
dragActive = false
|
||||
|
||||
const droppedFiles = Array.from(event.dataTransfer?.files || [])
|
||||
addFiles(droppedFiles)
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const selectedFiles = Array.from(target.files || [])
|
||||
addFiles(selectedFiles)
|
||||
}
|
||||
|
||||
function addFiles(newFiles: File[]) {
|
||||
// Filter for image files
|
||||
const imageFiles = newFiles.filter(file => file.type.startsWith('image/'))
|
||||
|
||||
if (imageFiles.length !== newFiles.length) {
|
||||
uploadErrors = [...uploadErrors, `${newFiles.length - imageFiles.length} non-image files were skipped`]
|
||||
}
|
||||
|
||||
files = [...files, ...imageFiles]
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
files = files.filter((_, i) => i !== index)
|
||||
// Clear any related upload progress
|
||||
const fileName = files[index]?.name
|
||||
if (fileName && uploadProgress[fileName]) {
|
||||
const { [fileName]: removed, ...rest } = uploadProgress
|
||||
uploadProgress = rest
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
async function uploadFiles() {
|
||||
if (files.length === 0) return
|
||||
|
||||
isUploading = true
|
||||
uploadErrors = []
|
||||
successCount = 0
|
||||
uploadProgress = {}
|
||||
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
uploadErrors = ['Authentication required']
|
||||
isUploading = false
|
||||
return
|
||||
}
|
||||
|
||||
// Upload files individually to show progress
|
||||
for (const file of files) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${auth}`
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
uploadErrors = [...uploadErrors, `${file.name}: ${error.message || 'Upload failed'}`]
|
||||
} else {
|
||||
successCount++
|
||||
uploadProgress = { ...uploadProgress, [file.name]: 100 }
|
||||
}
|
||||
} catch (error) {
|
||||
uploadErrors = [...uploadErrors, `${file.name}: Network error`]
|
||||
}
|
||||
}
|
||||
|
||||
isUploading = false
|
||||
|
||||
// If all uploads succeeded, redirect back to media library
|
||||
if (successCount === files.length && uploadErrors.length === 0) {
|
||||
setTimeout(() => {
|
||||
goto('/admin/media')
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
files = []
|
||||
uploadProgress = {}
|
||||
uploadErrors = []
|
||||
successCount = 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<h1>Upload Media</h1>
|
||||
<div class="header-actions">
|
||||
<Button variant="secondary" onclick={() => goto('/admin/media')}>
|
||||
← Back to Media Library
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="upload-container">
|
||||
<!-- Drop Zone -->
|
||||
<div
|
||||
class="drop-zone"
|
||||
class:active={dragActive}
|
||||
class:has-files={files.length > 0}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
<div class="drop-zone-content">
|
||||
{#if files.length === 0}
|
||||
<div class="upload-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.9 22 6 22H18C19.1 22 20 21.1 20 20V8L14 2Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="14,2 14,8 20,8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<polyline points="10,9 9,9 8,9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Drop images here</h3>
|
||||
<p>or click to browse and select files</p>
|
||||
<p class="upload-hint">Supports JPG, PNG, GIF, WebP, and SVG files</p>
|
||||
{:else}
|
||||
<div class="file-count">
|
||||
<strong>{files.length} file{files.length !== 1 ? 's' : ''} selected</strong>
|
||||
<p>Drop more files to add them, or click to browse</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
onchange={handleFileSelect}
|
||||
class="hidden-input"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="drop-zone-button"
|
||||
onclick={() => fileInput.click()}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{dragActive ? 'Drop files' : 'Click to browse'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- File List -->
|
||||
{#if files.length > 0}
|
||||
<div class="file-list">
|
||||
<div class="file-list-header">
|
||||
<h3>Files to Upload</h3>
|
||||
<div class="file-actions">
|
||||
<Button variant="secondary" size="small" onclick={clearAll} disabled={isUploading}>
|
||||
Clear All
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onclick={uploadFiles}
|
||||
disabled={isUploading || files.length === 0}
|
||||
>
|
||||
{#if isUploading}
|
||||
<LoadingSpinner size="small" />
|
||||
Uploading...
|
||||
{:else}
|
||||
Upload {files.length} File{files.length !== 1 ? 's' : ''}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="files">
|
||||
{#each files as file, index}
|
||||
<div class="file-item">
|
||||
<div class="file-preview">
|
||||
{#if file.type.startsWith('image/')}
|
||||
<img src={URL.createObjectURL(file)} alt={file.name} />
|
||||
{:else}
|
||||
<div class="file-icon">📄</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="file-info">
|
||||
<div class="file-name">{file.name}</div>
|
||||
<div class="file-size">{formatFileSize(file.size)}</div>
|
||||
|
||||
{#if uploadProgress[file.name]}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {uploadProgress[file.name]}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isUploading}
|
||||
<button
|
||||
type="button"
|
||||
class="remove-button"
|
||||
onclick={() => removeFile(index)}
|
||||
title="Remove file"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Upload Results -->
|
||||
{#if successCount > 0 || uploadErrors.length > 0}
|
||||
<div class="upload-results">
|
||||
{#if successCount > 0}
|
||||
<div class="success-message">
|
||||
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
|
||||
{#if successCount === files.length && uploadErrors.length === 0}
|
||||
<br><small>Redirecting to media library...</small>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if uploadErrors.length > 0}
|
||||
<div class="error-messages">
|
||||
<h4>Upload Errors:</h4>
|
||||
{#each uploadErrors as error}
|
||||
<div class="error-item">❌ {error}</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
.upload-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: $unit-4x;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed $grey-80;
|
||||
border-radius: $unit-2x;
|
||||
padding: $unit-6x $unit-4x;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
background: $grey-95;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: $unit-4x;
|
||||
|
||||
&.active {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
&.has-files {
|
||||
padding: $unit-4x;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-60;
|
||||
background: $grey-90;
|
||||
}
|
||||
}
|
||||
|
||||
.drop-zone-content {
|
||||
pointer-events: none;
|
||||
|
||||
.upload-icon {
|
||||
color: $grey-50;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
color: $grey-20;
|
||||
margin-bottom: $unit;
|
||||
}
|
||||
|
||||
p {
|
||||
color: $grey-40;
|
||||
margin-bottom: $unit-half;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-50;
|
||||
}
|
||||
|
||||
.file-count {
|
||||
strong {
|
||||
color: $grey-20;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drop-zone-button {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: transparent;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.file-list {
|
||||
background: white;
|
||||
border: 1px solid $grey-85;
|
||||
border-radius: $unit-2x;
|
||||
padding: $unit-3x;
|
||||
margin-bottom: $unit-4x;
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $unit-3x;
|
||||
padding-bottom: $unit-2x;
|
||||
border-bottom: 1px solid $grey-85;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.files {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-3x;
|
||||
padding: $unit-2x;
|
||||
background: $grey-95;
|
||||
border-radius: $unit;
|
||||
border: 1px solid $grey-85;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: $unit;
|
||||
overflow: hidden;
|
||||
background: $grey-90;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
|
||||
.file-name {
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
margin-bottom: $unit-half;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-50;
|
||||
margin-bottom: $unit-half;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: $grey-85;
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.remove-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: $grey-50;
|
||||
cursor: pointer;
|
||||
padding: $unit;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $red-60;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-results {
|
||||
background: white;
|
||||
border: 1px solid $grey-85;
|
||||
border-radius: $unit-2x;
|
||||
padding: $unit-3x;
|
||||
|
||||
.success-message {
|
||||
color: #16a34a;
|
||||
margin-bottom: $unit-2x;
|
||||
|
||||
small {
|
||||
color: $grey-50;
|
||||
}
|
||||
}
|
||||
|
||||
.error-messages {
|
||||
h4 {
|
||||
color: $red-60;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
|
||||
.error-item {
|
||||
color: $red-60;
|
||||
margin-bottom: $unit;
|
||||
font-size: 0.925rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,29 +2,40 @@
|
|||
import { onMount } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import DataTable from '$lib/components/admin/DataTable.svelte'
|
||||
import PostDropdown from '$lib/components/admin/PostDropdown.svelte'
|
||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||
|
||||
interface Post {
|
||||
id: number
|
||||
slug: string
|
||||
postType: string
|
||||
title: string | null
|
||||
content: any // JSON content
|
||||
excerpt: string | null
|
||||
status: string
|
||||
tags: string[] | null
|
||||
linkUrl: string | null
|
||||
linkDescription: string | null
|
||||
featuredImage: string | null
|
||||
publishedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
let posts = $state<Post[]>([])
|
||||
let filteredPosts = $state<Post[]>([])
|
||||
let isLoading = $state(true)
|
||||
let error = $state('')
|
||||
let total = $state(0)
|
||||
let postTypeCounts = $state<Record<string, number>>({})
|
||||
|
||||
// Filter state
|
||||
let selectedFilter = $state<string>('all')
|
||||
|
||||
const postTypeIcons: Record<string, string> = {
|
||||
post: '💭',
|
||||
essay: '📝',
|
||||
// Legacy types for backward compatibility
|
||||
blog: '📝',
|
||||
microblog: '💭',
|
||||
link: '🔗',
|
||||
|
|
@ -33,66 +44,16 @@
|
|||
}
|
||||
|
||||
const postTypeLabels: Record<string, string> = {
|
||||
blog: 'Blog Post',
|
||||
microblog: 'Microblog',
|
||||
link: 'Link',
|
||||
photo: 'Photo',
|
||||
post: 'Post',
|
||||
essay: 'Essay',
|
||||
// Legacy types for backward compatibility
|
||||
blog: 'Essay',
|
||||
microblog: 'Post',
|
||||
link: 'Post',
|
||||
photo: 'Post',
|
||||
album: 'Album'
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Title',
|
||||
width: '40%',
|
||||
render: (post: Post) => {
|
||||
if (post.title) {
|
||||
return post.title
|
||||
}
|
||||
// For posts without titles, show excerpt or type
|
||||
if (post.excerpt) {
|
||||
return post.excerpt.substring(0, 50) + '...'
|
||||
}
|
||||
return `(${postTypeLabels[post.postType] || post.postType})`
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'postType',
|
||||
label: 'Type',
|
||||
width: '15%',
|
||||
render: (post: Post) => {
|
||||
const icon = postTypeIcons[post.postType] || '📄'
|
||||
const label = postTypeLabels[post.postType] || post.postType
|
||||
return `${icon} ${label}`
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
width: '15%',
|
||||
render: (post: Post) => {
|
||||
return post.status === 'published' ? '🟢 Published' : '⚪ Draft'
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'publishedAt',
|
||||
label: 'Published',
|
||||
width: '15%',
|
||||
render: (post: Post) => {
|
||||
if (!post.publishedAt) return '—'
|
||||
return new Date(post.publishedAt).toLocaleDateString()
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'updatedAt',
|
||||
label: 'Updated',
|
||||
width: '15%',
|
||||
render: (post: Post) => {
|
||||
return new Date(post.updatedAt).toLocaleDateString()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
onMount(async () => {
|
||||
await loadPosts()
|
||||
})
|
||||
|
|
@ -121,12 +82,27 @@
|
|||
posts = data.posts || []
|
||||
total = data.pagination?.total || posts.length
|
||||
|
||||
// Calculate post type counts
|
||||
const counts: Record<string, number> = {}
|
||||
// Calculate post type counts and normalize types
|
||||
const counts: Record<string, number> = {
|
||||
all: posts.length,
|
||||
post: 0,
|
||||
essay: 0
|
||||
}
|
||||
|
||||
posts.forEach((post) => {
|
||||
counts[post.postType] = (counts[post.postType] || 0) + 1
|
||||
// Normalize legacy types to simplified types
|
||||
if (post.postType === 'blog') {
|
||||
counts.essay = (counts.essay || 0) + 1
|
||||
} else if (['microblog', 'link', 'photo'].includes(post.postType)) {
|
||||
counts.post = (counts.post || 0) + 1
|
||||
} else {
|
||||
counts[post.postType] = (counts[post.postType] || 0) + 1
|
||||
}
|
||||
})
|
||||
postTypeCounts = counts
|
||||
|
||||
// Apply initial filter
|
||||
applyFilter()
|
||||
} catch (err) {
|
||||
error = 'Failed to load posts'
|
||||
console.error(err)
|
||||
|
|
@ -135,46 +111,228 @@
|
|||
}
|
||||
}
|
||||
|
||||
function handleRowClick(post: Post) {
|
||||
function applyFilter() {
|
||||
if (selectedFilter === 'all') {
|
||||
filteredPosts = posts
|
||||
} else if (selectedFilter === 'post') {
|
||||
filteredPosts = posts.filter(post =>
|
||||
['post', 'microblog', 'link', 'photo'].includes(post.postType)
|
||||
)
|
||||
} else if (selectedFilter === 'essay') {
|
||||
filteredPosts = posts.filter(post =>
|
||||
['essay', 'blog'].includes(post.postType)
|
||||
)
|
||||
} else {
|
||||
filteredPosts = posts.filter(post => post.postType === selectedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilterChange() {
|
||||
applyFilter()
|
||||
}
|
||||
|
||||
function handlePostClick(post: Post) {
|
||||
goto(`/admin/posts/${post.id}/edit`)
|
||||
}
|
||||
|
||||
function getPostSnippet(post: Post): string {
|
||||
// Try excerpt first
|
||||
if (post.excerpt) {
|
||||
return post.excerpt.length > 150 ? post.excerpt.substring(0, 150) + '...' : post.excerpt
|
||||
}
|
||||
|
||||
// Try to extract text from content JSON
|
||||
if (post.content) {
|
||||
let textContent = ''
|
||||
|
||||
if (typeof post.content === 'object' && post.content.content) {
|
||||
// BlockNote/TipTap format
|
||||
function extractText(node: any): string {
|
||||
if (node.text) return node.text
|
||||
if (node.content && Array.isArray(node.content)) {
|
||||
return node.content.map(extractText).join(' ')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
textContent = extractText(post.content)
|
||||
} else if (typeof post.content === 'string') {
|
||||
textContent = post.content
|
||||
}
|
||||
|
||||
if (textContent) {
|
||||
return textContent.length > 150 ? textContent.substring(0, 150) + '...' : textContent
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to link description for link posts
|
||||
if (post.linkDescription) {
|
||||
return post.linkDescription.length > 150 ? post.linkDescription.substring(0, 150) + '...' : post.linkDescription
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return `${postTypeLabels[post.postType] || post.postType} without content`
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffTime = now.getTime() - date.getTime()
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffDays === 0) {
|
||||
return 'Today'
|
||||
} else if (diffDays === 1) {
|
||||
return 'Yesterday'
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} days ago`
|
||||
} else {
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getDisplayTitle(post: Post): string {
|
||||
if (post.title) return post.title
|
||||
|
||||
// For posts without titles, create a meaningful display title
|
||||
if (post.linkUrl) {
|
||||
try {
|
||||
const domain = new URL(post.linkUrl).hostname.replace('www.', '')
|
||||
return `Link to ${domain}`
|
||||
} catch {
|
||||
return 'Link post'
|
||||
}
|
||||
}
|
||||
|
||||
const snippet = getPostSnippet(post)
|
||||
if (snippet && snippet !== `${postTypeLabels[post.postType] || post.postType} without content`) {
|
||||
return snippet.length > 50 ? snippet.substring(0, 50) + '...' : snippet
|
||||
}
|
||||
|
||||
return `${postTypeLabels[post.postType] || post.postType}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<h1>Posts</h1>
|
||||
<h1>Universe</h1>
|
||||
<div class="header-actions">
|
||||
<select bind:value={selectedFilter} onchange={handleFilterChange} class="filter-select">
|
||||
<option value="all">All posts ({postTypeCounts.all || 0})</option>
|
||||
<option value="post">Posts ({postTypeCounts.post || 0})</option>
|
||||
<option value="essay">Essays ({postTypeCounts.essay || 0})</option>
|
||||
</select>
|
||||
<PostDropdown />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
<div class="error-message">{error}</div>
|
||||
{:else}
|
||||
<!-- Stats -->
|
||||
<div class="posts-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{total}</span>
|
||||
<span class="stat-value">{postTypeCounts.all || 0}</span>
|
||||
<span class="stat-label">Total posts</span>
|
||||
</div>
|
||||
{#each Object.entries(postTypeCounts) as [type, count]}
|
||||
<div class="stat">
|
||||
<span class="stat-value">{count}</span>
|
||||
<span class="stat-label">{postTypeLabels[type] || type}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="stat">
|
||||
<span class="stat-value">{postTypeCounts.post || 0}</span>
|
||||
<span class="stat-label">Posts</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{postTypeCounts.essay || 0}</span>
|
||||
<span class="stat-label">Essays</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={posts}
|
||||
{columns}
|
||||
loading={isLoading}
|
||||
emptyMessage="No posts found. Create your first post!"
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
<!-- Posts List -->
|
||||
{#if isLoading}
|
||||
<div class="loading-container">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if filteredPosts.length === 0}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📝</div>
|
||||
<h3>No posts found</h3>
|
||||
<p>
|
||||
{#if selectedFilter === 'all'}
|
||||
Create your first post to get started!
|
||||
{:else}
|
||||
No {selectedFilter}s found. Try a different filter or create a new {selectedFilter}.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="posts-list">
|
||||
{#each filteredPosts as post}
|
||||
<article class="post-item" onclick={() => handlePostClick(post)}>
|
||||
<div class="post-header">
|
||||
<div class="post-meta">
|
||||
<span class="post-type">
|
||||
{postTypeIcons[post.postType] || '📄'}
|
||||
{postTypeLabels[post.postType] || post.postType}
|
||||
</span>
|
||||
<span class="post-date">{formatDate(post.updatedAt)}</span>
|
||||
</div>
|
||||
<div class="post-status">
|
||||
{#if post.status === 'published'}
|
||||
<span class="status-badge published">Published</span>
|
||||
{:else}
|
||||
<span class="status-badge draft">Draft</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-content">
|
||||
<h3 class="post-title">{getDisplayTitle(post)}</h3>
|
||||
|
||||
{#if post.linkUrl}
|
||||
<div class="post-link">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="link-url">{post.linkUrl}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="post-snippet">{getPostSnippet(post)}</p>
|
||||
|
||||
{#if post.tags && post.tags.length > 0}
|
||||
<div class="post-tags">
|
||||
{#each post.tags.slice(0, 3) as tag}
|
||||
<span class="tag">#{tag}</span>
|
||||
{/each}
|
||||
{#if post.tags.length > 3}
|
||||
<span class="tag-more">+{post.tags.length - 3} more</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="post-footer">
|
||||
<div class="post-actions">
|
||||
<span class="edit-hint">Click to edit</span>
|
||||
</div>
|
||||
<div class="post-indicator">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -186,58 +344,302 @@
|
|||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: $unit-2x $unit-3x;
|
||||
border-radius: 50px;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
transition: all 0.2s ease;
|
||||
.filter-select {
|
||||
padding: $unit $unit-3x;
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: 50px;
|
||||
background: white;
|
||||
font-size: 0.925rem;
|
||||
color: $grey-20;
|
||||
cursor: pointer;
|
||||
min-width: 160px;
|
||||
|
||||
&.btn-primary {
|
||||
background-color: $grey-10;
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-20;
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $grey-40;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
.error-message {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
padding: $unit-3x;
|
||||
border-radius: $unit-2x;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
text-align: center;
|
||||
padding: $unit-6x;
|
||||
color: #d33;
|
||||
margin-bottom: $unit-4x;
|
||||
}
|
||||
|
||||
.posts-stats {
|
||||
display: flex;
|
||||
gap: $unit-4x;
|
||||
margin-bottom: $unit-4x;
|
||||
flex-wrap: wrap;
|
||||
padding: $unit-4x;
|
||||
background: $grey-95;
|
||||
border-radius: $unit-2x;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
gap: $unit-3x;
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: $unit-8x $unit-4x;
|
||||
color: $grey-40;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: $unit-3x;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.posts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
background: white;
|
||||
border: 1px solid $grey-85;
|
||||
border-radius: 12px;
|
||||
padding: $unit-4x;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-70;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.post-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
flex: 1;
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
.post-type {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
padding: $unit-half $unit;
|
||||
background: $grey-95;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: $grey-30;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-50;
|
||||
}
|
||||
|
||||
.post-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: $unit-half $unit-2x;
|
||||
border-radius: 50px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&.published {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
&.draft {
|
||||
background: rgba(156, 163, 175, 0.1);
|
||||
color: #6b7280;
|
||||
border: 1px solid rgba(156, 163, 175, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.post-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.post-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
font-size: 0.875rem;
|
||||
color: $blue-60;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.link-url {
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.post-snippet {
|
||||
margin: 0;
|
||||
font-size: 0.925rem;
|
||||
line-height: 1.5;
|
||||
color: $grey-30;
|
||||
}
|
||||
|
||||
.post-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.tag {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-50;
|
||||
background: $grey-95;
|
||||
padding: $unit-half $unit;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tag-more {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-50;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.post-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.post-actions {
|
||||
.edit-hint {
|
||||
font-size: 0.75rem;
|
||||
color: $grey-50;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.post-item:hover .edit-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.post-indicator {
|
||||
color: $grey-60;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.post-item:hover .post-indicator {
|
||||
color: $grey-30;
|
||||
}
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 768px) {
|
||||
.post-item {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.post-item {
|
||||
padding: $unit-3x $unit-2x;
|
||||
}
|
||||
|
||||
.post-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.post-status {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,15 +3,13 @@
|
|||
import { onMount } from 'svelte'
|
||||
import EssayForm from '$lib/components/admin/EssayForm.svelte'
|
||||
import SimplePostForm from '$lib/components/admin/SimplePostForm.svelte'
|
||||
import PhotoPostForm from '$lib/components/admin/PhotoPostForm.svelte'
|
||||
import AlbumForm from '$lib/components/admin/AlbumForm.svelte'
|
||||
|
||||
let postType: 'blog' | 'microblog' | 'link' | 'photo' | 'album' = 'blog'
|
||||
let postType: 'post' | 'essay' = 'post'
|
||||
let mounted = false
|
||||
|
||||
onMount(() => {
|
||||
const type = $page.url.searchParams.get('type')
|
||||
if (type && ['blog', 'microblog', 'link', 'photo', 'album'].includes(type)) {
|
||||
if (type && ['post', 'essay'].includes(type)) {
|
||||
postType = type as typeof postType
|
||||
}
|
||||
mounted = true
|
||||
|
|
@ -19,13 +17,9 @@
|
|||
</script>
|
||||
|
||||
{#if mounted}
|
||||
{#if postType === 'blog'}
|
||||
{#if postType === 'essay'}
|
||||
<EssayForm mode="create" />
|
||||
{:else if postType === 'microblog' || postType === 'link'}
|
||||
<SimplePostForm {postType} mode="create" />
|
||||
{:else if postType === 'photo'}
|
||||
<PhotoPostForm mode="create" />
|
||||
{:else if postType === 'album'}
|
||||
<AlbumForm mode="create" />
|
||||
{:else}
|
||||
<SimplePostForm postType="post" mode="create" />
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -176,14 +176,13 @@
|
|||
{/if}
|
||||
</AdminPage>
|
||||
|
||||
{#if showDeleteModal && projectToDelete}
|
||||
<DeleteConfirmationModal
|
||||
title="Delete project?"
|
||||
message={`Are you sure you want to delete "${projectToDelete.title}"? This action cannot be undone.`}
|
||||
onconfirm={confirmDelete}
|
||||
oncancel={cancelDelete}
|
||||
/>
|
||||
{/if}
|
||||
<DeleteConfirmationModal
|
||||
bind:isOpen={showDeleteModal}
|
||||
title="Delete project?"
|
||||
message={projectToDelete ? `Are you sure you want to delete "${projectToDelete.title}"? This action cannot be undone.` : ''}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={cancelDelete}
|
||||
/>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
|
||||
// Get initial state from URL params
|
||||
$: postType = ($page.url.searchParams.get('type') as 'post' | 'essay' | 'album') || 'essay'
|
||||
$: postType = ($page.url.searchParams.get('type') as 'post' | 'essay') || 'essay'
|
||||
$: initialContent = $page.url.searchParams.get('content')
|
||||
? JSON.parse($page.url.searchParams.get('content')!)
|
||||
: undefined
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import {
|
|||
jsonResponse,
|
||||
errorResponse,
|
||||
getPaginationParams,
|
||||
getPaginationMeta
|
||||
getPaginationMeta,
|
||||
checkAdminAuth,
|
||||
parseRequestBody
|
||||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
|
|
@ -16,6 +18,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
|
||||
// Get filter parameters
|
||||
const status = event.url.searchParams.get('status')
|
||||
const isPhotography = event.url.searchParams.get('isPhotography')
|
||||
|
||||
// Build where clause
|
||||
const where: any = {}
|
||||
|
|
@ -23,6 +26,10 @@ export const GET: RequestHandler = async (event) => {
|
|||
where.status = status
|
||||
}
|
||||
|
||||
if (isPhotography !== null) {
|
||||
where.isPhotography = isPhotography === 'true'
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const total = await prisma.album.count({ where })
|
||||
|
||||
|
|
@ -52,3 +59,60 @@ export const GET: RequestHandler = async (event) => {
|
|||
return errorResponse('Failed to retrieve albums', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/albums - Create a new album
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
return errorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await parseRequestBody<{
|
||||
slug: string
|
||||
title: string
|
||||
description?: string
|
||||
date?: string
|
||||
location?: string
|
||||
coverPhotoId?: number
|
||||
isPhotography?: boolean
|
||||
status?: string
|
||||
showInUniverse?: boolean
|
||||
}>(event.request)
|
||||
|
||||
if (!body || !body.slug || !body.title) {
|
||||
return errorResponse('Missing required fields: slug, title', 400)
|
||||
}
|
||||
|
||||
// Check if slug already exists
|
||||
const existing = await prisma.album.findUnique({
|
||||
where: { slug: body.slug }
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return errorResponse('Album with this slug already exists', 409)
|
||||
}
|
||||
|
||||
// Create album
|
||||
const album = await prisma.album.create({
|
||||
data: {
|
||||
slug: body.slug,
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
date: body.date ? new Date(body.date) : null,
|
||||
location: body.location,
|
||||
coverPhotoId: body.coverPhotoId,
|
||||
isPhotography: body.isPhotography ?? false,
|
||||
status: body.status ?? 'draft',
|
||||
showInUniverse: body.showInUniverse ?? false
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Album created', { id: album.id, slug: album.slug })
|
||||
|
||||
return jsonResponse(album, 201)
|
||||
} catch (error) {
|
||||
logger.error('Failed to create album', error as Error)
|
||||
return errorResponse('Failed to create album', 500)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
196
src/routes/api/albums/[id]/+server.ts
Normal file
196
src/routes/api/albums/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
// GET /api/albums/[id] - Get a single album
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid album ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const album = await prisma.album.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
photos: {
|
||||
orderBy: { displayOrder: 'asc' }
|
||||
},
|
||||
_count: {
|
||||
select: { photos: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!album) {
|
||||
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
|
||||
const photosWithMedia = album.photos.map(photo => {
|
||||
// Try to find matching media by filename since we don't have direct relationship
|
||||
const media = Array.from(mediaMap.values()).find(m => m.filename === photo.filename)
|
||||
|
||||
return {
|
||||
...photo,
|
||||
mediaId: media?.id,
|
||||
altText: media?.altText,
|
||||
description: media?.description,
|
||||
isPhotography: media?.isPhotography,
|
||||
mimeType: media?.mimeType,
|
||||
size: media?.size
|
||||
}
|
||||
})
|
||||
|
||||
const albumWithEnrichedPhotos = {
|
||||
...album,
|
||||
photos: photosWithMedia
|
||||
}
|
||||
|
||||
return jsonResponse(albumWithEnrichedPhotos)
|
||||
} catch (error) {
|
||||
logger.error('Failed to retrieve album', error as Error)
|
||||
return errorResponse('Failed to retrieve album', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/albums/[id] - Update an album
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
return errorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid album ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await parseRequestBody<{
|
||||
slug?: string
|
||||
title?: string
|
||||
description?: string
|
||||
date?: string
|
||||
location?: string
|
||||
coverPhotoId?: number
|
||||
isPhotography?: boolean
|
||||
status?: string
|
||||
showInUniverse?: boolean
|
||||
}>(event.request)
|
||||
|
||||
if (!body) {
|
||||
return errorResponse('Invalid request body', 400)
|
||||
}
|
||||
|
||||
// Check if album exists
|
||||
const existing = await prisma.album.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
// If slug is being updated, check for conflicts
|
||||
if (body.slug && body.slug !== existing.slug) {
|
||||
const slugExists = await prisma.album.findUnique({
|
||||
where: { slug: body.slug }
|
||||
})
|
||||
|
||||
if (slugExists) {
|
||||
return errorResponse('Album with this slug already exists', 409)
|
||||
}
|
||||
}
|
||||
|
||||
// Update album
|
||||
const album = await prisma.album.update({
|
||||
where: { id },
|
||||
data: {
|
||||
slug: body.slug ?? existing.slug,
|
||||
title: body.title ?? existing.title,
|
||||
description: body.description !== undefined ? body.description : existing.description,
|
||||
date: body.date !== undefined ? (body.date ? new Date(body.date) : null) : existing.date,
|
||||
location: body.location !== undefined ? body.location : existing.location,
|
||||
coverPhotoId: body.coverPhotoId !== undefined ? body.coverPhotoId : existing.coverPhotoId,
|
||||
isPhotography: body.isPhotography ?? existing.isPhotography,
|
||||
status: body.status ?? existing.status,
|
||||
showInUniverse: body.showInUniverse ?? existing.showInUniverse
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Album updated', { id, slug: album.slug })
|
||||
|
||||
return jsonResponse(album)
|
||||
} catch (error) {
|
||||
logger.error('Failed to update album', error as Error)
|
||||
return errorResponse('Failed to update album', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/albums/[id] - Delete an album
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
return errorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid album ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if album exists
|
||||
const album = await prisma.album.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { photos: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!album) {
|
||||
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)
|
||||
}
|
||||
|
||||
// Delete album
|
||||
await prisma.album.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
logger.info('Album deleted', { id, slug: album.slug })
|
||||
|
||||
return new Response(null, { status: 204 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete album', error as Error)
|
||||
return errorResponse('Failed to delete album', 500)
|
||||
}
|
||||
}
|
||||
164
src/routes/api/albums/[id]/photos/+server.ts
Normal file
164
src/routes/api/albums/[id]/photos/+server.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
// POST /api/albums/[id]/photos - Add a photo to an album
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
return errorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const albumId = parseInt(event.params.id)
|
||||
if (isNaN(albumId)) {
|
||||
return errorResponse('Invalid album ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await parseRequestBody<{
|
||||
mediaId: number
|
||||
displayOrder?: number
|
||||
}>(event.request)
|
||||
|
||||
if (!body || !body.mediaId) {
|
||||
return errorResponse('Media ID is required', 400)
|
||||
}
|
||||
|
||||
// Check if album exists
|
||||
const album = await prisma.album.findUnique({
|
||||
where: { id: albumId }
|
||||
})
|
||||
|
||||
if (!album) {
|
||||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
// Check if media exists
|
||||
const media = await prisma.media.findUnique({
|
||||
where: { id: body.mediaId }
|
||||
})
|
||||
|
||||
if (!media) {
|
||||
return errorResponse('Media not found', 404)
|
||||
}
|
||||
|
||||
// Check if media is already an image type
|
||||
if (!media.mimeType.startsWith('image/')) {
|
||||
return errorResponse('Only images can be added to albums', 400)
|
||||
}
|
||||
|
||||
// Get the next display order if not provided
|
||||
let displayOrder = body.displayOrder
|
||||
if (displayOrder === undefined) {
|
||||
const lastPhoto = await prisma.photo.findFirst({
|
||||
where: { albumId },
|
||||
orderBy: { displayOrder: 'desc' }
|
||||
})
|
||||
displayOrder = (lastPhoto?.displayOrder || 0) + 1
|
||||
}
|
||||
|
||||
// Create photo record from media
|
||||
const photo = await prisma.photo.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
|
||||
}
|
||||
})
|
||||
|
||||
// Track media usage
|
||||
await prisma.mediaUsage.create({
|
||||
data: {
|
||||
mediaId: body.mediaId,
|
||||
contentType: 'album',
|
||||
contentId: albumId,
|
||||
fieldName: 'photos'
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Photo 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)
|
||||
} catch (error) {
|
||||
logger.error('Failed to add photo to album', error as Error)
|
||||
return errorResponse('Failed to add photo to album', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/albums/[id]/photos - Update photo order in album
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
return errorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const albumId = parseInt(event.params.id)
|
||||
if (isNaN(albumId)) {
|
||||
return errorResponse('Invalid album ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await parseRequestBody<{
|
||||
photoId: number
|
||||
displayOrder: number
|
||||
}>(event.request)
|
||||
|
||||
if (!body || !body.photoId || body.displayOrder === undefined) {
|
||||
return errorResponse('Photo ID and display order are required', 400)
|
||||
}
|
||||
|
||||
// Check if album exists
|
||||
const album = await prisma.album.findUnique({
|
||||
where: { id: albumId }
|
||||
})
|
||||
|
||||
if (!album) {
|
||||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
// Update photo display order
|
||||
const photo = await prisma.photo.update({
|
||||
where: {
|
||||
id: body.photoId,
|
||||
albumId // Ensure photo belongs to this album
|
||||
},
|
||||
data: {
|
||||
displayOrder: body.displayOrder
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Photo order updated', {
|
||||
albumId,
|
||||
photoId: body.photoId,
|
||||
displayOrder: body.displayOrder
|
||||
})
|
||||
|
||||
return jsonResponse(photo)
|
||||
} catch (error) {
|
||||
logger.error('Failed to update photo order', error as Error)
|
||||
return errorResponse('Failed to update photo order', 500)
|
||||
}
|
||||
}
|
||||
57
src/routes/api/albums/by-slug/[slug]/+server.ts
Normal file
57
src/routes/api/albums/by-slug/[slug]/+server.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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/albums/by-slug/[slug] - Get album by slug including photos
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const slug = event.params.slug
|
||||
|
||||
if (!slug) {
|
||||
return errorResponse('Invalid album slug', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const album = await prisma.album.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
},
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
caption: true,
|
||||
displayOrder: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!album) {
|
||||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
return jsonResponse(album)
|
||||
} catch (error) {
|
||||
logger.error('Failed to retrieve album by slug', error as Error)
|
||||
return errorResponse('Failed to retrieve album', 500)
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
const mimeType = event.url.searchParams.get('mimeType')
|
||||
const unused = event.url.searchParams.get('unused') === 'true'
|
||||
const search = event.url.searchParams.get('search')
|
||||
const isPhotography = event.url.searchParams.get('isPhotography')
|
||||
|
||||
// Build where clause
|
||||
const where: any = {}
|
||||
|
|
@ -40,6 +41,10 @@ export const GET: RequestHandler = async (event) => {
|
|||
where.filename = { contains: search, mode: 'insensitive' }
|
||||
}
|
||||
|
||||
if (isPhotography !== null) {
|
||||
where.isPhotography = isPhotography === 'true'
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const total = await prisma.media.count({ where })
|
||||
|
||||
|
|
@ -59,6 +64,7 @@ export const GET: RequestHandler = async (event) => {
|
|||
width: true,
|
||||
height: true,
|
||||
usedIn: true,
|
||||
isPhotography: true,
|
||||
createdAt: true
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,83 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { deleteFile, extractPublicId } from '$lib/server/cloudinary'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
// GET /api/media/[id] - Get a single media item
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid media ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const media = await prisma.media.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!media) {
|
||||
return errorResponse('Media not found', 404)
|
||||
}
|
||||
|
||||
return jsonResponse(media)
|
||||
} catch (error) {
|
||||
logger.error('Failed to retrieve media', error as Error)
|
||||
return errorResponse('Failed to retrieve media', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/media/[id] - Update a media item
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
return errorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid media ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await parseRequestBody<{
|
||||
altText?: string
|
||||
description?: string
|
||||
isPhotography?: boolean
|
||||
}>(event.request)
|
||||
|
||||
if (!body) {
|
||||
return errorResponse('Invalid request body', 400)
|
||||
}
|
||||
|
||||
// Check if media exists
|
||||
const existing = await prisma.media.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return errorResponse('Media not found', 404)
|
||||
}
|
||||
|
||||
// Update media
|
||||
const media = await prisma.media.update({
|
||||
where: { id },
|
||||
data: {
|
||||
altText: body.altText ?? existing.altText,
|
||||
description: body.description ?? existing.description,
|
||||
isPhotography: body.isPhotography ?? existing.isPhotography
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Media updated', { id, filename: media.filename })
|
||||
|
||||
return jsonResponse(media)
|
||||
} catch (error) {
|
||||
logger.error('Failed to update media', error as Error)
|
||||
return errorResponse('Failed to update media', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/media/[id] - Delete a media item
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { RequestHandler } from './$types'
|
|||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { getMediaUsage } from '$lib/server/media-usage.js'
|
||||
|
||||
// GET /api/media/[id]/usage - Check where media is used
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
|
|
@ -22,7 +23,9 @@ export const GET: RequestHandler = async (event) => {
|
|||
id: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
usedIn: true
|
||||
altText: true,
|
||||
description: true,
|
||||
isPhotography: true
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -30,48 +33,20 @@ export const GET: RequestHandler = async (event) => {
|
|||
return errorResponse('Media not found', 404)
|
||||
}
|
||||
|
||||
// Parse the usedIn field and fetch details
|
||||
const usage = (media.usedIn as Array<{ type: string; id: number }>) || []
|
||||
const detailedUsage = []
|
||||
|
||||
for (const item of usage) {
|
||||
try {
|
||||
let details = null
|
||||
|
||||
switch (item.type) {
|
||||
case 'post':
|
||||
details = await prisma.post.findUnique({
|
||||
where: { id: item.id },
|
||||
select: { id: true, title: true, slug: true, postType: true }
|
||||
})
|
||||
break
|
||||
case 'project':
|
||||
details = await prisma.project.findUnique({
|
||||
where: { id: item.id },
|
||||
select: { id: true, title: true, slug: true }
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
if (details) {
|
||||
detailedUsage.push({
|
||||
type: item.type,
|
||||
...details
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to fetch usage details', { type: item.type, id: item.id })
|
||||
}
|
||||
}
|
||||
// Get detailed usage information using our new tracking system
|
||||
const usage = await getMediaUsage(id)
|
||||
|
||||
return jsonResponse({
|
||||
media: {
|
||||
id: media.id,
|
||||
filename: media.filename,
|
||||
url: media.url
|
||||
url: media.url,
|
||||
altText: media.altText,
|
||||
description: media.description,
|
||||
isPhotography: media.isPhotography
|
||||
},
|
||||
usage: detailedUsage,
|
||||
isUsed: detailedUsage.length > 0
|
||||
usage: usage,
|
||||
isUsed: usage.length > 0
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to check media usage', error as Error)
|
||||
|
|
|
|||
142
src/routes/api/media/backfill-usage/+server.ts
Normal file
142
src/routes/api/media/backfill-usage/+server.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { trackMediaUsage, extractMediaIds, removeMediaUsage, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
|
||||
// POST /api/media/backfill-usage - Backfill media usage tracking for all content
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
return errorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
try {
|
||||
let totalTracked = 0
|
||||
const usageReferences: MediaUsageReference[] = []
|
||||
|
||||
// Clear all existing usage tracking
|
||||
await prisma.mediaUsage.deleteMany({})
|
||||
|
||||
// Backfill projects
|
||||
const projects = await prisma.project.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
featuredImage: true,
|
||||
logoUrl: true,
|
||||
gallery: true,
|
||||
caseStudyContent: true
|
||||
}
|
||||
})
|
||||
|
||||
for (const project of projects) {
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds(project, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
contentId: project.id,
|
||||
fieldName: 'featuredImage'
|
||||
})
|
||||
})
|
||||
|
||||
// Track logo
|
||||
const logoIds = extractMediaIds(project, 'logoUrl')
|
||||
logoIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
contentId: project.id,
|
||||
fieldName: 'logoUrl'
|
||||
})
|
||||
})
|
||||
|
||||
// Track gallery images
|
||||
const galleryIds = extractMediaIds(project, 'gallery')
|
||||
galleryIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
contentId: project.id,
|
||||
fieldName: 'gallery'
|
||||
})
|
||||
})
|
||||
|
||||
// Track media in case study content
|
||||
const contentIds = extractMediaIds(project, 'caseStudyContent')
|
||||
contentIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
contentId: project.id,
|
||||
fieldName: 'content'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Backfill posts
|
||||
const posts = await prisma.post.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
featuredImage: true,
|
||||
content: true,
|
||||
attachments: true
|
||||
}
|
||||
})
|
||||
|
||||
for (const post of posts) {
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds(post, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
contentId: post.id,
|
||||
fieldName: 'featuredImage'
|
||||
})
|
||||
})
|
||||
|
||||
// Track attachments
|
||||
const attachmentIds = extractMediaIds(post, 'attachments')
|
||||
attachmentIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
contentId: post.id,
|
||||
fieldName: 'attachments'
|
||||
})
|
||||
})
|
||||
|
||||
// Track media in post content
|
||||
const contentIds = extractMediaIds(post, 'content')
|
||||
contentIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
contentId: post.id,
|
||||
fieldName: 'content'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Save all usage references
|
||||
if (usageReferences.length > 0) {
|
||||
await trackMediaUsage(usageReferences)
|
||||
totalTracked = usageReferences.length
|
||||
}
|
||||
|
||||
logger.info('Media usage backfill completed', { totalTracked })
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
message: 'Media usage tracking backfilled successfully',
|
||||
totalTracked,
|
||||
projectsProcessed: projects.length,
|
||||
postsProcessed: posts.length
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to backfill media usage', error as Error)
|
||||
return errorResponse('Failed to backfill media usage', 500)
|
||||
}
|
||||
}
|
||||
239
src/routes/api/media/bulk-delete/+server.ts
Normal file
239
src/routes/api/media/bulk-delete/+server.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { removeMediaUsage, extractMediaIds } from '$lib/server/media-usage.js'
|
||||
|
||||
// DELETE /api/media/bulk-delete - Delete multiple media files and clean up references
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
return errorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await parseRequestBody<{ mediaIds: number[] }>(event.request)
|
||||
if (!body || !Array.isArray(body.mediaIds) || body.mediaIds.length === 0) {
|
||||
return errorResponse('Invalid request body. Expected array of media IDs.', 400)
|
||||
}
|
||||
|
||||
const mediaIds = body.mediaIds.filter(id => typeof id === 'number' && !isNaN(id))
|
||||
if (mediaIds.length === 0) {
|
||||
return errorResponse('No valid media IDs provided', 400)
|
||||
}
|
||||
|
||||
// Get media records before deletion to extract URLs for cleanup
|
||||
const mediaRecords = await prisma.media.findMany({
|
||||
where: { id: { in: mediaIds } },
|
||||
select: { id: true, url: true, thumbnailUrl: true, filename: true }
|
||||
})
|
||||
|
||||
if (mediaRecords.length === 0) {
|
||||
return errorResponse('No media files found with the provided IDs', 404)
|
||||
}
|
||||
|
||||
// Remove media usage tracking for all affected media
|
||||
for (const mediaId of mediaIds) {
|
||||
await prisma.mediaUsage.deleteMany({
|
||||
where: { mediaId }
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up references in content that uses these media files
|
||||
await cleanupMediaReferences(mediaIds)
|
||||
|
||||
// Delete the media records from database
|
||||
const deleteResult = await prisma.media.deleteMany({
|
||||
where: { id: { in: mediaIds } }
|
||||
})
|
||||
|
||||
logger.info('Bulk media deletion completed', {
|
||||
deletedCount: deleteResult.count,
|
||||
mediaIds,
|
||||
filenames: mediaRecords.map(m => m.filename)
|
||||
})
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
message: `Successfully deleted ${deleteResult.count} media file${deleteResult.count > 1 ? 's' : ''}`,
|
||||
deletedCount: deleteResult.count,
|
||||
deletedFiles: mediaRecords.map(m => ({ id: m.id, filename: m.filename }))
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to bulk delete media files', error as Error)
|
||||
return errorResponse('Failed to delete media files', 500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up references to deleted media in all content types
|
||||
*/
|
||||
async function cleanupMediaReferences(mediaIds: number[]) {
|
||||
const mediaUrls = await prisma.media.findMany({
|
||||
where: { id: { in: mediaIds } },
|
||||
select: { url: true }
|
||||
})
|
||||
const urlsToRemove = mediaUrls.map(m => m.url)
|
||||
|
||||
// Clean up projects
|
||||
const projects = await prisma.project.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
featuredImage: true,
|
||||
logoUrl: true,
|
||||
gallery: true,
|
||||
caseStudyContent: true
|
||||
}
|
||||
})
|
||||
|
||||
for (const project of projects) {
|
||||
let needsUpdate = false
|
||||
const updateData: any = {}
|
||||
|
||||
// Check featured image
|
||||
if (project.featuredImage && urlsToRemove.includes(project.featuredImage)) {
|
||||
updateData.featuredImage = null
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
// Check logo URL
|
||||
if (project.logoUrl && urlsToRemove.includes(project.logoUrl)) {
|
||||
updateData.logoUrl = null
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
// Check gallery
|
||||
if (project.gallery && Array.isArray(project.gallery)) {
|
||||
const filteredGallery = project.gallery.filter((item: any) => {
|
||||
const itemId = typeof item === 'object' ? item.id : parseInt(item)
|
||||
return !mediaIds.includes(itemId)
|
||||
})
|
||||
if (filteredGallery.length !== project.gallery.length) {
|
||||
updateData.gallery = filteredGallery.length > 0 ? filteredGallery : null
|
||||
needsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check case study content
|
||||
if (project.caseStudyContent) {
|
||||
const cleanedContent = cleanContentFromMedia(project.caseStudyContent, mediaIds, urlsToRemove)
|
||||
if (cleanedContent !== project.caseStudyContent) {
|
||||
updateData.caseStudyContent = cleanedContent
|
||||
needsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: updateData
|
||||
})
|
||||
logger.info('Cleaned up media references in project', { projectId: project.id })
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up posts
|
||||
const posts = await prisma.post.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
featuredImage: true,
|
||||
content: true,
|
||||
attachments: true
|
||||
}
|
||||
})
|
||||
|
||||
for (const post of posts) {
|
||||
let needsUpdate = false
|
||||
const updateData: any = {}
|
||||
|
||||
// Check featured image
|
||||
if (post.featuredImage && urlsToRemove.includes(post.featuredImage)) {
|
||||
updateData.featuredImage = null
|
||||
needsUpdate = true
|
||||
}
|
||||
|
||||
// Check attachments
|
||||
if (post.attachments && Array.isArray(post.attachments)) {
|
||||
const filteredAttachments = post.attachments.filter((item: any) => {
|
||||
const itemId = typeof item === 'object' ? item.id : parseInt(item)
|
||||
return !mediaIds.includes(itemId)
|
||||
})
|
||||
if (filteredAttachments.length !== post.attachments.length) {
|
||||
updateData.attachments = filteredAttachments.length > 0 ? filteredAttachments : null
|
||||
needsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check post content
|
||||
if (post.content) {
|
||||
const cleanedContent = cleanContentFromMedia(post.content, mediaIds, urlsToRemove)
|
||||
if (cleanedContent !== post.content) {
|
||||
updateData.content = cleanedContent
|
||||
needsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await prisma.post.update({
|
||||
where: { id: post.id },
|
||||
data: updateData
|
||||
})
|
||||
logger.info('Cleaned up media references in post', { postId: post.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove media references from rich text content
|
||||
*/
|
||||
function cleanContentFromMedia(content: any, mediaIds: number[], urlsToRemove: string[]): any {
|
||||
if (!content || typeof content !== 'object') return content
|
||||
|
||||
function cleanNode(node: any): any {
|
||||
if (!node) return node
|
||||
|
||||
// Remove image nodes that reference deleted media
|
||||
if (node.type === 'image' && node.attrs?.src) {
|
||||
const shouldRemove = urlsToRemove.some(url => node.attrs.src.includes(url))
|
||||
if (shouldRemove) {
|
||||
return null // Mark for removal
|
||||
}
|
||||
}
|
||||
|
||||
// Clean gallery nodes
|
||||
if (node.type === 'gallery' && node.attrs?.images) {
|
||||
const filteredImages = node.attrs.images.filter((image: any) =>
|
||||
!mediaIds.includes(image.id)
|
||||
)
|
||||
|
||||
if (filteredImages.length === 0) {
|
||||
return null // Remove empty gallery
|
||||
} else if (filteredImages.length !== node.attrs.images.length) {
|
||||
return {
|
||||
...node,
|
||||
attrs: {
|
||||
...node.attrs,
|
||||
images: filteredImages
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively clean child nodes
|
||||
if (node.content) {
|
||||
const cleanedContent = node.content
|
||||
.map(cleanNode)
|
||||
.filter((child: any) => child !== null)
|
||||
|
||||
return {
|
||||
...node,
|
||||
content: cleanedContent
|
||||
}
|
||||
}
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
return cleanNode(content)
|
||||
}
|
||||
|
|
@ -4,6 +4,83 @@ import { uploadFile, isCloudinaryConfigured } from '$lib/server/cloudinary'
|
|||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { dev } from '$app/environment'
|
||||
import exifr from 'exifr'
|
||||
|
||||
// Helper function to extract and format EXIF data
|
||||
async function extractExifData(file: File): Promise<any> {
|
||||
try {
|
||||
const buffer = await file.arrayBuffer()
|
||||
const exif = await exifr.parse(buffer, {
|
||||
pick: [
|
||||
'Make', 'Model', 'LensModel', 'FocalLength', 'FNumber', 'ExposureTime',
|
||||
'ISO', 'DateTime', 'DateTimeOriginal', 'CreateDate', 'GPSLatitude',
|
||||
'GPSLongitude', 'GPSAltitude', 'Orientation', 'ColorSpace'
|
||||
]
|
||||
})
|
||||
|
||||
if (!exif) return null
|
||||
|
||||
// Format the data into a more usable structure
|
||||
const formattedExif: any = {}
|
||||
|
||||
if (exif.Make && exif.Model) {
|
||||
formattedExif.camera = `${exif.Make} ${exif.Model}`.trim()
|
||||
}
|
||||
|
||||
if (exif.LensModel) {
|
||||
formattedExif.lens = exif.LensModel
|
||||
}
|
||||
|
||||
if (exif.FocalLength) {
|
||||
formattedExif.focalLength = `${exif.FocalLength}mm`
|
||||
}
|
||||
|
||||
if (exif.FNumber) {
|
||||
formattedExif.aperture = `f/${exif.FNumber}`
|
||||
}
|
||||
|
||||
if (exif.ExposureTime) {
|
||||
if (exif.ExposureTime < 1) {
|
||||
formattedExif.shutterSpeed = `1/${Math.round(1 / exif.ExposureTime)}`
|
||||
} else {
|
||||
formattedExif.shutterSpeed = `${exif.ExposureTime}s`
|
||||
}
|
||||
}
|
||||
|
||||
if (exif.ISO) {
|
||||
formattedExif.iso = `ISO ${exif.ISO}`
|
||||
}
|
||||
|
||||
// Use the most reliable date field available
|
||||
const dateField = exif.DateTimeOriginal || exif.CreateDate || exif.DateTime
|
||||
if (dateField) {
|
||||
formattedExif.dateTaken = dateField.toISOString()
|
||||
}
|
||||
|
||||
// GPS coordinates
|
||||
if (exif.GPSLatitude && exif.GPSLongitude) {
|
||||
formattedExif.coordinates = {
|
||||
latitude: exif.GPSLatitude,
|
||||
longitude: exif.GPSLongitude,
|
||||
altitude: exif.GPSAltitude || null
|
||||
}
|
||||
}
|
||||
|
||||
// Additional metadata
|
||||
if (exif.Orientation) {
|
||||
formattedExif.orientation = exif.Orientation
|
||||
}
|
||||
|
||||
if (exif.ColorSpace) {
|
||||
formattedExif.colorSpace = exif.ColorSpace
|
||||
}
|
||||
|
||||
return Object.keys(formattedExif).length > 0 ? formattedExif : null
|
||||
} catch (error) {
|
||||
logger.warn('Failed to extract EXIF data', { error: error instanceof Error ? error.message : 'Unknown error' })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
|
|
@ -22,6 +99,7 @@ export const POST: RequestHandler = async (event) => {
|
|||
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'
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return errorResponse('No file provided', 400)
|
||||
|
|
@ -46,6 +124,12 @@ export const POST: RequestHandler = async (event) => {
|
|||
return errorResponse('File too large. Maximum size is 10MB', 400)
|
||||
}
|
||||
|
||||
// Extract EXIF data for image files (but don't block upload if it fails)
|
||||
let exifData = null
|
||||
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
|
||||
exifData = await extractExifData(file)
|
||||
}
|
||||
|
||||
// Upload to Cloudinary
|
||||
const uploadResult = await uploadFile(file, context as 'media' | 'photos' | 'projects')
|
||||
|
||||
|
|
@ -64,8 +148,10 @@ export const POST: RequestHandler = async (event) => {
|
|||
thumbnailUrl: uploadResult.thumbnailUrl,
|
||||
width: uploadResult.width,
|
||||
height: uploadResult.height,
|
||||
exifData: exifData,
|
||||
altText: altText?.trim() || null,
|
||||
description: description?.trim() || null,
|
||||
isPhotography: isPhotography,
|
||||
usedIn: []
|
||||
}
|
||||
})
|
||||
|
|
|
|||
130
src/routes/api/photos/+server.ts
Normal file
130
src/routes/api/photos/+server.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
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'
|
||||
import type { PhotoItem, PhotoAlbum, Photo } from '$lib/types/photos'
|
||||
|
||||
// GET /api/photos - Get published photography albums and individual photos
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
try {
|
||||
const url = new URL(event.request.url)
|
||||
const limit = parseInt(url.searchParams.get('limit') || '50')
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0')
|
||||
|
||||
// Fetch published photography albums
|
||||
const albums = await prisma.album.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
isPhotography: true
|
||||
},
|
||||
include: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
},
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
caption: true,
|
||||
displayOrder: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: offset,
|
||||
take: limit
|
||||
})
|
||||
|
||||
// Fetch individual published photos (not in albums, marked for photography)
|
||||
const individualPhotos = await prisma.photo.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true,
|
||||
albumId: null // Only photos not in albums
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
filename: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
width: true,
|
||||
height: true,
|
||||
caption: true,
|
||||
title: true,
|
||||
description: true
|
||||
},
|
||||
orderBy: { createdAt: '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()
|
||||
}))
|
||||
|
||||
// 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
|
||||
}))
|
||||
|
||||
// Combine albums and individual photos
|
||||
const photoItems: PhotoItem[] = [...photoAlbums, ...photos]
|
||||
|
||||
// Sort by creation date (albums use createdAt, individual photos would need publishedAt or 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()
|
||||
return dateB.getTime() - dateA.getTime()
|
||||
})
|
||||
|
||||
const response = {
|
||||
photoItems,
|
||||
pagination: {
|
||||
total: photoItems.length,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: photoItems.length === limit // Simple check, could be more sophisticated
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse(response)
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch photos', error as Error)
|
||||
return errorResponse('Failed to fetch photos', 500)
|
||||
}
|
||||
}
|
||||
80
src/routes/api/photos/[albumSlug]/[photoId]/+server.ts
Normal file
80
src/routes/api/photos/[albumSlug]/[photoId]/+server.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
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/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)
|
||||
|
||||
if (!albumSlug || isNaN(photoId)) {
|
||||
return errorResponse('Invalid album slug or photo ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
// First find the album
|
||||
const album = await prisma.album.findUnique({
|
||||
where: {
|
||||
slug: albumSlug,
|
||||
status: 'published',
|
||||
isPhotography: true
|
||||
},
|
||||
include: {
|
||||
photos: {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!album) {
|
||||
return errorResponse('Album not found', 404)
|
||||
}
|
||||
|
||||
// Find the specific photo
|
||||
const photo = album.photos.find(p => p.id === photoId)
|
||||
if (!photo) {
|
||||
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
|
||||
|
||||
return jsonResponse({
|
||||
photo,
|
||||
album: {
|
||||
id: album.id,
|
||||
slug: album.slug,
|
||||
title: album.title,
|
||||
description: album.description,
|
||||
location: album.location,
|
||||
date: album.date,
|
||||
totalPhotos: album.photos.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
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to retrieve photo', error as Error)
|
||||
return errorResponse('Failed to retrieve photo', 500)
|
||||
}
|
||||
}
|
||||
128
src/routes/api/photos/[id]/+server.ts
Normal file
128
src/routes/api/photos/[id]/+server.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
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
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid photo ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const photo = await prisma.photo.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
album: {
|
||||
select: { id: true, title: true, slug: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!photo) {
|
||||
return errorResponse('Photo not found', 404)
|
||||
}
|
||||
|
||||
return jsonResponse(photo)
|
||||
} catch (error) {
|
||||
logger.error('Failed to retrieve photo', error as Error)
|
||||
return errorResponse('Failed to retrieve photo', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/photos/[id] - Delete a photo (remove from album)
|
||||
export const DELETE: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
return errorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid photo ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if photo exists
|
||||
const photo = await prisma.photo.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!photo) {
|
||||
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 }
|
||||
})
|
||||
|
||||
logger.info('Photo deleted from album', {
|
||||
photoId: id,
|
||||
albumId: photo.albumId
|
||||
})
|
||||
|
||||
return new Response(null, { status: 204 })
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete photo', error as Error)
|
||||
return errorResponse('Failed to delete photo', 500)
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/photos/[id] - Update photo metadata
|
||||
export const PUT: RequestHandler = async (event) => {
|
||||
// Check authentication
|
||||
if (!checkAdminAuth(event)) {
|
||||
return errorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const id = parseInt(event.params.id)
|
||||
if (isNaN(id)) {
|
||||
return errorResponse('Invalid photo ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await event.request.json()
|
||||
|
||||
// Check if photo exists
|
||||
const existing = await prisma.photo.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
return errorResponse('Photo not found', 404)
|
||||
}
|
||||
|
||||
// Update photo
|
||||
const photo = await prisma.photo.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
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Photo updated', { photoId: id })
|
||||
|
||||
return jsonResponse(photo)
|
||||
} catch (error) {
|
||||
logger.error('Failed to update photo', error as Error)
|
||||
return errorResponse('Failed to update photo', 500)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
checkAdminAuth
|
||||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { trackMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
|
||||
// GET /api/posts - List all posts
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
|
|
@ -111,11 +112,69 @@ export const POST: RequestHandler = async (event) => {
|
|||
linkUrl: data.link_url,
|
||||
linkDescription: data.linkDescription,
|
||||
featuredImage: featuredImageId,
|
||||
attachments: data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
|
||||
tags: data.tags,
|
||||
publishedAt: data.publishedAt
|
||||
}
|
||||
})
|
||||
|
||||
// Track media usage
|
||||
try {
|
||||
const usageReferences: MediaUsageReference[] = []
|
||||
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds({ featuredImage: featuredImageId }, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
contentId: post.id,
|
||||
fieldName: 'featuredImage'
|
||||
})
|
||||
})
|
||||
|
||||
// Track attached photos (for photo posts)
|
||||
if (data.attachedPhotos && Array.isArray(data.attachedPhotos)) {
|
||||
data.attachedPhotos.forEach((mediaId: number) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
contentId: post.id,
|
||||
fieldName: 'attachments'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Track gallery (for album posts)
|
||||
if (data.gallery && Array.isArray(data.gallery)) {
|
||||
data.gallery.forEach((mediaId: number) => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
contentId: post.id,
|
||||
fieldName: 'gallery'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Track media in post content
|
||||
const contentIds = extractMediaIds({ content: postContent }, 'content')
|
||||
contentIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
contentId: post.id,
|
||||
fieldName: 'content'
|
||||
})
|
||||
})
|
||||
|
||||
if (usageReferences.length > 0) {
|
||||
await trackMediaUsage(usageReferences)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to track media usage for post', { postId: post.id, error })
|
||||
}
|
||||
|
||||
logger.info('Post created', { id: post.id, title: post.title })
|
||||
return jsonResponse(post)
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { RequestHandler } from './$types'
|
|||
import { prisma } from '$lib/server/database'
|
||||
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { updateMediaUsage, removeMediaUsage, extractMediaIds, trackMediaUsage, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
|
||||
// GET /api/posts/[id] - Get a single post
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
|
|
@ -92,11 +93,61 @@ export const PUT: RequestHandler = async (event) => {
|
|||
linkUrl: data.link_url,
|
||||
linkDescription: data.linkDescription,
|
||||
featuredImage: featuredImageId,
|
||||
attachments: data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
|
||||
tags: data.tags,
|
||||
publishedAt: data.publishedAt
|
||||
}
|
||||
})
|
||||
|
||||
// Update media usage tracking
|
||||
try {
|
||||
// Remove all existing usage for this post first
|
||||
await removeMediaUsage('post', id)
|
||||
|
||||
// Track all current media usage in the updated post
|
||||
const usageReferences: MediaUsageReference[] = []
|
||||
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds(post, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
contentId: id,
|
||||
fieldName: 'featuredImage'
|
||||
})
|
||||
})
|
||||
|
||||
// Track attachments
|
||||
const attachmentIds = extractMediaIds(post, 'attachments')
|
||||
attachmentIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
contentId: id,
|
||||
fieldName: 'attachments'
|
||||
})
|
||||
})
|
||||
|
||||
// Track media in post content
|
||||
const contentIds = extractMediaIds(post, 'content')
|
||||
contentIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'post',
|
||||
contentId: id,
|
||||
fieldName: 'content'
|
||||
})
|
||||
})
|
||||
|
||||
// Add new usage references
|
||||
if (usageReferences.length > 0) {
|
||||
await trackMediaUsage(usageReferences)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to update media usage for post', { postId: id, error })
|
||||
}
|
||||
|
||||
logger.info('Post updated', { id })
|
||||
return jsonResponse(post)
|
||||
} catch (error) {
|
||||
|
|
@ -117,6 +168,10 @@ export const DELETE: RequestHandler = async (event) => {
|
|||
return errorResponse('Invalid post ID', 400)
|
||||
}
|
||||
|
||||
// Remove media usage tracking first
|
||||
await removeMediaUsage('post', id)
|
||||
|
||||
// Delete the post
|
||||
await prisma.post.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
|
|
|||
53
src/routes/api/posts/by-slug/[slug]/+server.ts
Normal file
53
src/routes/api/posts/by-slug/[slug]/+server.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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/posts/by-slug/[slug] - Get post by slug
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
const slug = event.params.slug
|
||||
|
||||
if (!slug) {
|
||||
return errorResponse('Invalid post slug', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
album: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
description: true
|
||||
}
|
||||
},
|
||||
photo: {
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
caption: true,
|
||||
width: true,
|
||||
height: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!post) {
|
||||
return errorResponse('Post not found', 404)
|
||||
}
|
||||
|
||||
// Only return published posts
|
||||
if (post.status !== 'published' || !post.publishedAt) {
|
||||
return errorResponse('Post not found', 404)
|
||||
}
|
||||
|
||||
return jsonResponse(post)
|
||||
} catch (error) {
|
||||
logger.error('Failed to retrieve post by slug', error as Error)
|
||||
return errorResponse('Failed to retrieve post', 500)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { createSlug, ensureUniqueSlug } from '$lib/server/database'
|
||||
import { trackMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
|
||||
// GET /api/projects - List all projects
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
|
|
@ -19,11 +20,32 @@ export const GET: RequestHandler = async (event) => {
|
|||
|
||||
// Get filter parameters
|
||||
const status = event.url.searchParams.get('status')
|
||||
const projectType = event.url.searchParams.get('projectType')
|
||||
const includeListOnly = event.url.searchParams.get('includeListOnly') === 'true'
|
||||
const includePasswordProtected = event.url.searchParams.get('includePasswordProtected') === 'true'
|
||||
|
||||
// Build where clause
|
||||
const where: any = {}
|
||||
|
||||
if (status) {
|
||||
where.status = status
|
||||
} else {
|
||||
// Default behavior: determine which statuses to include
|
||||
const allowedStatuses = ['published']
|
||||
|
||||
if (includeListOnly) {
|
||||
allowedStatuses.push('list-only')
|
||||
}
|
||||
|
||||
if (includePasswordProtected) {
|
||||
allowedStatuses.push('password-protected')
|
||||
}
|
||||
|
||||
where.status = { in: allowedStatuses }
|
||||
}
|
||||
|
||||
if (projectType) {
|
||||
where.projectType = projectType
|
||||
}
|
||||
|
||||
// Get total count
|
||||
|
|
@ -83,7 +105,6 @@ export const POST: RequestHandler = async (event) => {
|
|||
year: body.year,
|
||||
client: body.client,
|
||||
role: body.role,
|
||||
technologies: body.technologies || [],
|
||||
featuredImage: body.featuredImage,
|
||||
logoUrl: body.logoUrl,
|
||||
gallery: body.gallery || [],
|
||||
|
|
@ -91,12 +112,69 @@ export const POST: RequestHandler = async (event) => {
|
|||
caseStudyContent: body.caseStudyContent,
|
||||
backgroundColor: body.backgroundColor,
|
||||
highlightColor: body.highlightColor,
|
||||
projectType: body.projectType || 'work',
|
||||
displayOrder: body.displayOrder || 0,
|
||||
status: body.status || 'draft',
|
||||
password: body.password || null,
|
||||
publishedAt: body.status === 'published' ? new Date() : null
|
||||
}
|
||||
})
|
||||
|
||||
// Track media usage
|
||||
try {
|
||||
const usageReferences: MediaUsageReference[] = []
|
||||
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds(body, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
contentId: project.id,
|
||||
fieldName: 'featuredImage'
|
||||
})
|
||||
})
|
||||
|
||||
// Track logo
|
||||
const logoIds = extractMediaIds(body, 'logoUrl')
|
||||
logoIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
contentId: project.id,
|
||||
fieldName: 'logoUrl'
|
||||
})
|
||||
})
|
||||
|
||||
// Track gallery images
|
||||
const galleryIds = extractMediaIds(body, 'gallery')
|
||||
galleryIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
contentId: project.id,
|
||||
fieldName: 'gallery'
|
||||
})
|
||||
})
|
||||
|
||||
// Track media in case study content
|
||||
const contentIds = extractMediaIds(body, 'caseStudyContent')
|
||||
contentIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
contentId: project.id,
|
||||
fieldName: 'content'
|
||||
})
|
||||
})
|
||||
|
||||
if (usageReferences.length > 0) {
|
||||
await trackMediaUsage(usageReferences)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to track media usage for project', { projectId: project.id, error })
|
||||
}
|
||||
|
||||
logger.info('Project created', { id: project.id, slug: project.slug })
|
||||
|
||||
return jsonResponse(project, 201)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
} from '$lib/server/api-utils'
|
||||
import { logger } from '$lib/server/logger'
|
||||
import { ensureUniqueSlug } from '$lib/server/database'
|
||||
import { updateMediaUsage, removeMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js'
|
||||
|
||||
// GET /api/projects/[id] - Get a single project
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
|
|
@ -76,7 +77,6 @@ export const PUT: RequestHandler = async (event) => {
|
|||
year: body.year ?? existing.year,
|
||||
client: body.client ?? existing.client,
|
||||
role: body.role ?? existing.role,
|
||||
technologies: body.technologies ?? existing.technologies,
|
||||
featuredImage: body.featuredImage ?? existing.featuredImage,
|
||||
logoUrl: body.logoUrl ?? existing.logoUrl,
|
||||
gallery: body.gallery ?? existing.gallery,
|
||||
|
|
@ -84,13 +84,74 @@ export const PUT: RequestHandler = async (event) => {
|
|||
caseStudyContent: body.caseStudyContent ?? existing.caseStudyContent,
|
||||
backgroundColor: body.backgroundColor ?? existing.backgroundColor,
|
||||
highlightColor: body.highlightColor ?? existing.highlightColor,
|
||||
projectType: body.projectType ?? existing.projectType,
|
||||
displayOrder: body.displayOrder ?? existing.displayOrder,
|
||||
status: body.status ?? existing.status,
|
||||
password: body.password ?? existing.password,
|
||||
publishedAt:
|
||||
body.status === 'published' && !existing.publishedAt ? new Date() : existing.publishedAt
|
||||
}
|
||||
})
|
||||
|
||||
// Update media usage tracking
|
||||
try {
|
||||
// Remove all existing usage for this project first
|
||||
await removeMediaUsage('project', id)
|
||||
|
||||
// Track all current media usage in the updated project
|
||||
const usageReferences: MediaUsageReference[] = []
|
||||
|
||||
// Track featured image
|
||||
const featuredImageIds = extractMediaIds(project, 'featuredImage')
|
||||
featuredImageIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
contentId: id,
|
||||
fieldName: 'featuredImage'
|
||||
})
|
||||
})
|
||||
|
||||
// Track logo
|
||||
const logoIds = extractMediaIds(project, 'logoUrl')
|
||||
logoIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
contentId: id,
|
||||
fieldName: 'logoUrl'
|
||||
})
|
||||
})
|
||||
|
||||
// Track gallery images
|
||||
const galleryIds = extractMediaIds(project, 'gallery')
|
||||
galleryIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
contentId: id,
|
||||
fieldName: 'gallery'
|
||||
})
|
||||
})
|
||||
|
||||
// Track media in case study content
|
||||
const contentIds = extractMediaIds(project, 'caseStudyContent')
|
||||
contentIds.forEach(mediaId => {
|
||||
usageReferences.push({
|
||||
mediaId,
|
||||
contentType: 'project',
|
||||
contentId: id,
|
||||
fieldName: 'content'
|
||||
})
|
||||
})
|
||||
|
||||
if (usageReferences.length > 0) {
|
||||
await trackMediaUsage(usageReferences)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to update media usage tracking for project', { projectId: id, error })
|
||||
}
|
||||
|
||||
logger.info('Project updated', { id: project.id, slug: project.slug })
|
||||
|
||||
return jsonResponse(project)
|
||||
|
|
@ -113,6 +174,10 @@ export const DELETE: RequestHandler = async (event) => {
|
|||
}
|
||||
|
||||
try {
|
||||
// Remove media usage tracking first
|
||||
await removeMediaUsage('project', id)
|
||||
|
||||
// Delete the project
|
||||
await prisma.project.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
|
|
|||
145
src/routes/api/universe/+server.ts
Normal file
145
src/routes/api/universe/+server.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
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'
|
||||
|
||||
export interface UniverseItem {
|
||||
id: number
|
||||
type: 'post' | 'album'
|
||||
slug: string
|
||||
title?: string
|
||||
excerpt?: string
|
||||
content?: any
|
||||
publishedAt: string
|
||||
createdAt: string
|
||||
|
||||
// Post-specific fields
|
||||
postType?: string
|
||||
linkUrl?: string
|
||||
linkDescription?: string
|
||||
attachments?: any
|
||||
|
||||
// Album-specific fields
|
||||
description?: string
|
||||
location?: string
|
||||
date?: string
|
||||
photosCount?: number
|
||||
coverPhoto?: any
|
||||
}
|
||||
|
||||
// GET /api/universe - Get mixed feed of published posts and albums
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
try {
|
||||
const url = new URL(event.request.url)
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20')
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0')
|
||||
|
||||
// Fetch published posts
|
||||
const posts = await prisma.post.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
publishedAt: { not: null }
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
postType: true,
|
||||
title: true,
|
||||
content: true,
|
||||
excerpt: true,
|
||||
linkUrl: true,
|
||||
linkDescription: true,
|
||||
attachments: true,
|
||||
publishedAt: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { publishedAt: 'desc' }
|
||||
})
|
||||
|
||||
// Fetch published albums marked for Universe
|
||||
const albums = await prisma.album.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
showInUniverse: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
description: true,
|
||||
date: true,
|
||||
location: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: { photos: true }
|
||||
},
|
||||
photos: {
|
||||
take: 1,
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
thumbnailUrl: true,
|
||||
caption: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
// Transform posts to universe items
|
||||
const postItems: UniverseItem[] = posts.map(post => ({
|
||||
id: post.id,
|
||||
type: 'post' as const,
|
||||
slug: post.slug,
|
||||
title: post.title || undefined,
|
||||
excerpt: post.excerpt || undefined,
|
||||
content: post.content,
|
||||
postType: post.postType,
|
||||
linkUrl: post.linkUrl || undefined,
|
||||
linkDescription: post.linkDescription || undefined,
|
||||
attachments: post.attachments,
|
||||
publishedAt: post.publishedAt!.toISOString(),
|
||||
createdAt: post.createdAt.toISOString()
|
||||
}))
|
||||
|
||||
// Transform albums to universe items
|
||||
const albumItems: UniverseItem[] = albums.map(album => ({
|
||||
id: album.id,
|
||||
type: 'album' as const,
|
||||
slug: album.slug,
|
||||
title: album.title,
|
||||
description: album.description || undefined,
|
||||
excerpt: album.description || undefined,
|
||||
location: album.location || undefined,
|
||||
date: album.date?.toISOString(),
|
||||
photosCount: album._count.photos,
|
||||
coverPhoto: album.photos[0] || null,
|
||||
publishedAt: album.createdAt.toISOString(), // Albums use createdAt as publishedAt
|
||||
createdAt: album.createdAt.toISOString()
|
||||
}))
|
||||
|
||||
// Combine and sort by publishedAt
|
||||
const allItems = [...postItems, ...albumItems]
|
||||
.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
|
||||
|
||||
// Apply pagination
|
||||
const paginatedItems = allItems.slice(offset, offset + limit)
|
||||
const hasMore = allItems.length > offset + limit
|
||||
|
||||
const response = {
|
||||
items: paginatedItems,
|
||||
pagination: {
|
||||
total: allItems.length,
|
||||
limit,
|
||||
offset,
|
||||
hasMore
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse(response)
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch universe feed', error as Error)
|
||||
return errorResponse('Failed to fetch universe feed', 500)
|
||||
}
|
||||
}
|
||||
|
|
@ -4,15 +4,28 @@
|
|||
|
||||
const { data }: { data: PageData } = $props()
|
||||
|
||||
const projects = $derived(data.projects)
|
||||
const projects = $derived(data.projects || [])
|
||||
const error = $derived(data.error)
|
||||
</script>
|
||||
|
||||
<div class="labs-container">
|
||||
<div class="projects-grid">
|
||||
{#each projects as project}
|
||||
<LabCard {project} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
<h2>Unable to load projects</h2>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
{:else if projects.length === 0}
|
||||
<div class="empty-message">
|
||||
<h2>No projects yet</h2>
|
||||
<p>Labs projects will appear here once published.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="projects-grid">
|
||||
{#each projects as project}
|
||||
<LabCard {project} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
@ -35,4 +48,27 @@
|
|||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message,
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
padding: $unit-6x $unit-3x;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: $grey-40;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message h2 {
|
||||
color: $red-60;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,56 +1,21 @@
|
|||
import type { PageLoad } from './$types'
|
||||
import type { LabProject } from '$lib/types/labs'
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
const projects: LabProject[] = [
|
||||
{
|
||||
id: 'granblue-team',
|
||||
title: 'granblue.team',
|
||||
description:
|
||||
'A comprehensive web application for Granblue Fantasy players to track raids, manage crews, and optimize team compositions. Features real-time raid tracking, character databases, and community tools.',
|
||||
status: 'active',
|
||||
technologies: [],
|
||||
url: 'https://granblue.team',
|
||||
github: 'https://github.com/jedmund/granblue-team',
|
||||
year: 2022,
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: 'subway-board',
|
||||
title: 'Subway Board',
|
||||
description:
|
||||
'A beautiful, minimalist dashboard displaying real-time NYC subway arrival times. Clean interface inspired by the classic subway map design with live MTA data integration.',
|
||||
status: 'maintenance',
|
||||
technologies: [],
|
||||
github: 'https://github.com/jedmund/subway-board',
|
||||
year: 2023,
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: 'siero-discord',
|
||||
title: 'Siero for Discord',
|
||||
description:
|
||||
'A Discord bot for Granblue Fantasy communities providing character lookups, raid notifications, and server management tools. Serves thousands of users across multiple servers.',
|
||||
status: 'active',
|
||||
technologies: [],
|
||||
github: 'https://github.com/jedmund/siero-bot',
|
||||
year: 2021,
|
||||
featured: true
|
||||
},
|
||||
{
|
||||
id: 'homelab',
|
||||
title: 'Homelab',
|
||||
description:
|
||||
'Self-hosted infrastructure running on Kubernetes with monitoring, media servers, and development environments. Includes automated deployments and backup strategies.',
|
||||
status: 'active',
|
||||
technologies: [],
|
||||
github: 'https://github.com/jedmund/homelab',
|
||||
year: 2023,
|
||||
featured: false
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
try {
|
||||
const response = await fetch('/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch labs projects')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return {
|
||||
projects: data.projects || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading labs projects:', error)
|
||||
return {
|
||||
projects: [],
|
||||
error: 'Failed to load projects'
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
projects
|
||||
}
|
||||
}
|
||||
|
|
|
|||
152
src/routes/labs/[slug]/+page.svelte
Normal file
152
src/routes/labs/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<script lang="ts">
|
||||
import Page from '$components/Page.svelte'
|
||||
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
|
||||
import ProjectContent from '$lib/components/ProjectContent.svelte'
|
||||
import type { PageData } from './$types'
|
||||
import type { Project } from '$lib/types/project'
|
||||
|
||||
let { data } = $props<{ data: PageData }>()
|
||||
|
||||
const project = $derived(data.project as Project | null)
|
||||
const error = $derived(data.error as string | undefined)
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<Page>
|
||||
<div slot="header" class="error-header">
|
||||
<h1>Error</h1>
|
||||
</div>
|
||||
<div class="error-content">
|
||||
<p>{error}</p>
|
||||
<a href="/labs" class="back-link">← Back to labs</a>
|
||||
</div>
|
||||
</Page>
|
||||
{:else if !project}
|
||||
<Page>
|
||||
<div class="loading">Loading project...</div>
|
||||
</Page>
|
||||
{:else if project.status === 'list-only'}
|
||||
<Page>
|
||||
<div slot="header" class="error-header">
|
||||
<h1>Project Not Available</h1>
|
||||
</div>
|
||||
<div class="error-content">
|
||||
<p>This project is not yet available for viewing. Please check back later.</p>
|
||||
<a href="/labs" class="back-link">← Back to labs</a>
|
||||
</div>
|
||||
</Page>
|
||||
{:else if project.status === 'password-protected'}
|
||||
<Page>
|
||||
<ProjectPasswordProtection projectSlug={project.slug} correctPassword={project.password || ''} projectType="labs">
|
||||
{#snippet children()}
|
||||
<div slot="header" class="project-header">
|
||||
{#if project.logoUrl}
|
||||
<div class="project-logo" style="background-color: {project.backgroundColor || '#f5f5f5'}">
|
||||
<img src={project.logoUrl} alt="{project.title} logo" />
|
||||
</div>
|
||||
{/if}
|
||||
<h1 class="project-title">{project.title}</h1>
|
||||
{#if project.subtitle}
|
||||
<p class="project-subtitle">{project.subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<ProjectContent {project} />
|
||||
{/snippet}
|
||||
</ProjectPasswordProtection>
|
||||
</Page>
|
||||
{:else}
|
||||
<Page>
|
||||
<div slot="header" class="project-header">
|
||||
{#if project.logoUrl}
|
||||
<div class="project-logo" style="background-color: {project.backgroundColor || '#f5f5f5'}">
|
||||
<img src={project.logoUrl} alt="{project.title} logo" />
|
||||
</div>
|
||||
{/if}
|
||||
<h1 class="project-title">{project.title}</h1>
|
||||
{#if project.subtitle}
|
||||
<p class="project-subtitle">{project.subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<ProjectContent {project} />
|
||||
</Page>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
/* Error and Loading States */
|
||||
.error-header h1 {
|
||||
color: $red-60;
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
color: $grey-40;
|
||||
margin-bottom: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: $grey-40;
|
||||
padding: $unit-4x;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
/* Project Header */
|
||||
.project-header {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-logo {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto $unit-2x;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $unit-2x;
|
||||
padding: $unit-2x;
|
||||
box-sizing: border-box;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 $unit;
|
||||
color: $grey-10;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.project-subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: $grey-40;
|
||||
margin: 0;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
34
src/routes/labs/[slug]/+page.ts
Normal file
34
src/routes/labs/[slug]/+page.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { PageLoad } from './$types'
|
||||
import type { Project } from '$lib/types/project'
|
||||
|
||||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
try {
|
||||
// Find project by slug - we'll fetch all published, list-only, and password-protected projects
|
||||
const response = await fetch(`/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch projects')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const project = data.projects.find((p: Project) => p.slug === params.slug)
|
||||
|
||||
if (!project) {
|
||||
throw new Error('Project not found')
|
||||
}
|
||||
|
||||
// Handle different project statuses
|
||||
if (project.status === 'draft') {
|
||||
throw new Error('Project not found')
|
||||
}
|
||||
|
||||
return {
|
||||
project
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading project:', error)
|
||||
return {
|
||||
project: null,
|
||||
error: error instanceof Error ? error.message : 'Failed to load project'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,13 +4,72 @@
|
|||
|
||||
const { data }: { data: PageData } = $props()
|
||||
|
||||
const photoItems = $derived(data.photoItems)
|
||||
const photoItems = $derived(data.photoItems || [])
|
||||
const error = $derived(data.error)
|
||||
</script>
|
||||
|
||||
<PhotoGrid {photoItems} />
|
||||
<div class="photos-page">
|
||||
{#if error}
|
||||
<div class="error-container">
|
||||
<div class="error-message">
|
||||
<h2>Unable to load photos</h2>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if photoItems.length === 0}
|
||||
<div class="empty-container">
|
||||
<div class="empty-message">
|
||||
<h2>No photos yet</h2>
|
||||
<p>Photography albums will appear here once published.</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<PhotoGrid {photoItems} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
:global(main) {
|
||||
padding: 0;
|
||||
.photos-page {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: $unit-4x $unit-3x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.error-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.error-message,
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: $grey-40;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
h2 {
|
||||
color: $red-60;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,413 +1,25 @@
|
|||
import type { PageLoad } from './$types'
|
||||
import type { PhotoItem } from '$lib/types/photos'
|
||||
|
||||
export const load: PageLoad = async () => {
|
||||
// Mock data for now - in a real app this would come from an API or CMS
|
||||
const photoItems: PhotoItem[] = [
|
||||
{
|
||||
id: 'photo-1',
|
||||
src: 'https://picsum.photos/400/600?random=1',
|
||||
alt: 'Mountain landscape at sunset',
|
||||
caption: 'A beautiful landscape captured during golden hour',
|
||||
width: 400,
|
||||
height: 600,
|
||||
exif: {
|
||||
camera: 'Canon EOS R5',
|
||||
lens: '24-70mm f/2.8',
|
||||
focalLength: '35mm',
|
||||
aperture: 'f/5.6',
|
||||
shutterSpeed: '1/250s',
|
||||
iso: '100',
|
||||
dateTaken: '2024-01-15',
|
||||
location: 'Yosemite National Park'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'album-1',
|
||||
title: 'Tokyo Street Photography',
|
||||
description: 'A collection of street photography from Tokyo',
|
||||
coverPhoto: {
|
||||
id: 'album-1-cover',
|
||||
src: 'https://picsum.photos/500/400?random=2',
|
||||
alt: 'Tokyo street scene',
|
||||
width: 500,
|
||||
height: 400
|
||||
},
|
||||
photos: [
|
||||
{
|
||||
id: 'album-1-1',
|
||||
src: 'https://picsum.photos/500/400?random=2',
|
||||
alt: 'Tokyo street scene',
|
||||
caption: 'Busy intersection in Shibuya',
|
||||
width: 500,
|
||||
height: 400,
|
||||
exif: {
|
||||
camera: 'Fujifilm X-T4',
|
||||
lens: '23mm f/1.4',
|
||||
focalLength: '23mm',
|
||||
aperture: 'f/2.8',
|
||||
shutterSpeed: '1/60s',
|
||||
iso: '800',
|
||||
dateTaken: '2024-02-10'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'album-1-2',
|
||||
src: 'https://picsum.photos/600/400?random=25',
|
||||
alt: 'Tokyo alley',
|
||||
caption: 'Quiet alley in Shinjuku',
|
||||
width: 600,
|
||||
height: 400
|
||||
},
|
||||
{
|
||||
id: 'album-1-3',
|
||||
src: 'https://picsum.photos/500/700?random=26',
|
||||
alt: 'Neon signs',
|
||||
caption: 'Colorful neon signs in Harajuku',
|
||||
width: 500,
|
||||
height: 700,
|
||||
exif: {
|
||||
camera: 'Fujifilm X-T4',
|
||||
lens: '56mm f/1.2',
|
||||
focalLength: '56mm',
|
||||
aperture: 'f/1.8',
|
||||
shutterSpeed: '1/30s',
|
||||
iso: '1600',
|
||||
dateTaken: '2024-02-11'
|
||||
}
|
||||
}
|
||||
],
|
||||
createdAt: '2024-02-10'
|
||||
},
|
||||
{
|
||||
id: 'photo-2',
|
||||
src: 'https://picsum.photos/300/500?random=4',
|
||||
alt: 'Modern building',
|
||||
caption: 'Urban architecture study',
|
||||
width: 300,
|
||||
height: 500,
|
||||
exif: {
|
||||
camera: 'Sony A7IV',
|
||||
lens: '85mm f/1.8',
|
||||
focalLength: '85mm',
|
||||
aperture: 'f/2.8',
|
||||
shutterSpeed: '1/125s',
|
||||
iso: '200',
|
||||
dateTaken: '2024-01-20'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'photo-3',
|
||||
src: 'https://picsum.photos/600/300?random=5',
|
||||
alt: 'Ocean waves',
|
||||
caption: 'Minimalist seascape composition',
|
||||
width: 600,
|
||||
height: 300
|
||||
},
|
||||
{
|
||||
id: 'photo-4',
|
||||
src: 'https://picsum.photos/400/500?random=6',
|
||||
alt: 'Forest path',
|
||||
caption: 'Morning light through the trees',
|
||||
width: 400,
|
||||
height: 500,
|
||||
exif: {
|
||||
camera: 'Nikon Z6II',
|
||||
lens: '24-120mm f/4',
|
||||
focalLength: '50mm',
|
||||
aperture: 'f/8',
|
||||
shutterSpeed: '1/60s',
|
||||
iso: '400',
|
||||
dateTaken: '2024-03-05',
|
||||
location: 'Redwood National Park'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'album-2',
|
||||
title: 'Portrait Series',
|
||||
description: 'A collection of environmental portraits',
|
||||
coverPhoto: {
|
||||
id: 'album-2-cover',
|
||||
src: 'https://picsum.photos/400/600?random=7',
|
||||
alt: 'Portrait of a musician',
|
||||
width: 400,
|
||||
height: 600
|
||||
},
|
||||
photos: [
|
||||
{
|
||||
id: 'album-2-1',
|
||||
src: 'https://picsum.photos/400/600?random=7',
|
||||
alt: 'Portrait of a musician',
|
||||
caption: 'Jazz musician in his studio',
|
||||
width: 400,
|
||||
height: 600,
|
||||
exif: {
|
||||
camera: 'Canon EOS R6',
|
||||
lens: '85mm f/1.2',
|
||||
focalLength: '85mm',
|
||||
aperture: 'f/1.8',
|
||||
shutterSpeed: '1/125s',
|
||||
iso: '640',
|
||||
dateTaken: '2024-02-20'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'album-2-2',
|
||||
src: 'https://picsum.photos/500/650?random=27',
|
||||
alt: 'Artist in gallery',
|
||||
caption: 'Painter surrounded by her work',
|
||||
width: 500,
|
||||
height: 650
|
||||
},
|
||||
{
|
||||
id: 'album-2-3',
|
||||
src: 'https://picsum.photos/450/600?random=28',
|
||||
alt: 'Chef in kitchen',
|
||||
caption: 'Chef preparing for evening service',
|
||||
width: 450,
|
||||
height: 600
|
||||
}
|
||||
],
|
||||
createdAt: '2024-02-20'
|
||||
},
|
||||
{
|
||||
id: 'photo-5',
|
||||
src: 'https://picsum.photos/500/350?random=8',
|
||||
alt: 'City skyline',
|
||||
caption: 'Downtown at blue hour',
|
||||
width: 500,
|
||||
height: 350,
|
||||
exif: {
|
||||
camera: 'Sony A7R V',
|
||||
lens: '16-35mm f/2.8',
|
||||
focalLength: '24mm',
|
||||
aperture: 'f/11',
|
||||
shutterSpeed: '8s',
|
||||
iso: '100',
|
||||
dateTaken: '2024-01-30',
|
||||
location: 'San Francisco'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'photo-6',
|
||||
src: 'https://picsum.photos/350/550?random=9',
|
||||
alt: 'Vintage motorcycle',
|
||||
caption: 'Classic bike restoration project',
|
||||
width: 350,
|
||||
height: 550
|
||||
},
|
||||
{
|
||||
id: 'photo-7',
|
||||
src: 'https://picsum.photos/450/300?random=10',
|
||||
alt: 'Coffee and books',
|
||||
caption: 'Quiet morning ritual',
|
||||
width: 450,
|
||||
height: 300,
|
||||
exif: {
|
||||
camera: 'Fujifilm X100V',
|
||||
lens: '23mm f/2',
|
||||
focalLength: '23mm',
|
||||
aperture: 'f/2.8',
|
||||
shutterSpeed: '1/60s',
|
||||
iso: '320',
|
||||
dateTaken: '2024-03-01'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'album-3',
|
||||
title: 'Nature Macro',
|
||||
description: 'Close-up studies of natural details',
|
||||
coverPhoto: {
|
||||
id: 'album-3-cover',
|
||||
src: 'https://picsum.photos/400/400?random=11',
|
||||
alt: 'Dewdrop on leaf',
|
||||
width: 400,
|
||||
height: 400
|
||||
},
|
||||
photos: [
|
||||
{
|
||||
id: 'album-3-1',
|
||||
src: 'https://picsum.photos/400/400?random=11',
|
||||
alt: 'Dewdrop on leaf',
|
||||
caption: 'Morning dew captured with macro lens',
|
||||
width: 400,
|
||||
height: 400,
|
||||
exif: {
|
||||
camera: 'Canon EOS R5',
|
||||
lens: '100mm f/2.8 Macro',
|
||||
focalLength: '100mm',
|
||||
aperture: 'f/5.6',
|
||||
shutterSpeed: '1/250s',
|
||||
iso: '200',
|
||||
dateTaken: '2024-03-15'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'album-3-2',
|
||||
src: 'https://picsum.photos/500/500?random=29',
|
||||
alt: 'Butterfly wing detail',
|
||||
caption: 'Intricate patterns on butterfly wing',
|
||||
width: 500,
|
||||
height: 500
|
||||
},
|
||||
{
|
||||
id: 'album-3-3',
|
||||
src: 'https://picsum.photos/400/600?random=30',
|
||||
alt: 'Tree bark texture',
|
||||
caption: 'Ancient oak bark patterns',
|
||||
width: 400,
|
||||
height: 600
|
||||
},
|
||||
{
|
||||
id: 'album-3-4',
|
||||
src: 'https://picsum.photos/350/500?random=31',
|
||||
alt: 'Flower stamen',
|
||||
caption: 'Lily stamen in soft light',
|
||||
width: 350,
|
||||
height: 500
|
||||
}
|
||||
],
|
||||
createdAt: '2024-03-15'
|
||||
},
|
||||
{
|
||||
id: 'photo-8',
|
||||
src: 'https://picsum.photos/600/400?random=12',
|
||||
alt: 'Desert landscape',
|
||||
caption: 'Vast desert under starry sky',
|
||||
width: 600,
|
||||
height: 400,
|
||||
exif: {
|
||||
camera: 'Nikon D850',
|
||||
lens: '14-24mm f/2.8',
|
||||
focalLength: '14mm',
|
||||
aperture: 'f/2.8',
|
||||
shutterSpeed: '25s',
|
||||
iso: '3200',
|
||||
dateTaken: '2024-02-25',
|
||||
location: 'Death Valley'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'photo-9',
|
||||
src: 'https://picsum.photos/300/450?random=13',
|
||||
alt: 'Vintage camera',
|
||||
caption: "My grandfather's Leica",
|
||||
width: 300,
|
||||
height: 450
|
||||
},
|
||||
{
|
||||
id: 'photo-10',
|
||||
src: 'https://picsum.photos/550/350?random=14',
|
||||
alt: 'Market scene',
|
||||
caption: 'Colorful spices at local market',
|
||||
width: 550,
|
||||
height: 350,
|
||||
exif: {
|
||||
camera: 'Fujifilm X-T5',
|
||||
lens: '18-55mm f/2.8-4',
|
||||
focalLength: '35mm',
|
||||
aperture: 'f/4',
|
||||
shutterSpeed: '1/125s',
|
||||
iso: '800',
|
||||
dateTaken: '2024-03-10',
|
||||
location: 'Marrakech, Morocco'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'photo-11',
|
||||
src: 'https://picsum.photos/400/600?random=15',
|
||||
alt: 'Lighthouse at dawn',
|
||||
caption: 'Coastal beacon in morning mist',
|
||||
width: 400,
|
||||
height: 600,
|
||||
exif: {
|
||||
camera: 'Sony A7III',
|
||||
lens: '70-200mm f/2.8',
|
||||
focalLength: '135mm',
|
||||
aperture: 'f/8',
|
||||
shutterSpeed: '1/200s',
|
||||
iso: '400',
|
||||
dateTaken: '2024-02-28',
|
||||
location: 'Big Sur, California'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'photo-12',
|
||||
src: 'https://picsum.photos/500/300?random=16',
|
||||
alt: 'Train station',
|
||||
caption: 'Rush hour commuters',
|
||||
width: 500,
|
||||
height: 300
|
||||
},
|
||||
{
|
||||
id: 'album-4',
|
||||
title: 'Black & White',
|
||||
description: 'Monochrome photography collection',
|
||||
coverPhoto: {
|
||||
id: 'album-4-cover',
|
||||
src: 'https://picsum.photos/450/600?random=17',
|
||||
alt: 'Urban shadows',
|
||||
width: 450,
|
||||
height: 600
|
||||
},
|
||||
photos: [
|
||||
{
|
||||
id: 'album-4-1',
|
||||
src: 'https://picsum.photos/450/600?random=17',
|
||||
alt: 'Urban shadows',
|
||||
caption: 'Dramatic shadows in the financial district',
|
||||
width: 450,
|
||||
height: 600,
|
||||
exif: {
|
||||
camera: 'Leica M11 Monochrom',
|
||||
lens: '35mm f/1.4',
|
||||
focalLength: '35mm',
|
||||
aperture: 'f/5.6',
|
||||
shutterSpeed: '1/250s',
|
||||
iso: '200',
|
||||
dateTaken: '2024-03-20'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'album-4-2',
|
||||
src: 'https://picsum.photos/600/400?random=32',
|
||||
alt: 'Elderly man reading',
|
||||
caption: 'Contemplation in the park',
|
||||
width: 600,
|
||||
height: 400
|
||||
},
|
||||
{
|
||||
id: 'album-4-3',
|
||||
src: 'https://picsum.photos/400/500?random=33',
|
||||
alt: 'Rain on window',
|
||||
caption: 'Storm patterns on glass',
|
||||
width: 400,
|
||||
height: 500
|
||||
}
|
||||
],
|
||||
createdAt: '2024-03-20'
|
||||
},
|
||||
{
|
||||
id: 'photo-13',
|
||||
src: 'https://picsum.photos/350/500?random=18',
|
||||
alt: 'Street art mural',
|
||||
caption: 'Vibrant wall art in the Mission District',
|
||||
width: 350,
|
||||
height: 500,
|
||||
exif: {
|
||||
camera: 'iPhone 15 Pro',
|
||||
lens: '24mm f/1.8',
|
||||
focalLength: '24mm',
|
||||
aperture: 'f/1.8',
|
||||
shutterSpeed: '1/120s',
|
||||
iso: '64',
|
||||
dateTaken: '2024-03-25',
|
||||
location: 'San Francisco'
|
||||
}
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
try {
|
||||
const response = await fetch('/api/photos?limit=50')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch photos')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return {
|
||||
photoItems: data.photoItems || [],
|
||||
pagination: data.pagination || null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading photos:', error)
|
||||
|
||||
// Fallback to empty array if API fails
|
||||
return {
|
||||
photoItems: [],
|
||||
pagination: null,
|
||||
error: 'Failed to load photos'
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
photoItems
|
||||
}
|
||||
}
|
||||
}
|
||||
471
src/routes/photos/[albumSlug]/[photoId]/+page.svelte
Normal file
471
src/routes/photos/[albumSlug]/[photoId]/+page.svelte
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
const photo = $derived(data.photo)
|
||||
const album = $derived(data.album)
|
||||
const navigation = $derived(data.navigation)
|
||||
const error = $derived(data.error)
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const formatExif = (exifData: any) => {
|
||||
if (!exifData) return null
|
||||
|
||||
const formatSpeed = (speed: string) => {
|
||||
if (speed?.includes('/')) return speed
|
||||
if (speed?.includes('s')) return speed
|
||||
return speed ? `1/${speed}s` : null
|
||||
}
|
||||
|
||||
return {
|
||||
camera: exifData.camera,
|
||||
lens: exifData.lens,
|
||||
settings: [
|
||||
exifData.focalLength,
|
||||
exifData.aperture,
|
||||
formatSpeed(exifData.shutterSpeed),
|
||||
exifData.iso ? `ISO ${exifData.iso}` : null
|
||||
].filter(Boolean).join(' • '),
|
||||
location: exifData.location,
|
||||
dateTaken: exifData.dateTaken
|
||||
}
|
||||
}
|
||||
|
||||
const exif = $derived(photo ? formatExif(photo.exifData) : null)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if photo && album}
|
||||
<title>{photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}</title>
|
||||
<meta name="description" content={photo.description || photo.caption || `Photo from ${album.title}`} />
|
||||
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:title" content="{photo.title || photo.caption || `Photo ${navigation?.currentIndex}`} - {album.title}" />
|
||||
<meta property="og:description" content={photo.description || photo.caption || `Photo from ${album.title}`} />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:image" content={photo.url} />
|
||||
|
||||
<!-- Article meta -->
|
||||
<meta property="article:author" content="jedmund" />
|
||||
{#if exif?.dateTaken}
|
||||
<meta property="article:published_time" content={exif.dateTaken} />
|
||||
{/if}
|
||||
{:else}
|
||||
<title>Photo Not Found</title>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
{#if error || !photo || !album}
|
||||
<div class="error-container">
|
||||
<div class="error-content">
|
||||
<h1>Photo Not Found</h1>
|
||||
<p>{error || 'The photo you\'re looking for doesn\'t exist.'}</p>
|
||||
<a href="/photos" class="back-link">← Back to Photos</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="photo-page">
|
||||
<!-- Navigation Header -->
|
||||
<header class="photo-header">
|
||||
<nav class="breadcrumb">
|
||||
<a href="/photos">Photos</a>
|
||||
<span class="separator">→</span>
|
||||
<a href="/photos/{album.slug}">{album.title}</a>
|
||||
<span class="separator">→</span>
|
||||
<span class="current">Photo {navigation.currentIndex} of {navigation.totalCount}</span>
|
||||
</nav>
|
||||
|
||||
<div class="photo-nav">
|
||||
{#if navigation.prevPhoto}
|
||||
<a href="/photos/{album.slug}/{navigation.prevPhoto.id}" class="nav-btn prev">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Previous
|
||||
</a>
|
||||
{:else}
|
||||
<div class="nav-btn disabled">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M12.5 15L7.5 10L12.5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Previous
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if navigation.nextPhoto}
|
||||
<a href="/photos/{album.slug}/{navigation.nextPhoto.id}" class="nav-btn next">
|
||||
Next
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M7.5 5L12.5 10L7.5 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="nav-btn disabled">
|
||||
Next
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M7.5 5L12.5 10L7.5 15" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Photo Display -->
|
||||
<main class="photo-main">
|
||||
<div class="photo-container">
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.caption || photo.title || 'Photo'}
|
||||
class="main-photo"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Photo Details -->
|
||||
<aside class="photo-details">
|
||||
<div class="details-content">
|
||||
{#if photo.title}
|
||||
<h1 class="photo-title">{photo.title}</h1>
|
||||
{/if}
|
||||
|
||||
{#if photo.caption}
|
||||
<p class="photo-caption">{photo.caption}</p>
|
||||
{/if}
|
||||
|
||||
{#if photo.description}
|
||||
<p class="photo-description">{photo.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if exif}
|
||||
<div class="photo-exif">
|
||||
<h3>Photo Details</h3>
|
||||
|
||||
{#if exif.camera}
|
||||
<div class="exif-item">
|
||||
<span class="label">Camera</span>
|
||||
<span class="value">{exif.camera}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exif.lens}
|
||||
<div class="exif-item">
|
||||
<span class="label">Lens</span>
|
||||
<span class="value">{exif.lens}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exif.settings}
|
||||
<div class="exif-item">
|
||||
<span class="label">Settings</span>
|
||||
<span class="value">{exif.settings}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exif.location}
|
||||
<div class="exif-item">
|
||||
<span class="label">Location</span>
|
||||
<span class="value">{exif.location}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if exif.dateTaken}
|
||||
<div class="exif-item">
|
||||
<span class="label">Date Taken</span>
|
||||
<span class="value">{formatDate(exif.dateTaken)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="photo-actions">
|
||||
<a href="/photos/{album.slug}" class="back-to-album">← Back to {album.title}</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
:global(main) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
padding: $unit-6x $unit-3x;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $red-60;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-40;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"header header"
|
||||
"main details";
|
||||
grid-template-columns: 1fr 400px;
|
||||
grid-template-rows: auto 1fr;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
grid-template-areas:
|
||||
"header"
|
||||
"main"
|
||||
"details";
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-header {
|
||||
grid-area: header;
|
||||
background: $grey-100;
|
||||
border-bottom: 1px solid $grey-90;
|
||||
padding: $unit-3x $unit-4x;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-2x;
|
||||
flex-direction: column;
|
||||
gap: $unit-2x;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
|
||||
a {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 $unit;
|
||||
}
|
||||
|
||||
.current {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-nav {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
padding: $unit $unit-2x;
|
||||
border-radius: $unit;
|
||||
border: 1px solid $grey-85;
|
||||
background: $grey-100;
|
||||
color: $grey-20;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
border-color: $grey-70;
|
||||
background: $grey-95;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&.prev svg {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
&.next svg {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-main {
|
||||
grid-area: main;
|
||||
background: $grey-95;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $unit-4x;
|
||||
min-height: 60vh;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-container {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main-photo {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: $unit;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
object-fit: contain;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
max-height: 60vh;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-details {
|
||||
grid-area: details;
|
||||
background: $grey-100;
|
||||
border-left: 1px solid $grey-90;
|
||||
overflow-y: auto;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
border-left: none;
|
||||
border-top: 1px solid $grey-90;
|
||||
}
|
||||
}
|
||||
|
||||
.details-content {
|
||||
padding: $unit-4x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
.photo-caption {
|
||||
font-size: 1.125rem;
|
||||
color: $grey-20;
|
||||
margin: 0 0 $unit-3x;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.photo-description {
|
||||
font-size: 1rem;
|
||||
color: $grey-30;
|
||||
margin: 0 0 $unit-4x;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.photo-exif {
|
||||
margin-bottom: $unit-4x;
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.exif-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $unit;
|
||||
gap: $unit-2x;
|
||||
|
||||
.label {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-50;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-20;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-actions {
|
||||
padding-top: $unit-3x;
|
||||
border-top: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
.back-to-album {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
30
src/routes/photos/[albumSlug]/[photoId]/+page.ts
Normal file
30
src/routes/photos/[albumSlug]/[photoId]/+page.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import type { PageLoad } from './$types'
|
||||
|
||||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
try {
|
||||
const response = await fetch(`/api/photos/${params.albumSlug}/${params.photoId}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error('Photo not found')
|
||||
}
|
||||
throw new Error('Failed to fetch photo')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
return {
|
||||
photo: data.photo,
|
||||
album: data.album,
|
||||
navigation: data.navigation
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading photo:', error)
|
||||
return {
|
||||
photo: null,
|
||||
album: null,
|
||||
navigation: null,
|
||||
error: error instanceof Error ? error.message : 'Failed to load photo'
|
||||
}
|
||||
}
|
||||
}
|
||||
223
src/routes/photos/[slug]/+page.svelte
Normal file
223
src/routes/photos/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
<script lang="ts">
|
||||
import PhotoGrid from '$components/PhotoGrid.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
const album = $derived(data.album)
|
||||
const error = $derived(data.error)
|
||||
|
||||
// Transform album data to PhotoItem format for PhotoGrid
|
||||
const photoItems = $derived(album?.photos?.map((photo: any) => ({
|
||||
id: `photo-${photo.id}`,
|
||||
src: photo.url,
|
||||
alt: photo.caption || photo.filename,
|
||||
caption: photo.caption,
|
||||
width: photo.width || 400,
|
||||
height: photo.height || 400
|
||||
})) ?? [])
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if album}
|
||||
<title>{album.title} - Photos</title>
|
||||
<meta name="description" content={album.description || `Photo album: ${album.title}`} />
|
||||
{:else}
|
||||
<title>Album Not Found - Photos</title>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
{#if error}
|
||||
<div class="error-container">
|
||||
<div class="error-message">
|
||||
<h1>Album Not Found</h1>
|
||||
<p>{error}</p>
|
||||
<a href="/photos" class="back-link">← Back to Photos</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else if album}
|
||||
<div class="album-page">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb">
|
||||
<a href="/photos">Photos</a>
|
||||
<span class="separator">→</span>
|
||||
<span class="current">{album.title}</span>
|
||||
</nav>
|
||||
|
||||
<!-- Album Card -->
|
||||
<div class="album-card">
|
||||
<h1 class="album-title">{album.title}</h1>
|
||||
|
||||
{#if album.description}
|
||||
<p class="album-description">{album.description}</p>
|
||||
{/if}
|
||||
|
||||
<div class="album-meta">
|
||||
{#if album.date}
|
||||
<span class="meta-item">📅 {formatDate(album.date)}</span>
|
||||
{/if}
|
||||
{#if album.location}
|
||||
<span class="meta-item">📍 {album.location}</span>
|
||||
{/if}
|
||||
<span class="meta-item">📷 {album.photos?.length || 0} photo{(album.photos?.length || 0) !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Grid -->
|
||||
{#if photoItems.length > 0}
|
||||
<PhotoGrid photoItems={photoItems} albumSlug={album.slug} />
|
||||
{:else}
|
||||
<div class="empty-album">
|
||||
<p>This album doesn't contain any photos yet.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.error-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
padding: $unit-6x $unit-3x;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $red-60;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-40;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
.album-page {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: $unit-4x $unit-3x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-bottom: $unit-4x;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
|
||||
a {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 $unit;
|
||||
}
|
||||
|
||||
.current {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
.album-card {
|
||||
background: $grey-100;
|
||||
border: 1px solid $grey-90;
|
||||
border-radius: $card-corner-radius;
|
||||
padding: $unit-6x;
|
||||
margin-bottom: $unit-6x;
|
||||
text-align: center;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-4x $unit-3x;
|
||||
margin-bottom: $unit-4x;
|
||||
}
|
||||
}
|
||||
|
||||
.album-title {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $grey-10;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.album-description {
|
||||
font-size: 1.125rem;
|
||||
color: $grey-30;
|
||||
margin: 0 0 $unit-4x;
|
||||
line-height: 1.5;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
font-size: 1rem;
|
||||
margin-bottom: $unit-3x;
|
||||
}
|
||||
}
|
||||
|
||||
.album-meta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: $unit-3x;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.meta-item {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
gap: $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-album {
|
||||
text-align: center;
|
||||
padding: $unit-6x $unit-3x;
|
||||
color: $grey-40;
|
||||
}
|
||||
</style>
|
||||
31
src/routes/photos/[slug]/+page.ts
Normal file
31
src/routes/photos/[slug]/+page.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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')
|
||||
}
|
||||
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')
|
||||
}
|
||||
|
||||
return {
|
||||
album
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading album:', error)
|
||||
return {
|
||||
album: null,
|
||||
error: error instanceof Error ? error.message : 'Failed to load album'
|
||||
}
|
||||
}
|
||||
}
|
||||
227
src/routes/rss/+server.ts
Normal file
227
src/routes/rss/+server.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
// Helper function to escape XML special characters
|
||||
function escapeXML(str: string): string {
|
||||
if (!str) return ''
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// Helper function to convert content to HTML for full content
|
||||
function convertContentToHTML(content: any): string {
|
||||
if (!content || !content.blocks) return ''
|
||||
|
||||
return content.blocks
|
||||
.map((block: any) => {
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return `<p>${escapeXML(block.content || '')}</p>`
|
||||
case 'heading':
|
||||
const level = block.level || 2
|
||||
return `<h${level}>${escapeXML(block.content || '')}</h${level}>`
|
||||
case 'list':
|
||||
const items = (block.content || []).map((item: any) => `<li>${escapeXML(item)}</li>`).join('')
|
||||
return block.listType === 'ordered' ? `<ol>${items}</ol>` : `<ul>${items}</ul>`
|
||||
default:
|
||||
return `<p>${escapeXML(block.content || '')}</p>`
|
||||
}
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// Helper function to extract text summary from content
|
||||
function extractTextSummary(content: any, maxLength: number = 300): string {
|
||||
if (!content || !content.blocks) return ''
|
||||
|
||||
const text = content.blocks
|
||||
.filter((block: any) => block.type === 'paragraph' && block.content)
|
||||
.map((block: any) => block.content)
|
||||
.join(' ')
|
||||
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text
|
||||
}
|
||||
|
||||
// Helper function to format RFC 822 date
|
||||
function formatRFC822Date(date: Date): string {
|
||||
return date.toUTCString()
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
try {
|
||||
// Get published posts from Universe
|
||||
const posts = await prisma.post.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
publishedAt: { not: null }
|
||||
},
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: 25
|
||||
})
|
||||
|
||||
// Get published albums that show in universe
|
||||
const universeAlbums = await prisma.album.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
showInUniverse: true
|
||||
},
|
||||
include: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
},
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
take: 1 // Get first photo for cover image
|
||||
},
|
||||
_count: {
|
||||
select: { photos: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 15
|
||||
})
|
||||
|
||||
// Get published photography albums
|
||||
const photoAlbums = await prisma.album.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
isPhotography: true
|
||||
},
|
||||
include: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
},
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
take: 1 // Get first photo for cover image
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 15
|
||||
})
|
||||
|
||||
// Combine all content types
|
||||
const items = [
|
||||
...posts.map(post => ({
|
||||
type: 'post',
|
||||
section: 'universe',
|
||||
id: post.id.toString(),
|
||||
title: post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`,
|
||||
description: post.excerpt || extractTextSummary(post.content) || '',
|
||||
content: convertContentToHTML(post.content),
|
||||
link: `${event.url.origin}/universe/${post.slug}`,
|
||||
guid: `${event.url.origin}/universe/${post.slug}`,
|
||||
pubDate: post.publishedAt || post.createdAt,
|
||||
updatedDate: post.updatedAt,
|
||||
postType: post.postType,
|
||||
linkUrl: post.linkUrl || null
|
||||
})),
|
||||
...universeAlbums.map(album => ({
|
||||
type: 'album',
|
||||
section: 'universe',
|
||||
id: album.id.toString(),
|
||||
title: album.title,
|
||||
description: album.description || `Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
||||
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
|
||||
link: `${event.url.origin}/photos/${album.slug}`,
|
||||
guid: `${event.url.origin}/photos/${album.slug}`,
|
||||
pubDate: album.createdAt,
|
||||
updatedDate: album.updatedAt,
|
||||
photoCount: album._count.photos,
|
||||
coverPhoto: album.photos[0],
|
||||
location: album.location
|
||||
})),
|
||||
...photoAlbums
|
||||
.filter(album => !universeAlbums.some(ua => ua.id === album.id)) // Avoid duplicates
|
||||
.map(album => ({
|
||||
type: 'album',
|
||||
section: 'photos',
|
||||
id: album.id.toString(),
|
||||
title: album.title,
|
||||
description: album.description || `Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
||||
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
|
||||
link: `${event.url.origin}/photos/${album.slug}`,
|
||||
guid: `${event.url.origin}/photos/${album.slug}`,
|
||||
pubDate: album.createdAt,
|
||||
updatedDate: album.updatedAt,
|
||||
photoCount: album._count.photos,
|
||||
coverPhoto: album.photos[0],
|
||||
location: album.location,
|
||||
date: album.date
|
||||
}))
|
||||
].sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime())
|
||||
|
||||
const now = new Date()
|
||||
const lastBuildDate = formatRFC822Date(now)
|
||||
|
||||
// Build RSS XML following best practices
|
||||
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<title>jedmund.com</title>
|
||||
<description>Creative work, thoughts, and photography by Justin Edmund</description>
|
||||
<link>${event.url.origin}/</link>
|
||||
<atom:link href="${event.url.origin}/rss" rel="self" type="application/rss+xml"/>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
<managingEditor>noreply@jedmund.com (Justin Edmund)</managingEditor>
|
||||
<webMaster>noreply@jedmund.com (Justin Edmund)</webMaster>
|
||||
<generator>SvelteKit RSS Generator</generator>
|
||||
<docs>https://cyber.harvard.edu/rss/rss.html</docs>
|
||||
<ttl>60</ttl>
|
||||
${items.map(item => `
|
||||
<item>
|
||||
<title>${escapeXML(item.title)}</title>
|
||||
<description><![CDATA[${item.description}]]></description>
|
||||
${item.content ? `<content:encoded><![CDATA[${item.content}]]></content:encoded>` : ''}
|
||||
<link>${item.link}</link>
|
||||
<guid isPermaLink="true">${item.guid}</guid>
|
||||
<pubDate>${formatRFC822Date(new Date(item.pubDate))}</pubDate>
|
||||
${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}</atom:updated>` : ''}
|
||||
<category>${item.section}</category>
|
||||
<category>${item.type === 'post' ? item.postType : 'album'}</category>
|
||||
${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>` : ''}
|
||||
${item.type === 'album' && item.coverPhoto ? `
|
||||
<enclosure url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg" length="0"/>
|
||||
<media:thumbnail url="${event.url.origin}${item.coverPhoto.thumbnailUrl || item.coverPhoto.url}"/>
|
||||
<media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>` : ''}
|
||||
${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''}
|
||||
<author>noreply@jedmund.com (Justin Edmund)</author>
|
||||
</item>`).join('')}
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
logger.info('Combined RSS feed generated', { itemCount: items.length })
|
||||
|
||||
return new Response(rssXml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
||||
'Last-Modified': lastBuildDate,
|
||||
'ETag': `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Vary': 'Accept-Encoding'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate combined RSS feed', error as Error)
|
||||
return new Response('Failed to generate RSS feed', { status: 500 })
|
||||
}
|
||||
}
|
||||
154
src/routes/rss/photos/+server.ts
Normal file
154
src/routes/rss/photos/+server.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
// Helper function to escape XML special characters
|
||||
function escapeXML(str: string): string {
|
||||
if (!str) return ''
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// Helper function to format RFC 822 date
|
||||
function formatRFC822Date(date: Date): string {
|
||||
return date.toUTCString()
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
try {
|
||||
// Get published photography albums
|
||||
const albums = await prisma.album.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
isPhotography: true
|
||||
},
|
||||
include: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
},
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
take: 1 // Get first photo for cover image
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
photos: {
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50 // Limit to most recent 50 albums
|
||||
})
|
||||
|
||||
// Get individual published photos not in albums
|
||||
const standalonePhotos = await prisma.photo.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
showInPhotos: true,
|
||||
albumId: null
|
||||
},
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: 25
|
||||
})
|
||||
|
||||
// Combine albums and standalone photos
|
||||
const items = [
|
||||
...albums.map(album => ({
|
||||
type: 'album',
|
||||
id: album.id.toString(),
|
||||
title: album.title,
|
||||
description: album.description || `Photography album${album.location ? ` from ${album.location}` : ''} with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
||||
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
|
||||
link: `${event.url.origin}/photos/${album.slug}`,
|
||||
pubDate: album.createdAt,
|
||||
updatedDate: album.updatedAt,
|
||||
guid: `${event.url.origin}/photos/${album.slug}`,
|
||||
photoCount: album._count.photos,
|
||||
coverPhoto: album.photos[0],
|
||||
location: album.location,
|
||||
date: album.date
|
||||
})),
|
||||
...standalonePhotos.map(photo => ({
|
||||
type: 'photo',
|
||||
id: photo.id.toString(),
|
||||
title: photo.title || photo.filename,
|
||||
description: photo.description || photo.caption || `Photo: ${photo.filename}`,
|
||||
content: photo.description ? `<p>${escapeXML(photo.description)}</p>` : (photo.caption ? `<p>${escapeXML(photo.caption)}</p>` : ''),
|
||||
link: `${event.url.origin}/photos/photo/${photo.slug || photo.id}`,
|
||||
pubDate: photo.publishedAt || photo.createdAt,
|
||||
updatedDate: photo.updatedAt,
|
||||
guid: `${event.url.origin}/photos/photo/${photo.slug || photo.id}`,
|
||||
url: photo.url,
|
||||
thumbnailUrl: photo.thumbnailUrl
|
||||
}))
|
||||
].sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime())
|
||||
|
||||
const now = new Date()
|
||||
const lastBuildDate = formatRFC822Date(now)
|
||||
|
||||
// Build RSS XML following best practices
|
||||
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<title>Photos - jedmund.com</title>
|
||||
<description>Photography and visual content from jedmund</description>
|
||||
<link>${event.url.origin}/photos</link>
|
||||
<atom:link href="${event.url.origin}/rss/photos" rel="self" type="application/rss+xml"/>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
<managingEditor>noreply@jedmund.com (Justin Edmund)</managingEditor>
|
||||
<webMaster>noreply@jedmund.com (Justin Edmund)</webMaster>
|
||||
<generator>SvelteKit RSS Generator</generator>
|
||||
<docs>https://cyber.harvard.edu/rss/rss.html</docs>
|
||||
<ttl>60</ttl>
|
||||
${items.map(item => `
|
||||
<item>
|
||||
<title>${escapeXML(item.title)}</title>
|
||||
<description><![CDATA[${item.description}]]></description>
|
||||
${item.content ? `<content:encoded><![CDATA[${item.content}]]></content:encoded>` : ''}
|
||||
<link>${item.link}</link>
|
||||
<guid isPermaLink="true">${item.guid}</guid>
|
||||
<pubDate>${formatRFC822Date(new Date(item.pubDate))}</pubDate>
|
||||
${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}</atom:updated>` : ''}
|
||||
<category>${item.type}</category>
|
||||
${item.type === 'album' && item.coverPhoto ? `
|
||||
<enclosure url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg" length="0"/>
|
||||
<media:thumbnail url="${event.url.origin}${item.coverPhoto.thumbnailUrl || item.coverPhoto.url}"/>
|
||||
<media:content url="${event.url.origin}${item.coverPhoto.url}" type="image/jpeg"/>` : ''}
|
||||
${item.type === 'photo' ? `
|
||||
<enclosure url="${event.url.origin}${item.url}" type="image/jpeg" length="0"/>
|
||||
<media:thumbnail url="${event.url.origin}${item.thumbnailUrl || item.url}"/>
|
||||
<media:content url="${event.url.origin}${item.url}" type="image/jpeg"/>` : ''}
|
||||
${item.location ? `<category domain="location">${escapeXML(item.location)}</category>` : ''}
|
||||
<author>noreply@jedmund.com (Justin Edmund)</author>
|
||||
</item>`).join('')}
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
logger.info('Photos RSS feed generated', { itemCount: items.length })
|
||||
|
||||
return new Response(rssXml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
||||
'Last-Modified': lastBuildDate,
|
||||
'ETag': `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Vary': 'Accept-Encoding'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate Photos RSS feed', error as Error)
|
||||
return new Response('Failed to generate RSS feed', { status: 500 })
|
||||
}
|
||||
}
|
||||
161
src/routes/rss/universe/+server.ts
Normal file
161
src/routes/rss/universe/+server.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import type { RequestHandler } from './$types'
|
||||
import { prisma } from '$lib/server/database'
|
||||
import { logger } from '$lib/server/logger'
|
||||
|
||||
// Helper function to escape XML special characters
|
||||
function escapeXML(str: string): string {
|
||||
if (!str) return ''
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
// Helper function to convert content to HTML for full content
|
||||
function convertContentToHTML(content: any): string {
|
||||
if (!content || !content.blocks) return ''
|
||||
|
||||
return content.blocks
|
||||
.map((block: any) => {
|
||||
switch (block.type) {
|
||||
case 'paragraph':
|
||||
return `<p>${escapeXML(block.content || '')}</p>`
|
||||
case 'heading':
|
||||
const level = block.level || 2
|
||||
return `<h${level}>${escapeXML(block.content || '')}</h${level}>`
|
||||
case 'list':
|
||||
const items = (block.content || []).map((item: any) => `<li>${escapeXML(item)}</li>`).join('')
|
||||
return block.listType === 'ordered' ? `<ol>${items}</ol>` : `<ul>${items}</ul>`
|
||||
default:
|
||||
return `<p>${escapeXML(block.content || '')}</p>`
|
||||
}
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// Helper function to extract text summary from content
|
||||
function extractTextSummary(content: any, maxLength: number = 300): string {
|
||||
if (!content || !content.blocks) return ''
|
||||
|
||||
const text = content.blocks
|
||||
.filter((block: any) => block.type === 'paragraph' && block.content)
|
||||
.map((block: any) => block.content)
|
||||
.join(' ')
|
||||
|
||||
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text
|
||||
}
|
||||
|
||||
// Helper function to format RFC 822 date
|
||||
function formatRFC822Date(date: Date): string {
|
||||
return date.toUTCString()
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async (event) => {
|
||||
try {
|
||||
// Get published posts from Universe
|
||||
const posts = await prisma.post.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
publishedAt: { not: null }
|
||||
},
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: 50 // Limit to most recent 50 posts
|
||||
})
|
||||
|
||||
// Get published albums that show in universe
|
||||
const albums = await prisma.album.findMany({
|
||||
where: {
|
||||
status: 'published',
|
||||
showInUniverse: true
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { photos: true }
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 25 // Limit to most recent 25 albums
|
||||
})
|
||||
|
||||
// Combine and sort by date
|
||||
const items = [
|
||||
...posts.map(post => ({
|
||||
type: 'post',
|
||||
id: post.id.toString(),
|
||||
title: post.title || `${post.postType.charAt(0).toUpperCase() + post.postType.slice(1)} Post`,
|
||||
description: post.excerpt || extractTextSummary(post.content) || '',
|
||||
content: convertContentToHTML(post.content),
|
||||
link: `${event.url.origin}/universe/${post.slug}`,
|
||||
guid: `${event.url.origin}/universe/${post.slug}`,
|
||||
pubDate: post.publishedAt || post.createdAt,
|
||||
updatedDate: post.updatedAt,
|
||||
postType: post.postType,
|
||||
linkUrl: post.linkUrl || null
|
||||
})),
|
||||
...albums.map(album => ({
|
||||
type: 'album',
|
||||
id: album.id.toString(),
|
||||
title: album.title,
|
||||
description: album.description || `Photo album with ${album._count.photos} photo${album._count.photos !== 1 ? 's' : ''}`,
|
||||
content: album.description ? `<p>${escapeXML(album.description)}</p>` : '',
|
||||
link: `${event.url.origin}/photos/${album.slug}`,
|
||||
guid: `${event.url.origin}/photos/${album.slug}`,
|
||||
pubDate: album.createdAt,
|
||||
updatedDate: album.updatedAt,
|
||||
photoCount: album._count.photos
|
||||
}))
|
||||
].sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime())
|
||||
|
||||
const now = new Date()
|
||||
const lastBuildDate = formatRFC822Date(now)
|
||||
|
||||
// Build RSS XML following best practices
|
||||
const rssXml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
||||
<channel>
|
||||
<title>Universe - jedmund.com</title>
|
||||
<description>Posts and photo albums from jedmund's universe</description>
|
||||
<link>${event.url.origin}/universe</link>
|
||||
<atom:link href="${event.url.origin}/rss/universe" rel="self" type="application/rss+xml"/>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
<managingEditor>noreply@jedmund.com (Justin Edmund)</managingEditor>
|
||||
<webMaster>noreply@jedmund.com (Justin Edmund)</webMaster>
|
||||
<generator>SvelteKit RSS Generator</generator>
|
||||
<docs>https://cyber.harvard.edu/rss/rss.html</docs>
|
||||
<ttl>60</ttl>
|
||||
${items.map(item => `
|
||||
<item>
|
||||
<title>${escapeXML(item.title)}</title>
|
||||
<description><![CDATA[${item.description}]]></description>
|
||||
${item.content ? `<content:encoded><![CDATA[${item.content}]]></content:encoded>` : ''}
|
||||
<link>${item.link}</link>
|
||||
<guid isPermaLink="true">${item.guid}</guid>
|
||||
<pubDate>${formatRFC822Date(new Date(item.pubDate))}</pubDate>
|
||||
${item.updatedDate ? `<atom:updated>${new Date(item.updatedDate).toISOString()}</atom:updated>` : ''}
|
||||
<category>${item.type === 'post' ? item.postType : 'album'}</category>
|
||||
${item.type === 'post' && item.linkUrl ? `<comments>${item.linkUrl}</comments>` : ''}
|
||||
<author>noreply@jedmund.com (Justin Edmund)</author>
|
||||
</item>`).join('')}
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
logger.info('Universe RSS feed generated', { itemCount: items.length })
|
||||
|
||||
return new Response(rssXml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/rss+xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
|
||||
'Last-Modified': lastBuildDate,
|
||||
'ETag': `"${Buffer.from(rssXml).toString('base64').slice(0, 16)}"`,
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Vary': 'Accept-Encoding'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate Universe RSS feed', error as Error)
|
||||
return new Response('Failed to generate RSS feed', { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,23 @@
|
|||
import { getAllPosts } from '$lib/posts'
|
||||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const posts = await getAllPosts()
|
||||
|
||||
return {
|
||||
posts
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
try {
|
||||
const response = await fetch('/api/universe?limit=20')
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch universe feed')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return {
|
||||
universeItems: data.items || [],
|
||||
pagination: data.pagination || null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading universe feed:', error)
|
||||
return {
|
||||
universeItems: [],
|
||||
pagination: null,
|
||||
error: 'Failed to load universe feed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,27 @@
|
|||
<script lang="ts">
|
||||
import Page from '$components/Page.svelte'
|
||||
import PostList from '$components/PostList.svelte'
|
||||
import UniverseFeed from '$components/UniverseFeed.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Blog - jedmund</title>
|
||||
<meta name="description" content="Thoughts on design, development, and everything in between." />
|
||||
<title>Universe - jedmund</title>
|
||||
<meta name="description" content="A mixed feed of posts, thoughts, and photo albums." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="blog-container">
|
||||
<PostList posts={data.posts} />
|
||||
<div class="universe-container">
|
||||
{#if data.error}
|
||||
<div class="error-message">
|
||||
<p>{data.error}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<UniverseFeed items={data.universeItems || []} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.blog-container {
|
||||
.universe-container {
|
||||
max-width: 784px;
|
||||
margin: 0 auto;
|
||||
padding: 0 $unit-5x;
|
||||
|
|
@ -31,4 +36,18 @@
|
|||
padding: 0 $unit-2x;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
padding: $unit-6x $unit-3x;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: $unit-2x;
|
||||
color: #dc2626;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,30 @@
|
|||
import { getPostBySlug } from '$lib/posts'
|
||||
import { error } from '@sveltejs/kit'
|
||||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const post = await getPostBySlug(params.slug)
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
try {
|
||||
// Fetch the specific post by slug from the database
|
||||
const response = await fetch(`/api/posts/by-slug/${params.slug}`)
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
post: null,
|
||||
error: 'Post not found'
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to fetch post')
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
throw error(404, 'Post not found')
|
||||
}
|
||||
const post = await response.json()
|
||||
|
||||
return {
|
||||
post
|
||||
return {
|
||||
post
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading post:', error)
|
||||
return {
|
||||
post: null,
|
||||
error: 'Failed to load post'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,89 @@
|
|||
<script lang="ts">
|
||||
import Page from '$components/Page.svelte'
|
||||
import PostContent from '$components/PostContent.svelte'
|
||||
import DynamicPostContent from '$components/DynamicPostContent.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
let { data }: { data: PageData } = $props()
|
||||
|
||||
const pageTitle = data.post.title || 'Blog post'
|
||||
const post = $derived(data.post)
|
||||
const error = $derived(data.error)
|
||||
const pageTitle = $derived(post?.title || 'Post')
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{pageTitle} - jedmund</title>
|
||||
<meta name="description" content={data.post.excerpt} />
|
||||
{#if post}
|
||||
<title>{pageTitle} - jedmund</title>
|
||||
<meta name="description" content={post.excerpt || `${post.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`} />
|
||||
|
||||
<!-- Open Graph meta tags -->
|
||||
<meta property="og:title" content={pageTitle} />
|
||||
<meta property="og:description" content={post.excerpt || `${post.postType === 'essay' ? 'Essay' : 'Post'} by jedmund`} />
|
||||
<meta property="og:type" content="article" />
|
||||
{#if post.attachments && post.attachments.length > 0}
|
||||
<meta property="og:image" content={post.attachments[0].url} />
|
||||
{/if}
|
||||
|
||||
<!-- Article meta -->
|
||||
<meta property="article:published_time" content={post.publishedAt} />
|
||||
<meta property="article:author" content="jedmund" />
|
||||
{:else}
|
||||
<title>Post Not Found - jedmund</title>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PostContent post={data.post} />
|
||||
</Page>
|
||||
{#if error || !post}
|
||||
<Page>
|
||||
<div class="error-container">
|
||||
<div class="error-content">
|
||||
<h1>Post Not Found</h1>
|
||||
<p>{error || 'The post you\'re looking for doesn\'t exist.'}</p>
|
||||
<a href="/universe" class="back-link">← Back to Universe</a>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
{:else}
|
||||
<Page>
|
||||
<DynamicPostContent {post} />
|
||||
</Page>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.error-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
padding: $unit-6x $unit-3x;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 $unit-2x;
|
||||
color: $red-60;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-40;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: $red-60;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-style: wavy;
|
||||
text-underline-offset: 0.15em;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<script lang="ts">
|
||||
import Page from '$components/Page.svelte'
|
||||
import ProjectPasswordProtection from '$lib/components/ProjectPasswordProtection.svelte'
|
||||
import ProjectContent from '$lib/components/ProjectContent.svelte'
|
||||
import type { PageData } from './$types'
|
||||
import type { Project } from '$lib/types/project'
|
||||
|
||||
|
|
@ -7,46 +9,6 @@
|
|||
|
||||
const project = $derived(data.project as Project | null)
|
||||
const error = $derived(data.error as string | undefined)
|
||||
|
||||
// Temporary function to render BlockNote content as HTML
|
||||
// This is a basic implementation - you might want to use a proper BlockNote renderer
|
||||
function renderBlockNoteContent(content: any): string {
|
||||
if (!content || !content.content) return ''
|
||||
|
||||
return content.content
|
||||
.map((block: any) => {
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
const level = block.attrs?.level || 1
|
||||
const text = block.content?.[0]?.text || ''
|
||||
return `<h${level}>${text}</h${level}>`
|
||||
|
||||
case 'paragraph':
|
||||
if (!block.content || block.content.length === 0) return '<p><br></p>'
|
||||
const paragraphText = block.content.map((c: any) => c.text || '').join('')
|
||||
return `<p>${paragraphText}</p>`
|
||||
|
||||
case 'image':
|
||||
return `<figure><img src="${block.attrs?.src}" alt="${block.attrs?.alt || ''}" style="width: ${block.attrs?.width || '100%'}; height: ${block.attrs?.height || 'auto'};" /></figure>`
|
||||
|
||||
case 'bulletedList':
|
||||
case 'numberedList':
|
||||
const tag = block.type === 'bulletedList' ? 'ul' : 'ol'
|
||||
const items =
|
||||
block.content
|
||||
?.map((item: any) => {
|
||||
const itemText = item.content?.[0]?.content?.[0]?.text || ''
|
||||
return `<li>${itemText}</li>`
|
||||
})
|
||||
.join('') || ''
|
||||
return `<${tag}>${items}</${tag}>`
|
||||
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
|
|
@ -63,6 +25,35 @@
|
|||
<Page>
|
||||
<div class="loading">Loading project...</div>
|
||||
</Page>
|
||||
{:else if project.status === 'list-only'}
|
||||
<Page>
|
||||
<div slot="header" class="error-header">
|
||||
<h1>Project Not Available</h1>
|
||||
</div>
|
||||
<div class="error-content">
|
||||
<p>This project is not yet available for viewing. Please check back later.</p>
|
||||
<a href="/" class="back-link">← Back to projects</a>
|
||||
</div>
|
||||
</Page>
|
||||
{:else if project.status === 'password-protected'}
|
||||
<Page>
|
||||
<ProjectPasswordProtection projectSlug={project.slug} correctPassword={project.password || ''} projectType="work">
|
||||
{#snippet children()}
|
||||
<div slot="header" class="project-header">
|
||||
{#if project.logoUrl}
|
||||
<div class="project-logo" style="background-color: {project.backgroundColor || '#f5f5f5'}">
|
||||
<img src={project.logoUrl} alt="{project.title} logo" />
|
||||
</div>
|
||||
{/if}
|
||||
<h1 class="project-title">{project.title}</h1>
|
||||
{#if project.subtitle}
|
||||
<p class="project-subtitle">{project.subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<ProjectContent {project} />
|
||||
{/snippet}
|
||||
</ProjectPasswordProtection>
|
||||
</Page>
|
||||
{:else}
|
||||
<Page>
|
||||
<div slot="header" class="project-header">
|
||||
|
|
@ -76,81 +67,7 @@
|
|||
<p class="project-subtitle">{project.subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<article class="project-content">
|
||||
<!-- Project Details -->
|
||||
<div class="project-details">
|
||||
<div class="meta-grid">
|
||||
{#if project.client}
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Client</span>
|
||||
<span class="meta-value">{project.client}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if project.year}
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Year</span>
|
||||
<span class="meta-value">{project.year}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if project.role}
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Role</span>
|
||||
<span class="meta-value">{project.role}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if project.technologies && project.technologies.length > 0}
|
||||
<div class="technologies">
|
||||
{#each project.technologies as tech}
|
||||
<span class="tech-tag">{tech}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if project.externalUrl}
|
||||
<div class="external-link-wrapper">
|
||||
<a
|
||||
href={project.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="external-link"
|
||||
>
|
||||
Visit Project →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Case Study Content -->
|
||||
{#if project.caseStudyContent && project.caseStudyContent.content && project.caseStudyContent.content.length > 0}
|
||||
<div class="case-study-section">
|
||||
<div class="case-study-content">
|
||||
{@html renderBlockNoteContent(project.caseStudyContent)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Gallery (if available) -->
|
||||
{#if project.gallery && project.gallery.length > 0}
|
||||
<div class="gallery-section">
|
||||
<h2>Gallery</h2>
|
||||
<div class="gallery-grid">
|
||||
{#each project.gallery as image}
|
||||
<img src={image} alt="Project gallery image" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="project-nav">
|
||||
<a href="/" class="back-link">← Back to projects</a>
|
||||
</nav>
|
||||
</article>
|
||||
<ProjectContent {project} />
|
||||
</Page>
|
||||
{/if}
|
||||
|
||||
|
|
@ -177,6 +94,17 @@
|
|||
padding: $unit-4x;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
/* Project Header */
|
||||
.project-header {
|
||||
text-align: center;
|
||||
|
|
@ -221,182 +149,4 @@
|
|||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Project Content */
|
||||
.project-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
}
|
||||
|
||||
.project-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
padding-bottom: $unit-3x;
|
||||
border-bottom: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: $unit-2x;
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-60;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 1rem;
|
||||
color: $grey-20;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.technologies {
|
||||
display: flex;
|
||||
gap: $unit;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.tech-tag {
|
||||
padding: $unit $unit-2x;
|
||||
background: $grey-95;
|
||||
border-radius: 50px;
|
||||
font-size: 0.875rem;
|
||||
color: $grey-30;
|
||||
}
|
||||
}
|
||||
|
||||
.external-link-wrapper {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.external-link {
|
||||
display: inline-block;
|
||||
padding: $unit-2x $unit-3x;
|
||||
background: $grey-10;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 50px;
|
||||
font-weight: 500;
|
||||
font-size: 0.925rem;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
/* Case Study Section */
|
||||
.case-study-section {
|
||||
// No extra styling needed, content flows naturally
|
||||
}
|
||||
|
||||
.case-study-content {
|
||||
:global(h1),
|
||||
:global(h2),
|
||||
:global(h3) {
|
||||
margin: $unit-3x 0 $unit-2x;
|
||||
color: $grey-10;
|
||||
font-weight: 600;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(h1) {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
:global(h2) {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
:global(h3) {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
:global(p) {
|
||||
margin: $unit-2x 0;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.65;
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
:global(figure) {
|
||||
margin: $unit-3x 0;
|
||||
|
||||
:global(img) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
:global(ul),
|
||||
:global(ol) {
|
||||
margin: $unit-2x 0;
|
||||
padding-left: $unit-3x;
|
||||
|
||||
:global(li) {
|
||||
margin: $unit 0;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.65;
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Gallery Section */
|
||||
.gallery-section {
|
||||
padding-top: $unit-3x;
|
||||
border-top: 1px solid $grey-90;
|
||||
|
||||
h2 {
|
||||
font-size: 1.75rem;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: $unit-2x;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.project-nav {
|
||||
text-align: center;
|
||||
padding-top: $unit-3x;
|
||||
border-top: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import type { Project } from '$lib/types/project'
|
|||
|
||||
export const load: PageLoad = async ({ params, fetch }) => {
|
||||
try {
|
||||
// Find project by slug
|
||||
const response = await fetch(`/api/projects?status=published`)
|
||||
// Find project by slug - we'll fetch all published, list-only, and password-protected projects
|
||||
const response = await fetch(`/api/projects?includeListOnly=true&includePasswordProtected=true`)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch projects')
|
||||
}
|
||||
|
|
@ -16,6 +16,11 @@ export const load: PageLoad = async ({ params, fetch }) => {
|
|||
throw new Error('Project not found')
|
||||
}
|
||||
|
||||
// Handle different project statuses
|
||||
if (project.status === 'draft') {
|
||||
throw new Error('Project not found')
|
||||
}
|
||||
|
||||
return {
|
||||
project
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue