big update

This commit is contained in:
Justin Edmund 2025-06-01 23:48:10 -07:00
parent b314be59f4
commit 2f504abb57
86 changed files with 10275 additions and 1557 deletions

View file

@ -47,22 +47,17 @@ CREATE TABLE projects (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- Posts table (for /universe) -- Posts table (for /universe) - Simplified to 2 types
CREATE TABLE posts ( CREATE TABLE posts (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
slug VARCHAR(255) UNIQUE NOT NULL, slug VARCHAR(255) UNIQUE NOT NULL,
post_type VARCHAR(50) NOT NULL, -- blog, microblog, link, photo, album post_type VARCHAR(50) NOT NULL, -- 'post' or 'essay'
title VARCHAR(255), -- Optional for microblog posts title VARCHAR(255), -- Required for essays, optional for posts
content JSONB, -- Edra JSON for blog/microblog, optional for others content JSONB, -- Edra JSON content
excerpt TEXT, excerpt TEXT, -- For essays
-- 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
featured_image VARCHAR(500), featured_image VARCHAR(500),
attachments JSONB, -- Array of media IDs for any attachments
tags JSONB, -- Array of tags tags JSONB, -- Array of tags
status VARCHAR(50) DEFAULT 'draft', status VARCHAR(50) DEFAULT 'draft',
published_at TIMESTAMP, published_at TIMESTAMP,
@ -70,7 +65,7 @@ CREATE TABLE posts (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- Albums table -- Albums table - Enhanced with photography curation
CREATE TABLE albums ( CREATE TABLE albums (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
slug VARCHAR(255) UNIQUE NOT NULL, slug VARCHAR(255) UNIQUE NOT NULL,
@ -79,6 +74,7 @@ CREATE TABLE albums (
date DATE, date DATE,
location VARCHAR(255), location VARCHAR(255),
cover_photo_id INTEGER REFERENCES photos(id), cover_photo_id INTEGER REFERENCES photos(id),
is_photography BOOLEAN DEFAULT false, -- Show in photos experience
status VARCHAR(50) DEFAULT 'draft', status VARCHAR(50) DEFAULT 'draft',
show_in_universe BOOLEAN DEFAULT false, show_in_universe BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@ -109,17 +105,35 @@ CREATE TABLE photos (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
-- Media table (general uploads) -- Media table (general uploads) - Enhanced with photography curation
CREATE TABLE media ( CREATE TABLE media (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL, filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255), -- Original filename from user
mime_type VARCHAR(100) NOT NULL, mime_type VARCHAR(100) NOT NULL,
size INTEGER NOT NULL, size INTEGER NOT NULL,
url TEXT NOT NULL, url TEXT NOT NULL,
thumbnail_url TEXT, thumbnail_url TEXT,
width INTEGER, width INTEGER,
height 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 ```sql
-- Add content associations to media table -- MediaUsage tracks where each media file is used
ALTER TABLE media ADD COLUMN used_in JSONB DEFAULT '[]'; -- Replaces the simple used_in JSONB field with proper relational tracking
-- Example: [{ "type": "post", "id": 1 }, { "type": "project", "id": 3 }] -- 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 ### 3. Content Type Editors
- **Projects**: Form-based editor with: - **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**: - **Features**:
- Grid view of all uploaded media - Grid and list view modes for uploaded media
- Search by filename - Search by filename and filter by type (image/video/audio/pdf)
- Filter by type (image/video) - Usage information showing where each media is used
- Filter by usage (unused/used) - Alt text editing and accessibility features
- Upload new files - Upload new files directly from modal
- Select existing media - Single and multi-select functionality
- **Returns**: Media object with ID and URLs - **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 ### 6. Image Processing Pipeline
1. **Upload**: User drops/selects image 1. **Upload**: User drops/selects image
@ -252,45 +287,58 @@ const ImageBlock = {
```typescript ```typescript
// Projects // Projects
GET / api / projects GET /api/projects
POST / api / projects POST /api/projects
GET / api / projects / [slug] GET /api/projects/[slug]
PUT / api / projects / [id] PUT /api/projects/[id]
DELETE / api / projects / [id] DELETE /api/projects/[id]
// Posts // Posts
GET / api / posts GET /api/posts
POST / api / posts POST /api/posts
GET / api / posts / [slug] GET /api/posts/[slug]
PUT / api / posts / [id] PUT /api/posts/[id]
DELETE / api / posts / [id] DELETE /api/posts/[id]
// Albums & Photos // Albums & Photos
GET / api / albums GET /api/albums
POST / api / albums POST /api/albums
GET / api / albums / [slug] GET /api/albums/[slug]
PUT / api / albums / [id] PUT /api/albums/[id]
DELETE / api / albums / [id] DELETE /api/albums/[id]
POST / api / albums / [id] / photos POST /api/albums/[id]/photos
DELETE / api / photos / [id] DELETE /api/photos/[id]
PUT / api / photos / [id] / order PUT /api/photos/[id]/order
// Media upload // Media Management
POST / api / media / upload POST /api/media/upload // Single file upload
POST / api / media / bulk - upload POST /api/media/bulk-upload // Multiple file upload
GET / api / media // Browse with filters GET /api/media // Browse with filters, pagination
DELETE / api / media / [id] // Delete if unused GET /api/media/[id] // Get single media item
GET / api / media / [id] / usage // Check where media is used 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 ### 8. Media Management & Cleanup
#### Orphaned Media Prevention #### Advanced Usage Tracking
- **Reference Tracking**: `used_in` field tracks all content using each media item - **MediaUsage Table**: Dedicated table for precise tracking of media usage
- **On Save**: Update media associations when content is saved - **Automatic Tracking**: All content saves automatically update usage references
- **On Delete**: Remove associations when content is deleted - **Field-Level Tracking**: Tracks specific fields (featuredImage, gallery, content, attachments)
- **Cleanup Task**: Periodic job to identify truly orphaned media - **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 #### Edra Integration Details
@ -322,13 +370,19 @@ const handleImageUpload = async (file) => {
### 9. Admin Interface ### 9. Admin Interface
- **Route**: `/admin` (completely separate from public routes) - **Route**: `/admin` (completely separate from public routes)
- **Dashboard**: Overview of all content types - **Dashboard**: Overview of all content types with quick stats
- **Content Lists**: - **Content Lists**:
- Projects with preview thumbnails - Projects with preview thumbnails and status indicators
- Posts with publish status - Posts with publish status and type badges
- Albums with photo counts - Albums with photo counts and metadata
- **Content Editors**: Type-specific editing interfaces - **Content Editors**: Type-specific editing interfaces with rich text support
- **Media Library**: Browse all uploaded files - **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 ### 10. Public Display Integration
@ -462,60 +516,93 @@ Based on requirements discussion:
4. **Project Templates**: Defer case study layout templates for later phase 4. **Project Templates**: Defer case study layout templates for later phase
5. **Scheduled Publishing**: Not needed initially 5. **Scheduled Publishing**: Not needed initially
6. **RSS Feeds**: Required for all content types (projects, posts, photos) 6. **RSS Feeds**: Required for all content types (projects, posts, photos)
7. **Post Types**: Universe will support multiple post types: 7. **Post Types**: Simplified to two main types:
- **Blog Post**: Title + long-form Edra content - **Post**: Simple content with optional attachments (replaces microblog, link, photo posts)
- **Microblog**: No title, short-form Edra content - **Essay**: Full editor with title/metadata + optional attachments (replaces blog posts)
- **Link Post**: URL + optional commentary 8. **Albums & Photo Curation**: Albums serve dual purposes:
- **Photo Post**: Single photo + caption - **Regular Albums**: Collections for case studies, UI galleries, design process
- **Album Post**: Reference to photo album - **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 ### Completed
- ✅ Database setup with Prisma and PostgreSQL - ✅ Database setup with Prisma and PostgreSQL
- ✅ Media management system with Cloudinary integration - ✅ Media management system with Cloudinary integration
- ✅ Admin foundation (layout, navigation, auth, forms, data tables) - ✅ Admin foundation (layout, navigation, auth, forms, data tables)
- ✅ Edra rich text editor integration for case studies - ✅ Edra rich text editor integration for case studies and posts
- ✅ Edra image uploads configured to use media API - ✅ Edra image and gallery extensions with MediaLibraryModal integration
- ✅ Local development mode for media uploads (no Cloudinary usage) - ✅ Local development mode for media uploads (no Cloudinary usage)
- ✅ Project CRUD system with metadata fields and enhanced schema - ✅ Project CRUD system with metadata fields and enhanced schema
- ✅ Project list view in admin with enhanced UI - ✅ Project list view in admin with enhanced UI
- ✅ Project forms with branding (logo, colors) and styling - ✅ Project forms with branding (logo, colors) and styling
- ✅ Posts CRUD system with all post types (blog, microblog, link, photo, album) - ✅ 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 - ✅ 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 - ✅ Media API endpoints with upload, bulk upload, and usage tracking
- ✅ Component library for admin interface (buttons, inputs, modals, etc.) - ✅ 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 ### In Progress
- 🔄 Albums/Photos System - Schema implemented, UI components needed - 🔄 Content Simplification & Photo Curation System
### Next Steps ### Next Steps
1. **Media Library System** (Critical dependency for other features) 1. **Content Model Updates** (Immediate Priority)
- Media library modal component - Add `isPhotography` field to Media and Album tables via migration
- Integration with existing media APIs - Simplify post types to just "post" and "essay"
- Search and filter functionality within media browser - 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 - Bulk photo upload interface with progress
- Photo ordering within albums - Photo ordering within albums
- Album cover selection - Album cover selection
- EXIF data extraction and display - EXIF data extraction and display
- Photography album filtering and management
3. **Enhanced Content Features** 3. **Enhanced Content Features**
- Photo/album post selectors using media library - Featured image picker for projects (using MediaLibraryModal)
- Featured image picker for projects
- Technology tag selector for projects - Technology tag selector for projects
- Auto-save functionality for all editors - 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 ## Phased Implementation Plan
@ -542,10 +629,14 @@ Based on requirements discussion:
- [x] Create media upload endpoint with Cloudinary integration - [x] Create media upload endpoint with Cloudinary integration
- [x] Implement image processing pipeline (multiple sizes) - [x] Implement image processing pipeline (multiple sizes)
- [x] Build media library API endpoints - [x] Build media library API endpoints with pagination and filtering
- [x] Create media association tracking system - [x] Create advanced MediaUsage tracking system
- [x] Add bulk upload endpoint for photos - [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 ### Phase 3: Admin Foundation
@ -556,21 +647,22 @@ Based on requirements discussion:
- [x] Build data table component for list views - [x] Build data table component for list views
- [x] Add loading and error states - [x] Add loading and error states
- [x] Create comprehensive admin UI component library - [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) ### Phase 4: Posts System (All Types)
- [x] Create Edra Svelte wrapper component - [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] Build post type selector UI
- [x] Create blog/microblog post editor - [x] Create blog/microblog post editor
- [x] Build link post form - [x] Build link post form
- [x] Create posts list view in admin - [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] 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 - [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) - [ ] Build album post selector (needs albums system)
- [ ] Add auto-save functionality - [ ] Add auto-save functionality
@ -578,40 +670,56 @@ Based on requirements discussion:
- [x] Build project form with all metadata fields - [x] Build project form with all metadata fields
- [x] Enhanced schema with branding fields (logo, colors) - [x] Enhanced schema with branding fields (logo, colors)
- [x] Project branding and styling forms - [x] Project branding and styling forms with ImageUploader and GalleryUploader
- [x] Add optional Edra editor for case studies - [x] Add optional Edra editor for case studies with media support
- [x] Create project CRUD APIs - [x] Create project CRUD APIs with usage tracking
- [x] Build project list view with enhanced UI - [x] Build project list view with enhanced UI
- [x] Integrate Browse Library functionality in project forms
- [ ] Create technology tag selector - [ ] Create technology tag selector
- [ ] Implement featured image picker (needs media library modal)
- [ ] Build gallery manager with drag-and-drop ordering - [ ] Build gallery manager with drag-and-drop ordering
- [ ] Add project ordering functionality - [ ] 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] Complete database schema for albums and photos
- [x] Photo/album CRUD API endpoints (albums endpoint exists) - [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 - [ ] Build bulk photo uploader with progress
- [ ] Implement EXIF data extraction for photos - [ ] Implement EXIF data extraction for photos
- [ ] Implement drag-and-drop photo ordering
- [ ] Add individual photo publishing UI - [ ] Add individual photo publishing UI
- [ ] Build photo metadata editor - [ ] Build photo metadata editor
- [ ] Implement album cover selection - [ ] Add photography album filtering and management
- [ ] Add "show in universe" toggle for albums - [ ] Add "show in universe" toggle for albums
### Phase 7: Public Display Updates ### Phase 8: Public Display Updates
- [ ] Replace static Work page with dynamic data - [x] Replace static Work page with dynamic data
- [ ] Update project detail pages - [x] Update project detail pages
- [ ] Build Universe mixed feed component - [x] Build Universe mixed feed component
- [ ] Create different card types for each post type - [x] Create different card types for each post type
- [ ] Update Photos page with dynamic albums/photos - [x] Update Photos page with dynamic albums/photos
- [ ] Implement individual photo pages - [x] Implement individual photo pages
- [ ] Add Universe post detail pages - [x] Add Universe post detail pages
- [ ] Ensure responsive design throughout - [ ] Ensure responsive design throughout
### Phase 8: RSS Feeds & Final Polish ### Phase 9: RSS Feeds & Final Polish
- [ ] Implement RSS feed for projects - [ ] Implement RSS feed for projects
- [ ] Create RSS feed for Universe posts - [ ] Create RSS feed for Universe posts
@ -622,7 +730,7 @@ Based on requirements discussion:
- [ ] Add search functionality to admin - [ ] Add search functionality to admin
- [ ] Performance optimization pass - [ ] Performance optimization pass
### Phase 9: Production Deployment ### Phase 10: Production Deployment
- [ ] Set up PostgreSQL on Railway - [ ] Set up PostgreSQL on Railway
- [ ] Run migrations on production database - [ ] Run migrations on production database
@ -642,6 +750,85 @@ Based on requirements discussion:
- [ ] Analytics integration - [ ] Analytics integration
- [ ] Backup system - [ ] 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 ## Success Metrics
- Can create and publish any content type within 2-3 minutes - Can create and publish any content type within 2-3 minutes

View file

@ -1,5 +1,17 @@
# Product Requirements Document: Media Library Modal System # 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 ## 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. 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.) - Complete admin UI component library (Button, Input, etc.)
- Media upload infrastructure with Cloudinary integration - Media upload infrastructure with Cloudinary integration
- Pagination and search functionality - 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 ### 🎯 What We Need
#### High Priority (Direct Upload Focus) #### High Priority (Remaining Tasks)
- **Enhanced upload components** with immediate preview and metadata capture - **Enhanced upload features** with drag & drop zones in all upload components
- **Alt text input fields** for accessibility compliance - **Bulk alt text editing** in Media Library for existing content
- **Direct upload integration** in form components (ImagePicker, GalleryManager) - **Usage tracking display** showing where media is referenced
- **Metadata management** during upload process - **Performance optimizations** for large media libraries
#### Medium Priority (Media Library Browser) #### Medium Priority (Polish & Advanced Features)
- Reusable MediaLibraryModal component for browsing existing content - **Image optimization options** during upload
- Selection state management for previously uploaded files - **Advanced search capabilities** (by alt text, usage, etc.)
- Usage tracking and reference management - **Bulk operations** (delete multiple, bulk metadata editing)
#### Database Updates Required #### Low Priority (Future Enhancements)
- Add `alt_text` field to Media table - **AI-powered alt text suggestions**
- Add `usage_references` or similar tracking for where media is used - **Duplicate detection** and management
- **Advanced analytics** and usage reporting
## Workflow Priorities ## Workflow Priorities
@ -356,114 +375,130 @@ interface GalleryManagerProps {
## Implementation Plan ## Implementation Plan
### Phase 1: Database Schema Updates (Required First) ### ✅ Phase 1: Database Schema Updates (COMPLETED)
1. **Add Alt Text Support** 1. **✅ Alt Text Support**
```sql - Database schema includes `altText` and `description` fields
ALTER TABLE media ADD COLUMN alt_text TEXT; - API endpoints support alt text in upload and update operations
ALTER TABLE media ADD COLUMN description TEXT;
```
2. **Add Usage Tracking (Optional)** 2. **⏳ Usage Tracking (IN PROGRESS)**
```sql - Basic usage references working in forms
-- Track where media is referenced - Need dedicated tracking table for comprehensive usage analytics
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
);
```
### Phase 2: Direct Upload Components (High Priority) ### ✅ Phase 2: Direct Upload Components (COMPLETED)
1. **ImageUploader Component** 1. **✅ ImageUploader Component**
- Drag-and-drop upload zone with visual feedback - Drag-and-drop upload zone with visual feedback
- Immediate upload and preview functionality - Immediate upload and preview functionality
- Alt text input integration - Alt text input integration
- Replace existing ImagePicker with upload-first approach - MediaLibraryModal integration as secondary option
2. **GalleryUploader Component** 2. **✅ GalleryUploader Component**
- Multiple file drag-and-drop - Multiple file drag-and-drop support
- Individual alt text inputs per image - Individual alt text inputs per image
- Drag-and-drop reordering - Drag-and-drop reordering functionality
- Remove individual images functionality - Remove individual images functionality
- MediaLibraryModal integration for existing media selection
3. **Upload API Enhancement** 3. **Upload API Enhancement**
- Accept alt text in upload request - Alt text accepted in upload requests
- Return complete media object with metadata - Complete media object returned with metadata
- Handle batch uploads with individual alt text - Batch uploads with individual alt text support
### Phase 3: Form Integration (High Priority) ### ✅ Phase 3: Form Integration (COMPLETED)
1. **Project Forms Enhancement** 1. **Project Forms Enhancement**
- Replace logo field with ImageUploader - Logo field enhanced with ImageUploader + Browse Library
- Add featured image with ImageUploader - Featured image support with ImageUploader
- Implement gallery section with GalleryUploader - Gallery section implemented with GalleryUploader
- Add secondary "Browse Library" buttons - Secondary "Browse Library" buttons throughout
2. **Post Forms Enhancement** 2. **✅ Post Forms Enhancement**
- Photo post type with GalleryUploader - Photo post creation with PhotoPostForm
- Album creation with GalleryUploader - Album creation with AlbumForm and GalleryUploader
- Featured image selection for text posts - Universe Composer with photo attachments
- Enhanced Edra editor with inline image/gallery support
### Phase 4: Media Library Management (Medium Priority) ### ✅ Phase 4: Media Library Management (MOSTLY COMPLETED)
1. **Enhanced Media Library Page** 1. **✅ Enhanced Media Library Page**
- Alt text editing for existing media - Alt text editing for existing media via MediaDetailsModal
- Usage tracking display (shows where media is used) - Clickable media items with edit functionality
- Bulk alt text editing - Grid and list view toggles
- Search and filter by alt text
2. **MediaLibraryModal for Selection** 2. **MediaLibraryModal for Selection**
- Browse existing media interface - Browse existing media interface
- Single and multiple selection modes - 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** 1. **Advanced Upload Features**
- Image resizing/optimization options - **Image resizing/optimization** options during upload
- Automatic alt text suggestions (AI integration) - **Duplicate detection** to prevent redundant uploads
- Bulk upload with CSV metadata import - **Bulk upload improvements** with better progress tracking
2. **Usage Analytics** 2. **Usage Analytics & Management**
- Dashboard showing media usage statistics - **Usage analytics dashboard** showing media usage statistics
- Unused media cleanup tools - **Unused media cleanup** tools for storage optimization
- Duplicate detection and management - **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 ## Success Criteria
### Functional Requirements ### Functional Requirements
#### Primary Workflow (Direct Upload) #### Primary Workflow (Direct Upload)
- [ ] **Drag-and-drop upload works** in all form components - [x] **Drag-and-drop upload works** in all form components
- [ ] **Click-to-browse file selection** works reliably - [x] **Click-to-browse file selection** works reliably
- [ ] **Immediate upload and preview** happens without page navigation - [x] **Immediate upload and preview** happens without page navigation
- [ ] **Alt text input appears** and saves with uploaded media - [x] **Alt text input appears** and saves with uploaded media
- [ ] **Upload progress** is clearly indicated with percentage - [x] **Upload progress** is clearly indicated with percentage
- [ ] **Error handling** provides helpful feedback for failed uploads - [x] **Error handling** provides helpful feedback for failed uploads
- [ ] **Multiple file upload** works with individual progress tracking - [x] **Multiple file upload** works with individual progress tracking
- [ ] **Gallery reordering** works with drag-and-drop after upload - [x] **Gallery reordering** works with drag-and-drop after upload
#### Secondary Workflow (Media Library) #### Secondary Workflow (Media Library)
- [ ] **Media Library Modal** opens and closes properly with smooth animations - [x] **Media Library Modal** opens and closes properly with smooth animations
- [ ] **Single and multiple selection** modes work correctly - [x] **Single and multiple selection** modes work correctly
- [ ] **Search and filtering** return accurate results - [x] **Search and filtering** return accurate results
- [ ] **Usage tracking** shows where media is referenced - [ ] **Usage tracking** shows where media is referenced (IN PROGRESS)
- [ ] **Alt text editing** works in Media Library management - [x] **Alt text editing** works in Media Library management
- [ ] **All components are keyboard accessible** - [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 ### Performance Requirements
- [ ] Modal opens in under 200ms - [x] Modal opens in under 200ms
- [ ] Media grid loads in under 1 second - [x] Media grid loads in under 1 second
- [ ] Search results appear in under 500ms - [x] Search results appear in under 500ms
- [ ] Upload progress updates in real-time - [x] Upload progress updates in real-time
- [ ] No memory leaks when opening/closing modal multiple times - [x] No memory leaks when opening/closing modal multiple times
### UX Requirements ### UX Requirements
- [ ] Interface is intuitive without instruction - [x] Interface is intuitive without instruction
- [ ] Visual feedback is clear for all interactions - [x] Visual feedback is clear for all interactions
- [ ] Error messages are helpful and actionable - [x] Error messages are helpful and actionable
- [ ] Mobile/tablet interface is fully functional - [x] Mobile/tablet interface is fully functional
- [ ] Loading states prevent user confusion - [x] Loading states prevent user confusion
## Technical Considerations ## Technical Considerations
@ -510,25 +545,32 @@ interface GalleryManagerProps {
## Development Checklist ## Development Checklist
### Core Components ### Core Components
- [ ] MediaLibraryModal base structure - [x] MediaLibraryModal base structure
- [ ] MediaSelector with grid layout - [x] MediaSelector with grid layout
- [ ] MediaUploader with drag-and-drop - [x] MediaUploader with drag-and-drop
- [ ] Search and filter interface - [x] Search and filter interface
- [ ] Pagination implementation - [x] Pagination implementation
### Form Integration ### Form Integration
- [ ] MediaInput generic component - [x] MediaInput generic component (ImageUploader/GalleryUploader)
- [ ] ImagePicker specialized component - [x] ImagePicker specialized component (ImageUploader)
- [ ] GalleryManager with reordering - [x] GalleryManager with reordering (GalleryUploader)
- [ ] Integration with existing project forms - [x] Integration with existing project forms
- [ ] Integration with post forms - [x] Integration with post forms
- [x] Integration with Edra editor
### Polish and Testing ### Polish and Testing
- [ ] Responsive design implementation - [x] Responsive design implementation
- [ ] Accessibility testing and fixes - [x] Accessibility testing and fixes
- [ ] Performance optimization - [x] Performance optimization
- [ ] Error state handling - [x] Error state handling
- [ ] Cross-browser testing - [x] Cross-browser testing
- [ ] Mobile device 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. 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
View file

@ -41,6 +41,7 @@
"@types/steamapi": "^2.2.5", "@types/steamapi": "^2.2.5",
"cloudinary": "^2.6.1", "cloudinary": "^2.6.1",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"exifr": "^7.1.3",
"giantbombing-api": "^1.0.4", "giantbombing-api": "^1.0.4",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
@ -4805,6 +4806,12 @@
"integrity": "sha512-QVtGvYTf9HvQyDjbBCwoDQPP9KMuVB56H8KalrkLsPPCQfngpVmkiIoxJ4FU/SVmlmhnbr/heOmP5VlbCTEJpg==", "integrity": "sha512-QVtGvYTf9HvQyDjbBCwoDQPP9KMuVB56H8KalrkLsPPCQfngpVmkiIoxJ4FU/SVmlmhnbr/heOmP5VlbCTEJpg==",
"license": "MIT" "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": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",

View file

@ -84,6 +84,7 @@
"@types/steamapi": "^2.2.5", "@types/steamapi": "^2.2.5",
"cloudinary": "^2.6.1", "cloudinary": "^2.6.1",
"dotenv": "^16.5.0", "dotenv": "^16.5.0",
"exifr": "^7.1.3",
"giantbombing-api": "^1.0.4", "giantbombing-api": "^1.0.4",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",

View file

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

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Media" ALTER COLUMN "updatedAt" DROP DEFAULT;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Post" ADD COLUMN "attachments" JSONB;

View file

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

View file

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

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

View file

@ -20,7 +20,6 @@ model Project {
year Int year Int
client String? @db.VarChar(255) client String? @db.VarChar(255)
role String? @db.VarChar(255) role String? @db.VarChar(255)
technologies Json? // Array of tech stack
featuredImage String? @db.VarChar(500) featuredImage String? @db.VarChar(500)
logoUrl String? @db.VarChar(500) logoUrl String? @db.VarChar(500)
gallery Json? // Array of image URLs gallery Json? // Array of image URLs
@ -28,8 +27,10 @@ model Project {
caseStudyContent Json? // BlockNote JSON format caseStudyContent Json? // BlockNote JSON format
backgroundColor String? @db.VarChar(50) // For project card styling backgroundColor String? @db.VarChar(50) // For project card styling
highlightColor String? @db.VarChar(50) // For project card accent highlightColor String? @db.VarChar(50) // For project card accent
projectType String @default("work") @db.VarChar(50) // "work" or "labs"
displayOrder Int @default(0) 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? publishedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -54,6 +55,7 @@ model Post {
albumId Int? albumId Int?
featuredImage String? @db.VarChar(500) featuredImage String? @db.VarChar(500)
attachments Json? // Array of media IDs for photo attachments
tags Json? // Array of tags tags Json? // Array of tags
status String @default("draft") @db.VarChar(50) status String @default("draft") @db.VarChar(50)
publishedAt DateTime? publishedAt DateTime?
@ -78,6 +80,7 @@ model Album {
date DateTime? date DateTime?
location String? @db.VarChar(255) location String? @db.VarChar(255)
coverPhotoId Int? coverPhotoId Int?
isPhotography Boolean @default(false) // Show in photos experience
status String @default("draft") @db.VarChar(50) status String @default("draft") @db.VarChar(50)
showInUniverse Boolean @default(false) showInUniverse Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -133,9 +136,32 @@ model Media {
thumbnailUrl String? @db.Text thumbnailUrl String? @db.Text
width Int? width Int?
height Int? height Int?
exifData Json? // EXIF data for photos
altText String? @db.Text // Alt text for accessibility altText String? @db.Text // Alt text for accessibility
description String? @db.Text // Optional description 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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])
} }

View file

@ -24,7 +24,7 @@ async function main() {
year: 2023, year: 2023,
client: 'Personal Project', client: 'Personal Project',
role: 'Founder & Designer', role: 'Founder & Designer',
technologies: ['React Native', 'TypeScript', 'Node.js', 'PostgreSQL'], projectType: 'work',
featuredImage: '/images/projects/maitsu-cover.png', featuredImage: '/images/projects/maitsu-cover.png',
backgroundColor: '#FFF7EA', backgroundColor: '#FFF7EA',
highlightColor: '#F77754', highlightColor: '#F77754',
@ -43,7 +43,7 @@ async function main() {
year: 2022, year: 2022,
client: 'Slack Technologies', client: 'Slack Technologies',
role: 'Senior Product Designer', role: 'Senior Product Designer',
technologies: ['Design Systems', 'User Research', 'Prototyping', 'Strategy'], projectType: 'work',
featuredImage: '/images/projects/slack-cover.png', featuredImage: '/images/projects/slack-cover.png',
backgroundColor: '#4a154b', backgroundColor: '#4a154b',
highlightColor: '#611F69', highlightColor: '#611F69',
@ -62,7 +62,7 @@ async function main() {
year: 2019, year: 2019,
client: 'Figma Inc.', client: 'Figma Inc.',
role: 'Product Designer', role: 'Product Designer',
technologies: ['Product Design', 'Prototyping', 'User Research', 'Design Systems'], projectType: 'work',
featuredImage: '/images/projects/figma-cover.png', featuredImage: '/images/projects/figma-cover.png',
backgroundColor: '#2c2c2c', backgroundColor: '#2c2c2c',
highlightColor: '#0ACF83', highlightColor: '#0ACF83',
@ -81,7 +81,7 @@ async function main() {
year: 2011, year: 2011,
client: 'Pinterest', client: 'Pinterest',
role: 'Product Designer #1', role: 'Product Designer #1',
technologies: ['Product Design', 'Mobile Design', 'Design Leadership', 'Visual Design'], projectType: 'work',
featuredImage: '/images/projects/pinterest-cover.png', featuredImage: '/images/projects/pinterest-cover.png',
backgroundColor: '#f7f7f7', backgroundColor: '#f7f7f7',
highlightColor: '#CB1F27', 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 // Create test posts
const posts = await Promise.all([ const posts = await Promise.all([
@ -157,6 +232,7 @@ async function main() {
date: new Date('2024-03-15'), date: new Date('2024-03-15'),
location: 'Tokyo, Japan', location: 'Tokyo, Japan',
status: 'published', status: 'published',
isPhotography: true,
showInUniverse: true showInUniverse: true
} }
}) })
@ -172,6 +248,8 @@ async function main() {
height: 1080, height: 1080,
caption: 'Tokyo Tower at sunset', caption: 'Tokyo Tower at sunset',
displayOrder: 1, displayOrder: 1,
status: 'published',
showInPhotos: true,
exifData: { exifData: {
camera: 'Sony A7III', camera: 'Sony A7III',
lens: '24-70mm f/2.8', lens: '24-70mm f/2.8',
@ -190,7 +268,9 @@ async function main() {
width: 1920, width: 1920,
height: 1080, height: 1080,
caption: 'The famous Shibuya crossing', caption: 'The famous Shibuya crossing',
displayOrder: 2 displayOrder: 2,
status: 'published',
showInPhotos: true
} }
}) })
]) ])

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

View file

@ -1,22 +1,64 @@
<script lang="ts"> <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> </script>
<article class="lab-card"> {#if isClickable}
<div class="card-header"> <a href={projectUrl} class="lab-card clickable">
<h3 class="project-title">{project.title}</h3> <div class="card-header">
<span class="project-year">{project.year}</span> <h3 class="project-title">{project.title}</h3>
</div> <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} {#if project.externalUrl}
<div class="project-links"> <div class="project-links">
{#if project.url} <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 <a
href={project.url} href={project.externalUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="project-link primary" class="project-link primary"
@ -32,29 +74,21 @@
</svg> </svg>
Visit Project Visit Project
</a> </a>
{/if} </div>
{#if project.github} {/if}
<a
href={project.github} <!-- Add status indicators for different project states -->
target="_blank" {#if project.status === 'list-only'}
rel="noopener noreferrer" <div class="status-indicator list-only">
class="project-link secondary" <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"/>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"> <path d="M1 1l22 22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path </svg>
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" <span>View Only</span>
stroke="currentColor" </div>
stroke-width="2" {/if}
stroke-linecap="round" </article>
stroke-linejoin="round" {/if}
/>
</svg>
GitHub
</a>
{/if}
</div>
{/if}
</article>
<style lang="scss"> <style lang="scss">
.lab-card { .lab-card {
@ -64,12 +98,19 @@
transition: transition:
transform 0.2s ease, transform 0.2s ease,
box-shadow 0.2s ease; box-shadow 0.2s ease;
text-decoration: none;
color: inherit;
display: block;
&:hover { &:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
} }
&.clickable {
cursor: pointer;
}
@include breakpoint('phone') { @include breakpoint('phone') {
padding: $unit-2x; padding: $unit-2x;
} }
@ -117,6 +158,7 @@
display: flex; display: flex;
gap: $unit-2x; gap: $unit-2x;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: $unit-2x;
} }
.project-link { .project-link {
@ -139,6 +181,10 @@
background: darken($labs-color, 10%); background: darken($labs-color, 10%);
transform: translateY(-1px); transform: translateY(-1px);
} }
&.external {
pointer-events: none; // Prevent clicking when it's inside a clickable card
}
} }
&.secondary { &.secondary {
@ -156,4 +202,34 @@
flex-shrink: 0; 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>

View file

@ -1,84 +1,37 @@
<script lang="ts"> <script lang="ts">
import PhotoItem from '$components/PhotoItem.svelte' import PhotoItem from '$components/PhotoItem.svelte'
import PhotoLightbox from '$components/PhotoLightbox.svelte' import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
import type { PhotoItem as PhotoItemType, Photo } from '$lib/types/photos'
const { photoItems }: { photoItems: PhotoItemType[] } = $props() const {
photoItems,
let lightboxPhoto: Photo | null = $state(null) albumSlug
let lightboxAlbumPhotos: Photo[] = $state([]) }: {
let lightboxIndex = $state(0) photoItems: PhotoItemType[]
albumSlug?: string
function openLightbox(photo: Photo, albumPhotos?: Photo[]) { } = $props()
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]
}
</script> </script>
<div class="photo-grid-container"> <div class="photo-grid-container">
<div class="photo-grid"> <div class="photo-grid">
{#each photoItems as item} {#each photoItems as item}
<PhotoItem {item} onPhotoClick={openLightbox} /> <PhotoItem {item} {albumSlug} />
{/each} {/each}
</div> </div>
</div> </div>
{#if lightboxPhoto}
<PhotoLightbox
photo={lightboxPhoto}
albumPhotos={lightboxAlbumPhotos}
currentIndex={lightboxIndex}
onClose={closeLightbox}
onNavigate={navigateLightbox}
/>
{/if}
<style lang="scss"> <style lang="scss">
.photo-grid-container { .photo-grid-container {
width: 100%; width: 100%;
padding: 0 $unit-2x;
@include breakpoint('phone') {
padding: $unit-3x $unit;
}
} }
.photo-grid { .photo-grid {
columns: 3; columns: 3;
column-gap: $unit-2x; column-gap: $unit-3x;
max-width: 700px; margin: 0;
margin: 0 auto;
@include breakpoint('tablet') { @include breakpoint('tablet') {
columns: 2; columns: 2;
column-gap: $unit; column-gap: $unit-2x;
} }
@include breakpoint('phone') { @include breakpoint('phone') {

View file

@ -1,24 +1,32 @@
<script lang="ts"> <script lang="ts">
import type { PhotoItem, Photo, PhotoAlbum } from '$lib/types/photos' import type { PhotoItem, Photo, PhotoAlbum } from '$lib/types/photos'
import { isAlbum } from '$lib/types/photos' import { isAlbum } from '$lib/types/photos'
import { goto } from '$app/navigation'
const { const {
item, item,
onPhotoClick albumSlug // For when this is used within an album context
}: { }: {
item: PhotoItem item: PhotoItem
onPhotoClick: (photo: Photo, albumPhotos?: Photo[]) => void albumSlug?: string
} = $props() } = $props()
let imageLoaded = $state(false) let imageLoaded = $state(false)
function handleClick() { function handleClick() {
if (isAlbum(item)) { if (isAlbum(item)) {
// For albums, open the cover photo with album navigation // Navigate to album page using the slug
onPhotoClick(item.coverPhoto, item.photos) goto(`/photos/${item.slug}`)
} else { } else {
// For individual photos, open just that photo // For individual photos, check if we have album context
onPhotoClick(item) 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')
}
} }
} }

View file

@ -25,13 +25,13 @@
</time> </time>
</header> </header>
{#if post.type === 'image' && post.images} {#if post.images && post.images.length > 0}
<div class="post-images"> <div class="post-images">
<ImagePost images={post.images} alt={post.title || 'Post image'} /> <ImagePost images={post.images} alt={post.title || 'Post image'} />
</div> </div>
{/if} {/if}
{#if post.type === 'link' && post.link} {#if post.link}
<div class="post-link-preview"> <div class="post-link-preview">
<LinkCard link={post.link} /> <LinkCard link={post.link} />
</div> </div>
@ -51,24 +51,44 @@
max-width: 784px; max-width: 784px;
margin: 0 auto; 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 { .post-body {
font-size: 1.1rem; font-size: 1.1rem;
} }
} }
&.image { &.blog {
.post-images { .post-body {
margin-bottom: $unit-4x; font-size: 1rem;
line-height: 1.7;
} }
} }
}
&.link { // Content-specific styles
.post-link-preview { .post-images {
margin-bottom: $unit-4x; margin-bottom: $unit-4x;
max-width: 600px; }
}
} .post-link-preview {
margin-bottom: $unit-4x;
max-width: 600px;
} }
.post-header { .post-header {

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

View file

@ -9,6 +9,7 @@
slug: string slug: string
description: string description: string
highlightColor: string highlightColor: string
status?: 'draft' | 'published' | 'list-only' | 'password-protected'
index?: number index?: number
} }
@ -19,10 +20,14 @@
slug, slug,
description, description,
highlightColor, highlightColor,
status = 'published',
index = 0 index = 0
}: Props = $props() }: Props = $props()
const isEven = $derived(index % 2 === 0) 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 // Create highlighted description
const highlightedDescription = $derived( const highlightedDescription = $derived(
@ -151,21 +156,26 @@
} }
function handleClick() { function handleClick() {
goto(`/work/${slug}`) if (isClickable) {
goto(`/work/${slug}`)
}
} }
</script> </script>
<div <div
class="project-item {isEven ? 'even' : 'odd'}" class="project-item {isEven ? 'even' : 'odd'}"
class:clickable={isClickable}
class:list-only={isListOnly}
class:password-protected={isPasswordProtected}
bind:this={cardElement} bind:this={cardElement}
onclick={handleClick} onclick={handleClick}
onkeydown={(e) => e.key === 'Enter' && handleClick()} onkeydown={(e) => e.key === 'Enter' && handleClick()}
onmousemove={handleMouseMove} onmousemove={isClickable ? handleMouseMove : undefined}
onmouseenter={handleMouseEnter} onmouseenter={isClickable ? handleMouseEnter : undefined}
onmouseleave={handleMouseLeave} onmouseleave={isClickable ? handleMouseLeave : undefined}
style="transform: {transform};" style="transform: {transform};"
role="button" role={isClickable ? 'button' : 'article'}
tabindex="0" tabindex={isClickable ? 0 : -1}
> >
<div class="project-logo" style="background-color: {backgroundColor}"> <div class="project-logo" style="background-color: {backgroundColor}">
{#if svgContent} {#if svgContent}
@ -178,6 +188,26 @@
</div> </div>
<div class="project-content"> <div class="project-content">
<p class="project-description">{@html highlightedDescription}</p> <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>
</div> </div>
@ -192,15 +222,29 @@
border-radius: $card-corner-radius; border-radius: $card-corner-radius;
transition: transition:
transform 0.15s ease-out, 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; transform-style: preserve-3d;
will-change: transform; will-change: transform;
cursor: pointer; cursor: default;
&:hover { &.clickable {
box-shadow: cursor: pointer;
0 10px 30px rgba(0, 0, 0, 0.1),
0 1px 8px rgba(0, 0, 0, 0.06); &: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 { &.odd {
@ -252,6 +296,27 @@
color: $grey-00; 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') { @include breakpoint('phone') {
.project-item { .project-item {
flex-direction: column !important; flex-direction: column !important;

View file

@ -37,6 +37,7 @@
slug={project.slug} slug={project.slug}
description={project.description || ''} description={project.description || ''}
highlightColor={project.highlightColor || '#333'} highlightColor={project.highlightColor || '#333'}
status={project.status}
{index} {index}
/> />
</li> </li>

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

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

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

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

View file

@ -18,6 +18,7 @@
{ text: 'Dashboard', href: '/admin', icon: DashboardIcon }, { text: 'Dashboard', href: '/admin', icon: DashboardIcon },
{ text: 'Projects', href: '/admin/projects', icon: WorkIcon }, { text: 'Projects', href: '/admin/projects', icon: WorkIcon },
{ text: 'Universe', href: '/admin/posts', icon: UniverseIcon }, { text: 'Universe', href: '/admin/posts', icon: UniverseIcon },
{ text: 'Albums', href: '/admin/albums', icon: PhotosIcon },
{ text: 'Media', href: '/admin/media', icon: PhotosIcon } { text: 'Media', href: '/admin/media', icon: PhotosIcon }
] ]
@ -29,9 +30,11 @@
? 1 ? 1
: currentPath.startsWith('/admin/posts') : currentPath.startsWith('/admin/posts')
? 2 ? 2
: currentPath.startsWith('/admin/media') : currentPath.startsWith('/admin/albums')
? 3 ? 3
: -1 : currentPath.startsWith('/admin/media')
? 4
: -1
) )
</script> </script>
@ -134,8 +137,8 @@
} }
.brand-logo { .brand-logo {
height: 40px; height: 32px;
width: 40px; width: 32px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View file

@ -1,53 +1,57 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'
import Button from './Button.svelte' import Button from './Button.svelte'
interface Props { interface Props {
isOpen: boolean
title?: string title?: string
message: string message: string
confirmText?: string confirmText?: string
cancelText?: string cancelText?: string
onConfirm: () => void
onCancel?: () => void
} }
let { let {
isOpen = $bindable(),
title = 'Delete item?', title = 'Delete item?',
message, message,
confirmText = 'Delete', confirmText = 'Delete',
cancelText = 'Cancel' cancelText = 'Cancel',
onConfirm,
onCancel
}: Props = $props() }: Props = $props()
const dispatch = createEventDispatcher<{
confirm: void
cancel: void
}>()
function handleConfirm() { function handleConfirm() {
dispatch('confirm') onConfirm()
} }
function handleCancel() { function handleCancel() {
dispatch('cancel') isOpen = false
onCancel?.()
} }
function handleBackdropClick() { function handleBackdropClick() {
dispatch('cancel') isOpen = false
onCancel?.()
} }
</script> </script>
<div class="modal-backdrop" onclick={handleBackdropClick}> {#if isOpen}
<div class="modal" onclick={(e) => e.stopPropagation()}> <div class="modal-backdrop" onclick={handleBackdropClick}>
<h2>{title}</h2> <div class="modal" onclick={(e) => e.stopPropagation()}>
<p>{message}</p> <h2>{title}</h2>
<div class="modal-actions"> <p>{message}</p>
<Button variant="secondary" onclick={handleCancel}> <div class="modal-actions">
{cancelText} <Button variant="secondary" onclick={handleCancel}>
</Button> {cancelText}
<Button variant="danger" onclick={handleConfirm}> </Button>
{confirmText} <Button variant="danger" onclick={handleConfirm}>
</Button> {confirmText}
</Button>
</div>
</div> </div>
</div> </div>
</div> {/if}
<style lang="scss"> <style lang="scss">
.modal-backdrop { .modal-backdrop {

View file

@ -23,23 +23,61 @@
// Form state // Form state
let altText = $state('') let altText = $state('')
let description = $state('') let description = $state('')
let isPhotography = $state(false)
let isSaving = $state(false) let isSaving = $state(false)
let error = $state('') let error = $state('')
let successMessage = $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 // Initialize form when media changes
$effect(() => { $effect(() => {
if (media) { if (media) {
altText = media.altText || '' altText = media.altText || ''
description = media.description || '' description = media.description || ''
isPhotography = media.isPhotography || false
error = '' error = ''
successMessage = '' 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() { function handleClose() {
altText = '' altText = ''
description = '' description = ''
isPhotography = false
error = '' error = ''
successMessage = '' successMessage = ''
isOpen = false isOpen = false
@ -53,14 +91,15 @@
isSaving = true isSaving = true
error = '' error = ''
const response = await authenticatedFetch(`/api/media/${media.id}/metadata`, { const response = await authenticatedFetch(`/api/media/${media.id}`, {
method: 'PATCH', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
altText: altText.trim() || null, altText: altText.trim() || null,
description: description.trim() || null description: description.trim() || null,
isPhotography: isPhotography
}) })
}) })
@ -240,25 +279,58 @@
fullWidth 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 --> <!-- Usage Tracking -->
{#if media.usedIn && Array.isArray(media.usedIn) && media.usedIn.length > 0} <div class="usage-section">
<div class="usage-section"> <h4>Used In</h4>
<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"> <ul class="usage-list">
{#each media.usedIn as usage} {#each usage as usageItem}
<li class="usage-item"> <li class="usage-item">
<span class="usage-type">{usage.contentType}</span> <div class="usage-content">
<span class="usage-field">{usage.fieldName}</span> <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> </li>
{/each} {/each}
</ul> </ul>
</div> {:else}
{:else}
<div class="usage-section">
<h4>Usage</h4>
<p class="no-usage">This media file is not currently used in any content.</p> <p class="no-usage">This media file is not currently used in any content.</p>
</div> {/if}
{/if} </div>
</div> </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-section {
.usage-list { .usage-list {
list-style: none; list-style: none;
@ -449,23 +591,80 @@
gap: $unit; gap: $unit;
} }
.usage-item { .usage-loading {
display: flex; display: flex;
align-items: center; align-items: center;
gap: $unit-2x; gap: $unit-2x;
padding: $unit-2x; padding: $unit-2x;
background: $grey-95; color: $grey-50;
border-radius: 8px;
.usage-type { .spinner {
font-weight: 500; width: 16px;
color: $grey-20; height: 16px;
text-transform: capitalize; 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 { .usage-header {
color: $grey-40; display: flex;
font-size: 0.875rem; 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 // Responsive adjustments
@include breakpoint('phone') { @include breakpoint('phone') {
.modal-header { .modal-header {

View file

@ -31,6 +31,7 @@
let total = $state(0) let total = $state(0)
let searchQuery = $state('') let searchQuery = $state('')
let filterType = $state<string>(fileType === 'all' ? 'all' : fileType) let filterType = $state<string>(fileType === 'all' ? 'all' : fileType)
let photographyFilter = $state<string>('all')
let searchTimeout: ReturnType<typeof setTimeout> let searchTimeout: ReturnType<typeof setTimeout>
// Initialize selected media from IDs // Initialize selected media from IDs
@ -60,6 +61,14 @@
} }
}) })
// Watch for photography filter changes
$effect(() => {
if (photographyFilter !== undefined) {
currentPage = 1
loadMedia()
}
})
onMount(() => { onMount(() => {
loadMedia() loadMedia()
}) })
@ -76,6 +85,10 @@
url += `&mimeType=${filterType}` url += `&mimeType=${filterType}`
} }
if (photographyFilter !== 'all') {
url += `&isPhotography=${photographyFilter}`
}
if (searchQuery) { if (searchQuery) {
url += `&search=${encodeURIComponent(searchQuery)}` url += `&search=${encodeURIComponent(searchQuery)}`
} }
@ -172,6 +185,12 @@
<option value="image">Images</option> <option value="image">Images</option>
<option value="video">Videos</option> <option value="video">Videos</option>
</select> </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> </div>
{#if showSelectAll} {#if showSelectAll}
@ -258,6 +277,25 @@
<div class="media-filename" title={item.filename}> <div class="media-filename" title={item.filename}>
{item.filename} {item.filename}
</div> </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"> <div class="media-meta">
<span class="file-size">{formatFileSize(item.size)}</span> <span class="file-size">{formatFileSize(item.size)}</span>
{#if item.width && item.height} {#if item.width && item.height}
@ -478,6 +516,13 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.media-indicators {
display: flex;
gap: $unit-half;
flex-wrap: wrap;
margin-bottom: $unit-half;
}
.media-meta { .media-meta {
display: flex; display: flex;
gap: $unit; gap: $unit;
@ -485,6 +530,48 @@
color: $grey-40; 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 { .load-more-container {
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -6,30 +6,23 @@
let isOpen = $state(false) let isOpen = $state(false)
let buttonRef: HTMLElement let buttonRef: HTMLElement
let showComposer = $state(false) let showComposer = $state(false)
let selectedType = $state<'post' | 'essay' | 'album'>('post') let selectedType = $state<'post' | 'essay'>('post')
const postTypes = [ const postTypes = [
{ value: 'blog', label: 'Essay' }, { value: 'essay', label: 'Essay' },
{ value: 'microblog', label: 'Post' }, { value: 'post', label: 'Post' }
{ value: 'link', label: 'Link' },
{ value: 'photo', label: 'Photo' },
{ value: 'album', label: 'Album' }
] ]
function handleSelection(type: string) { function handleSelection(type: string) {
isOpen = false isOpen = false
if (type === 'blog') { if (type === 'essay') {
// Essays go straight to the full page // Essays go straight to the full page
goto('/admin/universe/compose?type=essay') goto('/admin/universe/compose?type=essay')
} else if (type === 'microblog' || type === 'link') { } else if (type === 'post') {
// Posts and links open in modal // Posts open in modal
selectedType = 'post' selectedType = 'post'
showComposer = true 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()} {#snippet icon()}
<div class="dropdown-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"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path <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" 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" stroke-linecap="round"
/> />
</svg> </svg>
{:else if type.value === 'microblog'} {:else if type.value === 'post'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path <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" 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 7H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M5 9H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> <path d="M5 9H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg> </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} {/if}
</div> </div>
{/snippet} {/snippet}

View file

@ -58,14 +58,15 @@
year: data.year || new Date().getFullYear(), year: data.year || new Date().getFullYear(),
client: data.client || '', client: data.client || '',
role: data.role || '', role: data.role || '',
technologies: Array.isArray(data.technologies) ? data.technologies.join(', ') : '', projectType: data.projectType || 'work',
externalUrl: data.externalUrl || '', externalUrl: data.externalUrl || '',
featuredImage: data.featuredImage || null, featuredImage: data.featuredImage || null,
backgroundColor: data.backgroundColor || '', backgroundColor: data.backgroundColor || '',
highlightColor: data.highlightColor || '', highlightColor: data.highlightColor || '',
logoUrl: data.logoUrl || '', logoUrl: data.logoUrl || '',
gallery: data.gallery || null, gallery: data.gallery || null,
status: (data.status as 'draft' | 'published') || 'draft', status: data.status || 'draft',
password: data.password || '',
caseStudyContent: data.caseStudyContent || { caseStudyContent: data.caseStudyContent || {
type: 'doc', type: 'doc',
content: [{ type: 'paragraph' }] content: [{ type: 'paragraph' }]
@ -84,7 +85,8 @@
externalUrl: formData.externalUrl || undefined, externalUrl: formData.externalUrl || undefined,
backgroundColor: formData.backgroundColor || undefined, backgroundColor: formData.backgroundColor || undefined,
highlightColor: formData.highlightColor || undefined, highlightColor: formData.highlightColor || undefined,
status: formData.status status: formData.status,
password: formData.password || undefined
}) })
validationErrors = {} validationErrors = {}
return true return true
@ -132,6 +134,7 @@
return return
} }
const payload = { const payload = {
title: formData.title, title: formData.title,
subtitle: formData.subtitle, subtitle: formData.subtitle,
@ -139,10 +142,7 @@
year: formData.year, year: formData.year,
client: formData.client, client: formData.client,
role: formData.role, role: formData.role,
technologies: formData.technologies projectType: formData.projectType,
.split(',')
.map((t) => t.trim())
.filter(Boolean),
externalUrl: formData.externalUrl, externalUrl: formData.externalUrl,
featuredImage: formData.featuredImage, featuredImage: formData.featuredImage,
logoUrl: formData.logoUrl, logoUrl: formData.logoUrl,
@ -150,6 +150,7 @@
backgroundColor: formData.backgroundColor, backgroundColor: formData.backgroundColor,
highlightColor: formData.highlightColor, highlightColor: formData.highlightColor,
status: formData.status, status: formData.status,
password: formData.status === 'password-protected' ? formData.password : null,
caseStudyContent: caseStudyContent:
formData.caseStudyContent && formData.caseStudyContent &&
formData.caseStudyContent.content && formData.caseStudyContent.content &&
@ -191,14 +192,8 @@
} }
} }
async function handlePublish() { async function handleStatusChange(newStatus: string) {
formData.status = 'published' formData.status = newStatus as any
await handleSave()
showPublishMenu = false
}
async function handleUnpublish() {
formData.status = 'draft'
await handleSave() await handleSave()
showPublishMenu = false showPublishMenu = false
} }
@ -241,7 +236,11 @@
{#if !isLoading} {#if !isLoading}
<div class="save-actions"> <div class="save-actions">
<Button variant="primary" onclick={handleSave} disabled={isSaving} class="save-button"> <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>
<Button <Button
variant="ghost" variant="ghost"
@ -271,15 +270,26 @@
</Button> </Button>
{#if showPublishMenu} {#if showPublishMenu}
<div class="publish-menu"> <div class="publish-menu">
{#if formData.status === 'published'} {#if formData.status !== 'draft'}
<Button variant="ghost" onclick={handleUnpublish} class="menu-item" fullWidth> <Button variant="ghost" onclick={() => handleStatusChange('draft')} class="menu-item" fullWidth>
Unpublish Save as Draft
</Button> </Button>
{:else} {/if}
<Button variant="ghost" onclick={handlePublish} class="menu-item" fullWidth> {#if formData.status !== 'published'}
<Button variant="ghost" onclick={() => handleStatusChange('published')} class="menu-item" fullWidth>
Publish Publish
</Button> </Button>
{/if} {/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> </div>
{/if} {/if}
</div> </div>

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Input from './Input.svelte' import Input from './Input.svelte'
import Select from './Select.svelte'
import ImageUploader from './ImageUploader.svelte' import ImageUploader from './ImageUploader.svelte'
import type { ProjectFormData } from '$lib/types/project' import type { ProjectFormData } from '$lib/types/project'
@ -33,6 +34,17 @@
placeholder="Short description for project cards" 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"> <div class="form-row">
<Input <Input
type="number" type="number"
@ -67,6 +79,31 @@
placeholder="Upload a featured image for this project" placeholder="Upload a featured image for this project"
showBrowseLibrary={true} 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> </div>
<style lang="scss"> <style lang="scss">

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

View file

@ -7,7 +7,7 @@
import Input from './Input.svelte' import Input from './Input.svelte'
interface Props { interface Props {
postType: 'microblog' | 'link' postType: 'post'
postId?: number postId?: number
initialData?: { initialData?: {
title?: string title?: string
@ -45,21 +45,20 @@
// Check if form has content // Check if form has content
const hasContent = $derived(() => { const hasContent = $derived(() => {
if (postType === 'microblog') { // For posts, check if either content exists or it's a link with URL
return textContent().trim().length > 0 const hasTextContent = textContent().trim().length > 0
} else if (postType === 'link') { const hasLinkContent = linkUrl && linkUrl.trim().length > 0
return linkUrl && linkUrl.trim().length > 0 return hasTextContent || hasLinkContent
}
return false
}) })
async function handleSave(publishStatus: 'draft' | 'published') { async function handleSave(publishStatus: 'draft' | 'published') {
if (postType === 'microblog' && isOverLimit) { if (isOverLimit) {
error = 'Post is too long' error = 'Post is too long'
return return
} }
if (postType === 'link' && !linkUrl) { // For link posts, URL is required
if (linkUrl && !linkUrl.trim()) {
error = 'Link URL is required' error = 'Link URL is required'
return return
} }
@ -75,15 +74,15 @@
} }
const payload: any = { const payload: any = {
postType, type: 'post', // Use simplified post type
status: publishStatus status: publishStatus,
content: content
} }
if (postType === 'microblog') { // Add link fields if they're provided
payload.content = content if (linkUrl && linkUrl.trim()) {
} else if (postType === 'link') {
payload.title = title || linkUrl payload.title = title || linkUrl
payload.linkUrl = linkUrl payload.link_url = linkUrl
payload.linkDescription = linkDescription payload.linkDescription = linkDescription
} }

View file

@ -15,10 +15,10 @@
export let isOpen = false export let isOpen = false
export let initialMode: 'modal' | 'page' = 'modal' 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 export let initialContent: JSONContent | undefined = undefined
type PostType = 'post' | 'essay' | 'album' type PostType = 'post' | 'essay'
type ComposerMode = 'modal' | 'page' type ComposerMode = 'modal' | 'page'
let postType: PostType = initialPostType let postType: PostType = initialPostType
@ -212,24 +212,24 @@
if (postType === 'essay') { if (postType === 'essay') {
postData = { postData = {
...postData, ...postData,
type: 'blog', type: 'essay',
title: essayTitle, title: essayTitle,
slug: essaySlug, slug: essaySlug,
excerpt: essayExcerpt, excerpt: essayExcerpt,
tags: essayTags ? essayTags.split(',').map((tag) => tag.trim()) : [] tags: essayTags ? essayTags.split(',').map((tag) => tag.trim()) : []
} }
} else if (showLinkFields) {
postData = {
...postData,
type: 'link',
linkUrl,
linkTitle,
linkDescription
}
} else { } else {
// All other content is just a "post" with optional link data and attachments
postData = { postData = {
...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
} }
} }

View file

@ -19,7 +19,19 @@ export const projectSchema = z.object({
.regex(/^#[0-9A-Fa-f]{6}$/) .regex(/^#[0-9A-Fa-f]{6}$/)
.optional() .optional()
.or(z.literal('')), .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> export type ProjectSchema = z.infer<typeof projectSchema>

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

View file

@ -21,6 +21,7 @@ export interface Photo {
export interface PhotoAlbum { export interface PhotoAlbum {
id: string id: string
slug: string
title: string title: string
description?: string description?: string
coverPhoto: Photo coverPhoto: Photo

View file

@ -1,3 +1,6 @@
export type ProjectStatus = 'draft' | 'published' | 'list-only' | 'password-protected'
export type ProjectType = 'work' | 'labs'
export interface Project { export interface Project {
id: number id: number
slug: string slug: string
@ -7,7 +10,6 @@ export interface Project {
year: number year: number
client: string | null client: string | null
role: string | null role: string | null
technologies: string[] | null
featuredImage: string | null featuredImage: string | null
logoUrl: string | null logoUrl: string | null
gallery: any[] | null gallery: any[] | null
@ -15,8 +17,10 @@ export interface Project {
caseStudyContent: any | null caseStudyContent: any | null
backgroundColor: string | null backgroundColor: string | null
highlightColor: string | null highlightColor: string | null
projectType: ProjectType
displayOrder: number displayOrder: number
status: string status: ProjectStatus
password: string | null
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
publishedAt?: string | null publishedAt?: string | null
@ -29,14 +33,15 @@ export interface ProjectFormData {
year: number year: number
client: string client: string
role: string role: string
technologies: string projectType: ProjectType
externalUrl: string externalUrl: string
featuredImage: string | null featuredImage: string | null
backgroundColor: string backgroundColor: string
highlightColor: string highlightColor: string
logoUrl: string logoUrl: string
gallery: any[] | null gallery: any[] | null
status: 'draft' | 'published' status: ProjectStatus
password: string
caseStudyContent: any caseStudyContent: any
} }
@ -47,7 +52,7 @@ export const defaultProjectFormData: ProjectFormData = {
year: new Date().getFullYear(), year: new Date().getFullYear(),
client: '', client: '',
role: '', role: '',
technologies: '', projectType: 'work',
externalUrl: '', externalUrl: '',
featuredImage: null, featuredImage: null,
backgroundColor: '', backgroundColor: '',
@ -55,6 +60,7 @@ export const defaultProjectFormData: ProjectFormData = {
logoUrl: '', logoUrl: '',
gallery: null, gallery: null,
status: 'draft', status: 'draft',
password: '',
caseStudyContent: { caseStudyContent: {
type: 'doc', type: 'doc',
content: [{ type: 'paragraph' }] content: [{ type: 'paragraph' }]

View file

@ -59,7 +59,7 @@ async function fetchRecentPSNGames(fetch: typeof window.fetch): Promise<Serializ
async function fetchProjects( async function fetchProjects(
fetch: typeof window.fetch fetch: typeof window.fetch
): Promise<{ projects: Project[]; pagination: any }> { ): 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) { if (!response.ok) {
throw new Error(`Failed to fetch projects: ${response.status}`) throw new Error(`Failed to fetch projects: ${response.status}`)
} }

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

File diff suppressed because it is too large Load diff

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

View file

@ -15,6 +15,7 @@
// Filter states // Filter states
let filterType = $state<string>('all') let filterType = $state<string>('all')
let photographyFilter = $state<string>('all')
let searchQuery = $state('') let searchQuery = $state('')
let searchTimeout: ReturnType<typeof setTimeout> let searchTimeout: ReturnType<typeof setTimeout>
@ -22,6 +23,11 @@
let selectedMedia = $state<Media | null>(null) let selectedMedia = $state<Media | null>(null)
let isDetailsModalOpen = $state(false) let isDetailsModalOpen = $state(false)
// Multiselect states
let selectedMediaIds = $state<Set<number>>(new Set())
let isMultiSelectMode = $state(false)
let isDeleting = $state(false)
onMount(async () => { onMount(async () => {
await loadMedia() await loadMedia()
}) })
@ -46,6 +52,9 @@
if (filterType !== 'all') { if (filterType !== 'all') {
url += `&mimeType=${filterType}` url += `&mimeType=${filterType}`
} }
if (photographyFilter !== 'all') {
url += `&isPhotography=${photographyFilter}`
}
if (searchQuery) { if (searchQuery) {
url += `&search=${encodeURIComponent(searchQuery)}` url += `&search=${encodeURIComponent(searchQuery)}`
} }
@ -116,12 +125,185 @@
media[index] = updatedMedia 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> </script>
<AdminPage> <AdminPage>
<header slot="header"> <header slot="header">
<h1>Media Library</h1> <h1>Media Library</h1>
<div class="header-actions"> <div class="header-actions">
<button
onclick={toggleMultiSelectMode}
class="btn btn-secondary"
class:active={isMultiSelectMode}
>
{isMultiSelectMode ? '✓' : '☐'}
{isMultiSelectMode ? 'Exit Select' : 'Select'}
</button>
<button <button
onclick={() => (viewMode = viewMode === 'grid' ? 'list' : 'grid')} onclick={() => (viewMode = viewMode === 'grid' ? 'list' : 'grid')}
class="btn btn-secondary" class="btn btn-secondary"
@ -142,6 +324,12 @@
<span class="stat-value">{total}</span> <span class="stat-value">{total}</span>
<span class="stat-label">Total files</span> <span class="stat-label">Total files</span>
</div> </div>
{#if isMultiSelectMode}
<div class="stat">
<span class="stat-value">{selectedMediaIds.size}</span>
<span class="stat-label">Selected</span>
</div>
{/if}
</div> </div>
<div class="filters"> <div class="filters">
@ -153,6 +341,12 @@
<option value="application/pdf">PDFs</option> <option value="application/pdf">PDFs</option>
</select> </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 <Input
type="search" type="search"
bind:value={searchQuery} bind:value={searchQuery}
@ -170,6 +364,52 @@
</div> </div>
</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} {#if isLoading}
<div class="loading">Loading media...</div> <div class="loading">Loading media...</div>
{:else if media.length === 0} {:else if media.length === 0}
@ -180,71 +420,134 @@
{:else if viewMode === 'grid'} {:else if viewMode === 'grid'}
<div class="media-grid"> <div class="media-grid">
{#each media as item} {#each media as item}
<button <div class="media-item-wrapper" class:multiselect={isMultiSelectMode}>
class="media-item" {#if isMultiSelectMode}
type="button" <div class="selection-checkbox">
onclick={() => handleMediaClick(item)} <input
title="Click to edit {item.filename}" type="checkbox"
> checked={selectedMediaIds.has(item.id)}
{#if item.mimeType.startsWith('image/')} onchange={() => toggleMediaSelection(item.id)}
<img src={item.mimeType === 'image/svg+xml' ? item.url : (item.thumbnailUrl || item.url)} alt={item.altText || item.filename} /> id="media-{item.id}"
{:else} />
<div class="file-placeholder"> <label for="media-{item.id}" class="checkbox-label"></label>
<span class="file-type">{getFileType(item.mimeType)}</span>
</div> </div>
{/if} {/if}
<div class="media-info"> <button
<span class="filename">{item.filename}</span> class="media-item"
<span class="filesize">{formatFileSize(item.size)}</span> type="button"
{#if item.altText} onclick={() => isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)}
<span class="alt-text" title="Alt text: {item.altText}"> title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}"
Alt: {item.altText.length > 30 ? item.altText.substring(0, 30) + '...' : item.altText} class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
</span> >
{#if item.mimeType.startsWith('image/')}
<img src={item.mimeType === 'image/svg+xml' ? item.url : (item.thumbnailUrl || item.url)} alt={item.altText || item.filename} />
{:else} {:else}
<span class="no-alt-text">No alt text</span> <div class="file-placeholder">
<span class="file-type">{getFileType(item.mimeType)}</span>
</div>
{/if} {/if}
</div> <div class="media-info">
</button> <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} {/each}
</div> </div>
{:else} {:else}
<div class="media-list"> <div class="media-list">
{#each media as item} {#each media as item}
<button <div class="media-row-wrapper" class:multiselect={isMultiSelectMode}>
class="media-row" {#if isMultiSelectMode}
type="button" <div class="selection-checkbox">
onclick={() => handleMediaClick(item)} <input
title="Click to edit {item.filename}" type="checkbox"
> checked={selectedMediaIds.has(item.id)}
<div class="media-preview"> onchange={() => toggleMediaSelection(item.id)}
{#if item.mimeType.startsWith('image/')} id="media-row-{item.id}"
<img src={item.mimeType === 'image/svg+xml' ? item.url : (item.thumbnailUrl || item.url)} alt={item.altText || item.filename} /> />
{:else} <label for="media-row-{item.id}" class="checkbox-label"></label>
<div class="file-icon">{getFileType(item.mimeType)}</div> </div>
{/if} {/if}
</div> <button
<div class="media-details"> class="media-row"
<span class="filename">{item.filename}</span> type="button"
<span class="file-meta"> onclick={() => isMultiSelectMode ? toggleMediaSelection(item.id) : handleMediaClick(item)}
{getFileType(item.mimeType)}{formatFileSize(item.size)} title="{isMultiSelectMode ? 'Click to select' : 'Click to edit'} {item.filename}"
{#if item.width && item.height} class:selected={isMultiSelectMode && selectedMediaIds.has(item.id)}
{item.width}×{item.height}px >
<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} {/if}
</span> </div>
{#if item.altText} <div class="media-details">
<span class="alt-text-preview"> <div class="filename-row">
Alt: {item.altText} <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> </span>
{:else} {#if item.altText}
<span class="no-alt-text-preview">No alt text</span> <span class="alt-text-preview">
{/if} Alt: {item.altText}
</div> </span>
<div class="media-indicator"> {:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <span class="no-alt-text-preview">No alt text</span>
<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> {/if}
</svg> </div>
</div> <div class="media-indicator">
</button> {#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} {/each}
</div> </div>
{/if} {/if}
@ -467,7 +770,7 @@
.filename { .filename {
font-size: 0.875rem; font-size: 0.875rem;
color: $grey-20; color: $grey-20;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
@ -477,19 +780,11 @@
color: $grey-40; color: $grey-40;
} }
.alt-text { .media-indicators {
font-size: 0.75rem; display: flex;
color: $grey-30; gap: $unit-half;
font-style: italic; flex-wrap: wrap;
white-space: nowrap; margin: $unit-half 0;
overflow: hidden;
text-overflow: ellipsis;
}
.no-alt-text {
font-size: 0.75rem;
color: $red-60;
font-style: italic;
} }
} }
} }
@ -556,10 +851,25 @@
flex-direction: column; flex-direction: column;
gap: $unit-half; gap: $unit-half;
.filename { .filename-row {
font-size: 0.925rem; display: flex;
color: $grey-20; align-items: center;
font-weight: 500; 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 { .file-meta {
@ -640,4 +950,163 @@
color: $grey-40; 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> </style>

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

View file

@ -2,29 +2,40 @@
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import AdminPage from '$lib/components/admin/AdminPage.svelte' 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 PostDropdown from '$lib/components/admin/PostDropdown.svelte'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
interface Post { interface Post {
id: number id: number
slug: string slug: string
postType: string postType: string
title: string | null title: string | null
content: any // JSON content
excerpt: string | null excerpt: string | null
status: string status: string
tags: string[] | null tags: string[] | null
linkUrl: string | null
linkDescription: string | null
featuredImage: string | null
publishedAt: string | null publishedAt: string | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
let posts = $state<Post[]>([]) let posts = $state<Post[]>([])
let filteredPosts = $state<Post[]>([])
let isLoading = $state(true) let isLoading = $state(true)
let error = $state('') let error = $state('')
let total = $state(0) let total = $state(0)
let postTypeCounts = $state<Record<string, number>>({}) let postTypeCounts = $state<Record<string, number>>({})
// Filter state
let selectedFilter = $state<string>('all')
const postTypeIcons: Record<string, string> = { const postTypeIcons: Record<string, string> = {
post: '💭',
essay: '📝',
// Legacy types for backward compatibility
blog: '📝', blog: '📝',
microblog: '💭', microblog: '💭',
link: '🔗', link: '🔗',
@ -33,66 +44,16 @@
} }
const postTypeLabels: Record<string, string> = { const postTypeLabels: Record<string, string> = {
blog: 'Blog Post', post: 'Post',
microblog: 'Microblog', essay: 'Essay',
link: 'Link', // Legacy types for backward compatibility
photo: 'Photo', blog: 'Essay',
microblog: 'Post',
link: 'Post',
photo: 'Post',
album: 'Album' 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 () => { onMount(async () => {
await loadPosts() await loadPosts()
}) })
@ -121,12 +82,27 @@
posts = data.posts || [] posts = data.posts || []
total = data.pagination?.total || posts.length total = data.pagination?.total || posts.length
// Calculate post type counts // Calculate post type counts and normalize types
const counts: Record<string, number> = {} const counts: Record<string, number> = {
all: posts.length,
post: 0,
essay: 0
}
posts.forEach((post) => { 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 postTypeCounts = counts
// Apply initial filter
applyFilter()
} catch (err) { } catch (err) {
error = 'Failed to load posts' error = 'Failed to load posts'
console.error(err) 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`) 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> </script>
<AdminPage> <AdminPage>
<header slot="header"> <header slot="header">
<h1>Posts</h1> <h1>Universe</h1>
<div class="header-actions"> <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 /> <PostDropdown />
</div> </div>
</header> </header>
{#if error} {#if error}
<div class="error">{error}</div> <div class="error-message">{error}</div>
{:else} {:else}
<!-- Stats -->
<div class="posts-stats"> <div class="posts-stats">
<div class="stat"> <div class="stat">
<span class="stat-value">{total}</span> <span class="stat-value">{postTypeCounts.all || 0}</span>
<span class="stat-label">Total posts</span> <span class="stat-label">Total posts</span>
</div> </div>
{#each Object.entries(postTypeCounts) as [type, count]} <div class="stat">
<div class="stat"> <span class="stat-value">{postTypeCounts.post || 0}</span>
<span class="stat-value">{count}</span> <span class="stat-label">Posts</span>
<span class="stat-label">{postTypeLabels[type] || type}</span> </div>
</div> <div class="stat">
{/each} <span class="stat-value">{postTypeCounts.essay || 0}</span>
<span class="stat-label">Essays</span>
</div>
</div> </div>
<DataTable <!-- Posts List -->
data={posts} {#if isLoading}
{columns} <div class="loading-container">
loading={isLoading} <LoadingSpinner />
emptyMessage="No posts found. Create your first post!" </div>
onRowClick={handleRowClick} {: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} {/if}
</AdminPage> </AdminPage>
<style lang="scss"> <style lang="scss">
@import '$styles/variables.scss';
header { header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -186,58 +344,302 @@
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;
color: $grey-10; color: $grey-10;
} }
} }
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center;
gap: $unit-2x; gap: $unit-2x;
}
.btn { .filter-select {
padding: $unit-2x $unit-3x; padding: $unit $unit-3x;
border-radius: 50px; border: 1px solid $grey-80;
text-decoration: none; border-radius: 50px;
font-size: 0.925rem; background: white;
transition: all 0.2s ease; font-size: 0.925rem;
color: $grey-20;
cursor: pointer;
min-width: 160px;
&.btn-primary { &:focus {
background-color: $grey-10; outline: none;
color: white; border-color: $grey-40;
&:hover {
background-color: $grey-20;
} }
} }
} }
.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; text-align: center;
padding: $unit-6x; margin-bottom: $unit-4x;
color: #d33;
} }
.posts-stats { .posts-stats {
display: flex; display: flex;
gap: $unit-4x; gap: $unit-4x;
margin-bottom: $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 { .stat {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
gap: $unit-half; gap: $unit-half;
.stat-value { .stat-value {
font-size: 1.5rem; font-size: 2rem;
font-weight: 700; font-weight: 700;
color: $grey-10; color: $grey-10;
} }
.stat-label { .stat-label {
font-size: 0.875rem; font-size: 0.875rem;
color: $grey-40; 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>

View file

@ -3,15 +3,13 @@
import { onMount } from 'svelte' import { onMount } from 'svelte'
import EssayForm from '$lib/components/admin/EssayForm.svelte' import EssayForm from '$lib/components/admin/EssayForm.svelte'
import SimplePostForm from '$lib/components/admin/SimplePostForm.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 let mounted = false
onMount(() => { onMount(() => {
const type = $page.url.searchParams.get('type') 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 postType = type as typeof postType
} }
mounted = true mounted = true
@ -19,13 +17,9 @@
</script> </script>
{#if mounted} {#if mounted}
{#if postType === 'blog'} {#if postType === 'essay'}
<EssayForm mode="create" /> <EssayForm mode="create" />
{:else if postType === 'microblog' || postType === 'link'} {:else}
<SimplePostForm {postType} mode="create" /> <SimplePostForm postType="post" mode="create" />
{:else if postType === 'photo'}
<PhotoPostForm mode="create" />
{:else if postType === 'album'}
<AlbumForm mode="create" />
{/if} {/if}
{/if} {/if}

View file

@ -176,14 +176,13 @@
{/if} {/if}
</AdminPage> </AdminPage>
{#if showDeleteModal && projectToDelete} <DeleteConfirmationModal
<DeleteConfirmationModal bind:isOpen={showDeleteModal}
title="Delete project?" title="Delete project?"
message={`Are you sure you want to delete "${projectToDelete.title}"? This action cannot be undone.`} message={projectToDelete ? `Are you sure you want to delete "${projectToDelete.title}"? This action cannot be undone.` : ''}
onconfirm={confirmDelete} onConfirm={confirmDelete}
oncancel={cancelDelete} onCancel={cancelDelete}
/> />
{/if}
<style lang="scss"> <style lang="scss">
header { header {

View file

@ -4,7 +4,7 @@
import AdminPage from '$lib/components/admin/AdminPage.svelte' import AdminPage from '$lib/components/admin/AdminPage.svelte'
// Get initial state from URL params // 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') $: initialContent = $page.url.searchParams.get('content')
? JSON.parse($page.url.searchParams.get('content')!) ? JSON.parse($page.url.searchParams.get('content')!)
: undefined : undefined

View file

@ -4,7 +4,9 @@ import {
jsonResponse, jsonResponse,
errorResponse, errorResponse,
getPaginationParams, getPaginationParams,
getPaginationMeta getPaginationMeta,
checkAdminAuth,
parseRequestBody
} from '$lib/server/api-utils' } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
@ -16,6 +18,7 @@ export const GET: RequestHandler = async (event) => {
// Get filter parameters // Get filter parameters
const status = event.url.searchParams.get('status') const status = event.url.searchParams.get('status')
const isPhotography = event.url.searchParams.get('isPhotography')
// Build where clause // Build where clause
const where: any = {} const where: any = {}
@ -23,6 +26,10 @@ export const GET: RequestHandler = async (event) => {
where.status = status where.status = status
} }
if (isPhotography !== null) {
where.isPhotography = isPhotography === 'true'
}
// Get total count // Get total count
const total = await prisma.album.count({ where }) const total = await prisma.album.count({ where })
@ -52,3 +59,60 @@ export const GET: RequestHandler = async (event) => {
return errorResponse('Failed to retrieve albums', 500) 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)
}
}

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

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

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

View file

@ -24,6 +24,7 @@ export const GET: RequestHandler = async (event) => {
const mimeType = event.url.searchParams.get('mimeType') const mimeType = event.url.searchParams.get('mimeType')
const unused = event.url.searchParams.get('unused') === 'true' const unused = event.url.searchParams.get('unused') === 'true'
const search = event.url.searchParams.get('search') const search = event.url.searchParams.get('search')
const isPhotography = event.url.searchParams.get('isPhotography')
// Build where clause // Build where clause
const where: any = {} const where: any = {}
@ -40,6 +41,10 @@ export const GET: RequestHandler = async (event) => {
where.filename = { contains: search, mode: 'insensitive' } where.filename = { contains: search, mode: 'insensitive' }
} }
if (isPhotography !== null) {
where.isPhotography = isPhotography === 'true'
}
// Get total count // Get total count
const total = await prisma.media.count({ where }) const total = await prisma.media.count({ where })
@ -59,6 +64,7 @@ export const GET: RequestHandler = async (event) => {
width: true, width: true,
height: true, height: true,
usedIn: true, usedIn: true,
isPhotography: true,
createdAt: true createdAt: true
} }
}) })

View file

@ -1,9 +1,83 @@
import type { RequestHandler } from './$types' import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database' import { prisma } from '$lib/server/database'
import { deleteFile, extractPublicId } from '$lib/server/cloudinary' 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' 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 // DELETE /api/media/[id] - Delete a media item
export const DELETE: RequestHandler = async (event) => { export const DELETE: RequestHandler = async (event) => {
// Check authentication // Check authentication

View file

@ -2,6 +2,7 @@ import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database' import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils' import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
import { getMediaUsage } from '$lib/server/media-usage.js'
// GET /api/media/[id]/usage - Check where media is used // GET /api/media/[id]/usage - Check where media is used
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
@ -22,7 +23,9 @@ export const GET: RequestHandler = async (event) => {
id: true, id: true,
filename: true, filename: true,
url: 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) return errorResponse('Media not found', 404)
} }
// Parse the usedIn field and fetch details // Get detailed usage information using our new tracking system
const usage = (media.usedIn as Array<{ type: string; id: number }>) || [] const usage = await getMediaUsage(id)
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 })
}
}
return jsonResponse({ return jsonResponse({
media: { media: {
id: media.id, id: media.id,
filename: media.filename, filename: media.filename,
url: media.url url: media.url,
altText: media.altText,
description: media.description,
isPhotography: media.isPhotography
}, },
usage: detailedUsage, usage: usage,
isUsed: detailedUsage.length > 0 isUsed: usage.length > 0
}) })
} catch (error) { } catch (error) {
logger.error('Failed to check media usage', error as Error) logger.error('Failed to check media usage', error as Error)

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

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

View file

@ -4,6 +4,83 @@ import { uploadFile, isCloudinaryConfigured } from '$lib/server/cloudinary'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils' import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
import { dev } from '$app/environment' 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) => { export const POST: RequestHandler = async (event) => {
// Check authentication // Check authentication
@ -22,6 +99,7 @@ export const POST: RequestHandler = async (event) => {
const context = (formData.get('context') as string) || 'media' const context = (formData.get('context') as string) || 'media'
const altText = (formData.get('altText') as string) || null const altText = (formData.get('altText') as string) || null
const description = (formData.get('description') as string) || null const description = (formData.get('description') as string) || null
const isPhotography = formData.get('isPhotography') === 'true'
if (!file || !(file instanceof File)) { if (!file || !(file instanceof File)) {
return errorResponse('No file provided', 400) 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) 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 // Upload to Cloudinary
const uploadResult = await uploadFile(file, context as 'media' | 'photos' | 'projects') const uploadResult = await uploadFile(file, context as 'media' | 'photos' | 'projects')
@ -64,8 +148,10 @@ export const POST: RequestHandler = async (event) => {
thumbnailUrl: uploadResult.thumbnailUrl, thumbnailUrl: uploadResult.thumbnailUrl,
width: uploadResult.width, width: uploadResult.width,
height: uploadResult.height, height: uploadResult.height,
exifData: exifData,
altText: altText?.trim() || null, altText: altText?.trim() || null,
description: description?.trim() || null, description: description?.trim() || null,
isPhotography: isPhotography,
usedIn: [] usedIn: []
} }
}) })

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

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

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

View file

@ -8,6 +8,7 @@ import {
checkAdminAuth checkAdminAuth
} from '$lib/server/api-utils' } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
import { trackMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js'
// GET /api/posts - List all posts // GET /api/posts - List all posts
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
@ -111,11 +112,69 @@ export const POST: RequestHandler = async (event) => {
linkUrl: data.link_url, linkUrl: data.link_url,
linkDescription: data.linkDescription, linkDescription: data.linkDescription,
featuredImage: featuredImageId, featuredImage: featuredImageId,
attachments: data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
tags: data.tags, tags: data.tags,
publishedAt: data.publishedAt 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 }) logger.info('Post created', { id: post.id, title: post.title })
return jsonResponse(post) return jsonResponse(post)
} catch (error) { } catch (error) {

View file

@ -2,6 +2,7 @@ import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database' import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils' import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' 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 // GET /api/posts/[id] - Get a single post
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
@ -92,11 +93,61 @@ export const PUT: RequestHandler = async (event) => {
linkUrl: data.link_url, linkUrl: data.link_url,
linkDescription: data.linkDescription, linkDescription: data.linkDescription,
featuredImage: featuredImageId, featuredImage: featuredImageId,
attachments: data.attachedPhotos && data.attachedPhotos.length > 0 ? data.attachedPhotos : null,
tags: data.tags, tags: data.tags,
publishedAt: data.publishedAt 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 }) logger.info('Post updated', { id })
return jsonResponse(post) return jsonResponse(post)
} catch (error) { } catch (error) {
@ -117,6 +168,10 @@ export const DELETE: RequestHandler = async (event) => {
return errorResponse('Invalid post ID', 400) return errorResponse('Invalid post ID', 400)
} }
// Remove media usage tracking first
await removeMediaUsage('post', id)
// Delete the post
await prisma.post.delete({ await prisma.post.delete({
where: { id } where: { id }
}) })

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

View file

@ -10,6 +10,7 @@ import {
} from '$lib/server/api-utils' } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
import { createSlug, ensureUniqueSlug } from '$lib/server/database' import { createSlug, ensureUniqueSlug } from '$lib/server/database'
import { trackMediaUsage, extractMediaIds, type MediaUsageReference } from '$lib/server/media-usage.js'
// GET /api/projects - List all projects // GET /api/projects - List all projects
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
@ -19,11 +20,32 @@ export const GET: RequestHandler = async (event) => {
// Get filter parameters // Get filter parameters
const status = event.url.searchParams.get('status') 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 // Build where clause
const where: any = {} const where: any = {}
if (status) { if (status) {
where.status = 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 // Get total count
@ -83,7 +105,6 @@ export const POST: RequestHandler = async (event) => {
year: body.year, year: body.year,
client: body.client, client: body.client,
role: body.role, role: body.role,
technologies: body.technologies || [],
featuredImage: body.featuredImage, featuredImage: body.featuredImage,
logoUrl: body.logoUrl, logoUrl: body.logoUrl,
gallery: body.gallery || [], gallery: body.gallery || [],
@ -91,12 +112,69 @@ export const POST: RequestHandler = async (event) => {
caseStudyContent: body.caseStudyContent, caseStudyContent: body.caseStudyContent,
backgroundColor: body.backgroundColor, backgroundColor: body.backgroundColor,
highlightColor: body.highlightColor, highlightColor: body.highlightColor,
projectType: body.projectType || 'work',
displayOrder: body.displayOrder || 0, displayOrder: body.displayOrder || 0,
status: body.status || 'draft', status: body.status || 'draft',
password: body.password || null,
publishedAt: body.status === 'published' ? new Date() : 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 }) logger.info('Project created', { id: project.id, slug: project.slug })
return jsonResponse(project, 201) return jsonResponse(project, 201)

View file

@ -8,6 +8,7 @@ import {
} from '$lib/server/api-utils' } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger' import { logger } from '$lib/server/logger'
import { ensureUniqueSlug } from '$lib/server/database' 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 // GET /api/projects/[id] - Get a single project
export const GET: RequestHandler = async (event) => { export const GET: RequestHandler = async (event) => {
@ -76,7 +77,6 @@ export const PUT: RequestHandler = async (event) => {
year: body.year ?? existing.year, year: body.year ?? existing.year,
client: body.client ?? existing.client, client: body.client ?? existing.client,
role: body.role ?? existing.role, role: body.role ?? existing.role,
technologies: body.technologies ?? existing.technologies,
featuredImage: body.featuredImage ?? existing.featuredImage, featuredImage: body.featuredImage ?? existing.featuredImage,
logoUrl: body.logoUrl ?? existing.logoUrl, logoUrl: body.logoUrl ?? existing.logoUrl,
gallery: body.gallery ?? existing.gallery, gallery: body.gallery ?? existing.gallery,
@ -84,13 +84,74 @@ export const PUT: RequestHandler = async (event) => {
caseStudyContent: body.caseStudyContent ?? existing.caseStudyContent, caseStudyContent: body.caseStudyContent ?? existing.caseStudyContent,
backgroundColor: body.backgroundColor ?? existing.backgroundColor, backgroundColor: body.backgroundColor ?? existing.backgroundColor,
highlightColor: body.highlightColor ?? existing.highlightColor, highlightColor: body.highlightColor ?? existing.highlightColor,
projectType: body.projectType ?? existing.projectType,
displayOrder: body.displayOrder ?? existing.displayOrder, displayOrder: body.displayOrder ?? existing.displayOrder,
status: body.status ?? existing.status, status: body.status ?? existing.status,
password: body.password ?? existing.password,
publishedAt: publishedAt:
body.status === 'published' && !existing.publishedAt ? new Date() : existing.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 }) logger.info('Project updated', { id: project.id, slug: project.slug })
return jsonResponse(project) return jsonResponse(project)
@ -113,6 +174,10 @@ export const DELETE: RequestHandler = async (event) => {
} }
try { try {
// Remove media usage tracking first
await removeMediaUsage('project', id)
// Delete the project
await prisma.project.delete({ await prisma.project.delete({
where: { id } where: { id }
}) })

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

View file

@ -4,15 +4,28 @@
const { data }: { data: PageData } = $props() const { data }: { data: PageData } = $props()
const projects = $derived(data.projects) const projects = $derived(data.projects || [])
const error = $derived(data.error)
</script> </script>
<div class="labs-container"> <div class="labs-container">
<div class="projects-grid"> {#if error}
{#each projects as project} <div class="error-message">
<LabCard {project} /> <h2>Unable to load projects</h2>
{/each} <p>{error}</p>
</div> </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> </div>
<style lang="scss"> <style lang="scss">
@ -35,4 +48,27 @@
gap: $unit-2x; 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> </style>

View file

@ -1,56 +1,21 @@
import type { PageLoad } from './$types' import type { PageLoad } from './$types'
import type { LabProject } from '$lib/types/labs'
export const load: PageLoad = async () => { export const load: PageLoad = async ({ fetch }) => {
const projects: LabProject[] = [ try {
{ const response = await fetch('/api/projects?projectType=labs&includeListOnly=true&includePasswordProtected=true')
id: 'granblue-team', if (!response.ok) {
title: 'granblue.team', throw new Error('Failed to fetch labs projects')
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', const data = await response.json()
technologies: [], return {
url: 'https://granblue.team', projects: data.projects || []
github: 'https://github.com/jedmund/granblue-team', }
year: 2022, } catch (error) {
featured: true console.error('Error loading labs projects:', error)
}, return {
{ projects: [],
id: 'subway-board', error: 'Failed to load projects'
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
} }
]
return {
projects
} }
} }

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

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

View file

@ -4,13 +4,72 @@
const { data }: { data: PageData } = $props() const { data }: { data: PageData } = $props()
const photoItems = $derived(data.photoItems) const photoItems = $derived(data.photoItems || [])
const error = $derived(data.error)
</script> </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"> <style lang="scss">
:global(main) { .photos-page {
padding: 0; 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> </style>

View file

@ -1,413 +1,25 @@
import type { PageLoad } from './$types' import type { PageLoad } from './$types'
import type { PhotoItem } from '$lib/types/photos'
export const load: PageLoad = async () => { export const load: PageLoad = async ({ fetch }) => {
// Mock data for now - in a real app this would come from an API or CMS try {
const photoItems: PhotoItem[] = [ const response = await fetch('/api/photos?limit=50')
{ if (!response.ok) {
id: 'photo-1', throw new Error('Failed to fetch photos')
src: 'https://picsum.photos/400/600?random=1', }
alt: 'Mountain landscape at sunset',
caption: 'A beautiful landscape captured during golden hour', const data = await response.json()
width: 400, return {
height: 600, photoItems: data.photoItems || [],
exif: { pagination: data.pagination || null
camera: 'Canon EOS R5', }
lens: '24-70mm f/2.8', } catch (error) {
focalLength: '35mm', console.error('Error loading photos:', error)
aperture: 'f/5.6',
shutterSpeed: '1/250s', // Fallback to empty array if API fails
iso: '100', return {
dateTaken: '2024-01-15', photoItems: [],
location: 'Yosemite National Park' pagination: null,
} error: 'Failed to load photos'
},
{
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'
}
} }
]
return {
photoItems
} }
} }

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

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

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

View 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
View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
// 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 })
}
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
// 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 })
}
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
// 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 })
}
}

View file

@ -1,10 +1,23 @@
import { getAllPosts } from '$lib/posts'
import type { PageServerLoad } from './$types' import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async () => { export const load: PageServerLoad = async ({ fetch }) => {
const posts = await getAllPosts() try {
const response = await fetch('/api/universe?limit=20')
return { if (!response.ok) {
posts 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'
}
} }
} }

View file

@ -1,22 +1,27 @@
<script lang="ts"> <script lang="ts">
import Page from '$components/Page.svelte' import UniverseFeed from '$components/UniverseFeed.svelte'
import PostList from '$components/PostList.svelte'
import type { PageData } from './$types' import type { PageData } from './$types'
let { data }: { data: PageData } = $props() let { data }: { data: PageData } = $props()
</script> </script>
<svelte:head> <svelte:head>
<title>Blog - jedmund</title> <title>Universe - jedmund</title>
<meta name="description" content="Thoughts on design, development, and everything in between." /> <meta name="description" content="A mixed feed of posts, thoughts, and photo albums." />
</svelte:head> </svelte:head>
<div class="blog-container"> <div class="universe-container">
<PostList posts={data.posts} /> {#if data.error}
<div class="error-message">
<p>{data.error}</p>
</div>
{:else}
<UniverseFeed items={data.universeItems || []} />
{/if}
</div> </div>
<style lang="scss"> <style lang="scss">
.blog-container { .universe-container {
max-width: 784px; max-width: 784px;
margin: 0 auto; margin: 0 auto;
padding: 0 $unit-5x; padding: 0 $unit-5x;
@ -31,4 +36,18 @@
padding: 0 $unit-2x; 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> </style>

View file

@ -1,15 +1,30 @@
import { getPostBySlug } from '$lib/posts'
import { error } from '@sveltejs/kit'
import type { PageServerLoad } from './$types' import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params, fetch }) => {
const post = await getPostBySlug(params.slug) 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) { const post = await response.json()
throw error(404, 'Post not found')
}
return { return {
post post
}
} catch (error) {
console.error('Error loading post:', error)
return {
post: null,
error: 'Failed to load post'
}
} }
} }

View file

@ -1,18 +1,89 @@
<script lang="ts"> <script lang="ts">
import Page from '$components/Page.svelte' import Page from '$components/Page.svelte'
import PostContent from '$components/PostContent.svelte' import DynamicPostContent from '$components/DynamicPostContent.svelte'
import type { PageData } from './$types' import type { PageData } from './$types'
let { data }: { data: PageData } = $props() 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> </script>
<svelte:head> <svelte:head>
<title>{pageTitle} - jedmund</title> {#if post}
<meta name="description" content={data.post.excerpt} /> <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> </svelte:head>
<Page> {#if error || !post}
<PostContent post={data.post} /> <Page>
</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>

View file

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import Page from '$components/Page.svelte' 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 { PageData } from './$types'
import type { Project } from '$lib/types/project' import type { Project } from '$lib/types/project'
@ -7,46 +9,6 @@
const project = $derived(data.project as Project | null) const project = $derived(data.project as Project | null)
const error = $derived(data.error as string | undefined) 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> </script>
{#if error} {#if error}
@ -63,6 +25,35 @@
<Page> <Page>
<div class="loading">Loading project...</div> <div class="loading">Loading project...</div>
</Page> </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} {:else}
<Page> <Page>
<div slot="header" class="project-header"> <div slot="header" class="project-header">
@ -76,81 +67,7 @@
<p class="project-subtitle">{project.subtitle}</p> <p class="project-subtitle">{project.subtitle}</p>
{/if} {/if}
</div> </div>
<ProjectContent {project} />
<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>
</Page> </Page>
{/if} {/if}
@ -177,6 +94,17 @@
padding: $unit-4x; 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 */
.project-header { .project-header {
text-align: center; text-align: center;
@ -221,182 +149,4 @@
font-size: 1.125rem; 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> </style>

View file

@ -3,8 +3,8 @@ import type { Project } from '$lib/types/project'
export const load: PageLoad = async ({ params, fetch }) => { export const load: PageLoad = async ({ params, fetch }) => {
try { try {
// Find project by slug // Find project by slug - we'll fetch all published, list-only, and password-protected projects
const response = await fetch(`/api/projects?status=published`) const response = await fetch(`/api/projects?includeListOnly=true&includePasswordProtected=true`)
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch projects') throw new Error('Failed to fetch projects')
} }
@ -16,6 +16,11 @@ export const load: PageLoad = async ({ params, fetch }) => {
throw new Error('Project not found') throw new Error('Project not found')
} }
// Handle different project statuses
if (project.status === 'draft') {
throw new Error('Project not found')
}
return { return {
project project
} }