Merge pull request #1 from jedmund/universe/geo

This adds a full, fat CMS to the website because my brain is bad
This commit is contained in:
Justin Edmund 2025-06-02 15:23:35 -07:00 committed by GitHub
commit a31dcf8a0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
293 changed files with 54108 additions and 6523 deletions

16
.env.example Normal file
View file

@ -0,0 +1,16 @@
# Database
DATABASE_URL="postgresql://user:password@host:port/database?sslmode=require"
# Redis (existing)
REDIS_URL="your-redis-url"
# Last.fm API (existing)
LASTFM_API_KEY="your-lastfm-api-key"
# Cloudinary
CLOUDINARY_CLOUD_NAME="your-cloud-name"
CLOUDINARY_API_KEY="your-api-key"
CLOUDINARY_API_SECRET="your-api-secret"
# Admin Authentication (for later)
ADMIN_PASSWORD="your-admin-password"

8
.gitignore vendored
View file

@ -19,3 +19,11 @@ Thumbs.db
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/../generated/prisma
# Local uploads (for development)
/static/local-uploads
*storybook.log
storybook-static

39
.storybook/main.ts Normal file
View file

@ -0,0 +1,39 @@
import type { StorybookConfig } from '@storybook/sveltekit'
import { mergeConfig } from 'vite'
import path from 'path'
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'],
addons: ['@storybook/addon-svelte-csf', '@storybook/addon-docs', '@storybook/addon-a11y'],
framework: {
name: '@storybook/sveltekit',
options: {}
},
viteFinal: async (config) => {
return mergeConfig(config, {
resolve: {
alias: {
$lib: path.resolve('./src/lib'),
$components: path.resolve('./src/lib/components'),
$icons: path.resolve('./src/assets/icons'),
$illos: path.resolve('./src/assets/illos'),
$styles: path.resolve('./src/assets/styles')
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `
@import './src/assets/styles/variables.scss';
@import './src/assets/styles/fonts.scss';
@import './src/assets/styles/themes.scss';
`,
api: 'modern-compiler'
}
}
}
})
}
}
export default config

42
.storybook/preview.ts Normal file
View file

@ -0,0 +1,42 @@
import type { Preview } from '@storybook/sveltekit'
import '../src/assets/styles/reset.css'
import '../src/assets/styles/globals.scss'
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/
}
},
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#333333' },
{ name: 'admin', value: '#f5f5f5' },
{ name: 'grey-95', value: '#f8f9fa' }
]
},
viewport: {
viewports: {
mobile: {
name: 'Mobile',
styles: { width: '375px', height: '667px' }
},
tablet: {
name: 'Tablet',
styles: { width: '768px', height: '1024px' }
},
desktop: {
name: 'Desktop',
styles: { width: '1440px', height: '900px' }
}
}
}
}
}
export default preview

View file

@ -34,6 +34,10 @@ npm run preview
This is a SvelteKit personal portfolio site for @jedmund that integrates with multiple external APIs to display real-time data about music listening habits and gaming activity.
We are using Svelte 5 in Runes mode, so make sure to only write solutions that will work with that newer syntax.
Make sure to use the CSS variables that are defined across the various files in `src/assets/styles`. When making new colors or defining new variables, check that it doesn't exist first, then define it.
### Key Architecture Components
**API Integration Layer** (`src/routes/api/`)

179
LOCAL_SETUP.md Normal file
View file

@ -0,0 +1,179 @@
# Local Development Setup Guide
This guide will help you set up a local development environment for the CMS without needing external services.
## Prerequisites
- Node.js 18+ installed
- Homebrew (for macOS)
## Step 1: Install PostgreSQL
### On macOS:
```bash
# Install PostgreSQL
brew install postgresql@15
# Start PostgreSQL service
brew services start postgresql@15
# Verify installation
psql --version
```
### On Linux:
```bash
# Ubuntu/Debian
sudo apt update
sudo apt install postgresql postgresql-contrib
# Start PostgreSQL
sudo systemctl start postgresql
```
## Step 2: Set Up Local Environment
1. **Run the setup script:**
```bash
npm run setup:local
```
This script will:
- Check PostgreSQL installation
- Create a `.env` file from `.env.local.example`
- Create the database
- Run migrations
- Generate Prisma client
2. **If the script fails, manually create the database:**
```bash
# Create database
createdb jedmund_cms
# Or using psql
psql -c "CREATE DATABASE jedmund_cms;"
```
3. **Update your `.env` file:**
```env
# Use your local PostgreSQL settings
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/jedmund_cms?schema=public"
# For local dev, leave Cloudinary empty (will use mock data)
CLOUDINARY_CLOUD_NAME=""
CLOUDINARY_API_KEY=""
CLOUDINARY_API_SECRET=""
# Simple admin password for local testing
ADMIN_PASSWORD="localdev"
```
## Step 3: Run Database Migrations
```bash
# Generate Prisma client
npx prisma generate
# Run migrations
npm run db:migrate
# Seed with test data
npm run db:seed
```
## Step 4: Start Development Server
```bash
npm run dev
```
## Step 5: Test the Setup
1. **Check API health:**
Visit http://localhost:5173/api/health
You should see:
```json
{
"status": "ok",
"services": {
"database": "connected",
"cloudinary": "not configured"
}
}
```
2. **View database:**
```bash
npm run db:studio
```
This opens Prisma Studio at http://localhost:5555
## Testing API Endpoints
Since we need authentication, use these curl commands:
```bash
# Test health (no auth needed)
curl http://localhost:5173/api/health
# Test media list (with auth)
curl -H "Authorization: Basic $(echo -n 'admin:localdev' | base64)" \
http://localhost:5173/api/media
# Test project creation
curl -X POST \
-H "Authorization: Basic $(echo -n 'admin:localdev' | base64)" \
-H "Content-Type: application/json" \
-d '{"title":"Test Project","year":2024,"slug":"test-project"}' \
http://localhost:5173/api/projects
```
## Local Development Notes
1. **Media Uploads:** Without Cloudinary configured, uploaded images will return mock URLs. The files won't actually be stored anywhere, but the database records will be created for testing.
2. **Authentication:** Use Basic Auth with username `admin` and password `localdev` (or whatever you set in ADMIN_PASSWORD).
3. **Database:** All data is stored locally in PostgreSQL. You can reset it anytime with:
```bash
npx prisma migrate reset
```
4. **Debugging:** Check the console for detailed logs. The logger will show all API requests and database queries in development mode.
## Troubleshooting
### PostgreSQL Connection Issues
- Make sure PostgreSQL is running: `brew services list`
- Check if the database exists: `psql -l`
- Try connecting manually: `psql -d jedmund_cms`
### Prisma Issues
- Clear Prisma generated files: `rm -rf node_modules/.prisma`
- Regenerate: `npx prisma generate`
### Port Already in Use
- Kill the process: `lsof -ti:5173 | xargs kill -9`
- Or use a different port: `npm run dev -- --port 5174`
## Next Steps
Once local development is working, you can:
1. Start building the admin UI (Phase 3)
2. Test CRUD operations with the API
3. Develop without needing external services
When ready for production, see the deployment guide for setting up Railway and Cloudinary.

841
PRD-cms-functionality.md Normal file
View file

@ -0,0 +1,841 @@
# Product Requirements Document: Multi-Content CMS
## Overview
Add a comprehensive CMS to the personal portfolio site to manage multiple content types: Projects (Work section), Posts (Universe section), and Photos/Albums (Photos and Universe sections).
## Goals
- Enable dynamic content creation across all site sections
- Provide rich text editing for long-form content (Edra)
- Support different content types with appropriate editing interfaces
- Store all content in PostgreSQL database (Railway-compatible)
- Display content instantly after publishing
- Maintain the existing design aesthetic
## Technical Constraints
- **Hosting**: Railway (no direct file system access)
- **Database**: PostgreSQL add-on available
- **Framework**: SvelteKit
- **Editor**: Edra for rich text (https://edra.tsuzat.com/docs), custom forms for structured data
## Core Features
### 1. Database Schema
```sql
-- Projects table (for /work)
CREATE TABLE projects (
id SERIAL PRIMARY KEY,
slug VARCHAR(255) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
subtitle VARCHAR(255),
description TEXT,
year INTEGER NOT NULL,
client VARCHAR(255),
role VARCHAR(255),
technologies JSONB, -- Array of tech stack
featured_image VARCHAR(500),
gallery JSONB, -- Array of image URLs
external_url VARCHAR(500),
case_study_content JSONB, -- Edra JSON format
display_order INTEGER DEFAULT 0,
status VARCHAR(50) DEFAULT 'draft',
published_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Posts table (for /universe) - Simplified to 2 types
CREATE TABLE posts (
id SERIAL PRIMARY KEY,
slug VARCHAR(255) UNIQUE NOT NULL,
post_type VARCHAR(50) NOT NULL, -- 'post' or 'essay'
title VARCHAR(255), -- Required for essays, optional for posts
content JSONB, -- Edra JSON content
excerpt TEXT, -- For essays
featured_image VARCHAR(500),
attachments JSONB, -- Array of media IDs for any attachments
tags JSONB, -- Array of tags
status VARCHAR(50) DEFAULT 'draft',
published_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Albums table - Enhanced with photography curation
CREATE TABLE albums (
id SERIAL PRIMARY KEY,
slug VARCHAR(255) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
date DATE,
location VARCHAR(255),
cover_photo_id INTEGER REFERENCES photos(id),
is_photography BOOLEAN DEFAULT false, -- Show in photos experience
status VARCHAR(50) DEFAULT 'draft',
show_in_universe BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Photos table
CREATE TABLE photos (
id SERIAL PRIMARY KEY,
album_id INTEGER REFERENCES albums(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
url VARCHAR(500) NOT NULL,
thumbnail_url VARCHAR(500),
width INTEGER,
height INTEGER,
exif_data JSONB,
caption TEXT,
display_order INTEGER DEFAULT 0,
-- Individual publishing support
slug VARCHAR(255) UNIQUE, -- Only if published individually
title VARCHAR(255), -- Optional title for individual photos
description TEXT, -- Longer description when published solo
status VARCHAR(50) DEFAULT 'draft',
published_at TIMESTAMP,
show_in_photos BOOLEAN DEFAULT true, -- Show in photos page when published solo
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Media table (general uploads) - Enhanced with photography curation
CREATE TABLE media (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255), -- Original filename from user
mime_type VARCHAR(100) NOT NULL,
size INTEGER NOT NULL,
url TEXT NOT NULL,
thumbnail_url TEXT,
width INTEGER,
height INTEGER,
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)
);
```
### 2. Image Handling Strategy
#### For Posts (Edra Integration)
- **Storage**: Images embedded in posts are stored in the `media` table
- **Edra Custom Block**: Create custom image block that:
- Uploads to `/api/media/upload` on drop/paste
- Returns media ID and URL
- Stores reference as `{ type: "image", mediaId: 123, url: "...", alt: "..." }`
- **Advantages**:
- Images flow naturally with content
- Can add captions, alt text inline
- Supports drag-and-drop repositioning
- No orphaned images (tracked by mediaId)
#### For Projects
- **Featured Image**: Single image reference stored in `featured_image` field
- **Gallery Images**: Array of media IDs stored in `gallery` JSONB field
- **Case Study Content**: Uses same Edra approach as Posts
- **Storage Pattern**:
```json
{
"featured_image": "https://cdn.../image1.jpg",
"gallery": [
{ "mediaId": 123, "url": "...", "caption": "..." },
{ "mediaId": 124, "url": "...", "caption": "..." }
]
}
```
#### Media Usage Tracking System
The system now uses a dedicated `media_usage` table for robust tracking:
```sql
-- MediaUsage tracks where each media file is used
-- Replaces the simple used_in JSONB field with proper relational tracking
-- Enables complex queries like "show all projects using this media"
-- Supports bulk operations and reference cleanup
```
**Benefits:**
- Accurate usage tracking across all content types
- Efficient queries for usage information
- Safe bulk deletion with automatic reference cleanup
- Detailed tracking by field (featuredImage, gallery, content, etc.)
### 3. Content Type Editors
- **Projects**: Form-based editor with:
- Metadata fields (title, year, client, role)
- Technology tag selector
- Featured image picker (opens media library)
- Gallery manager (grid view with reordering)
- Optional Edra editor for case studies
- **Posts**: Full Edra editor with:
- Custom image block implementation
- Drag-and-drop image upload
- Media library integration
- Image optimization on upload
- Auto-save including image references
- **Photos/Albums**: Media-focused interface with:
- Bulk photo upload
- Drag-and-drop ordering
- EXIF data extraction
- Album metadata editing
### 4. Edra Custom Image Block
```typescript
// Custom image block schema for Edra
const ImageBlock = {
type: "image",
content: {
mediaId: number,
url: string,
thumbnailUrl?: string,
alt?: string,
caption?: string,
width?: number,
height?: number,
alignment?: "left" | "center" | "right" | "full"
}
}
// Example Edra content with images
{
"blocks": [
{ "type": "heading", "content": "Project Overview" },
{ "type": "paragraph", "content": "This project..." },
{
"type": "image",
"content": {
"mediaId": 123,
"url": "https://cdn.../full.jpg",
"thumbnailUrl": "https://cdn.../thumb.jpg",
"alt": "Project screenshot",
"caption": "The main dashboard view",
"alignment": "full"
}
},
{ "type": "paragraph", "content": "As shown above..." }
]
}
```
### 5. Media Library System
#### Media Library Component
- **Modal Interface**: Opens from Edra toolbar, form fields, or Browse Library buttons
- **Features**:
- Grid and list view modes for uploaded media
- Search by filename and filter by type (image/video/audio/pdf)
- Usage information showing where each media is used
- Alt text editing and accessibility features
- Upload new files directly from modal
- Single and multi-select functionality
- **Returns**: Media object with ID and URLs
#### Multiselect & Bulk Operations
- **Selection Interface**: Checkbox-based selection in both grid and list views
- **Bulk Actions**:
- Select All / Clear Selection controls
- Bulk delete with confirmation
- Progress indicators and loading states
- **Safe Deletion**: Automatic reference cleanup across all content types
- **Reference Tracking**: Shows exactly where each media file is used before deletion
### 6. Image Processing Pipeline
1. **Upload**: User drops/selects image
2. **Processing**:
- Generate unique filename
- Create multiple sizes:
- Thumbnail (300px)
- Medium (800px)
- Large (1600px)
- Original
- Extract metadata (dimensions, EXIF)
3. **Storage**: Upload to CDN
4. **Database**: Create media record with all URLs
5. **Association**: Update `used_in` when embedded
### 7. API Endpoints
```typescript
// Projects
GET /api/projects
POST /api/projects
GET /api/projects/[slug]
PUT /api/projects/[id]
DELETE /api/projects/[id]
// Posts
GET /api/posts
POST /api/posts
GET /api/posts/[slug]
PUT /api/posts/[id]
DELETE /api/posts/[id]
// Albums & Photos
GET /api/albums
POST /api/albums
GET /api/albums/[slug]
PUT /api/albums/[id]
DELETE /api/albums/[id]
POST /api/albums/[id]/photos
DELETE /api/photos/[id]
PUT /api/photos/[id]/order
// Media Management
POST /api/media/upload // Single file upload
POST /api/media/bulk-upload // Multiple file upload
GET /api/media // Browse with filters, pagination
GET /api/media/[id] // Get single media item
PUT /api/media/[id] // Update media (alt text, description)
DELETE /api/media/[id] // Delete single media item
DELETE /api/media/bulk-delete // Delete multiple media items
GET /api/media/[id]/usage // Check where media is used
POST /api/media/backfill-usage // Backfill usage tracking for existing content
```
### 8. Media Management & Cleanup
#### Advanced Usage Tracking
- **MediaUsage Table**: Dedicated table for precise tracking of media usage
- **Automatic Tracking**: All content saves automatically update usage references
- **Field-Level Tracking**: Tracks specific fields (featuredImage, gallery, content, attachments)
- **Content Type Support**: Projects, Posts, Albums with full reference tracking
- **Real-time Usage Display**: Shows exactly where each media file is used
#### Safe Deletion System
- **Usage Validation**: Prevents deletion if media is in use (unless forced)
- **Reference Cleanup**: Automatically removes deleted media from all content
- **Bulk Operations**: Multi-select deletion with comprehensive reference cleanup
- **Rich Text Cleanup**: Removes deleted media from Edra editor content (images, galleries)
- **Atomic Operations**: All-or-nothing deletion ensures data consistency
#### Edra Integration Details
```javascript
// Custom upload handler for BlockNote
const handleImageUpload = async (file) => {
const formData = new FormData()
formData.append('file', file)
formData.append('context', 'post') // or 'project'
const response = await fetch('/api/media/upload', {
method: 'POST',
body: formData
})
const media = await response.json()
// Return format expected by Edra
return {
mediaId: media.id,
url: media.url,
thumbnailUrl: media.thumbnail_url,
width: media.width,
height: media.height
}
}
```
### 9. Admin Interface
- **Route**: `/admin` (completely separate from public routes)
- **Dashboard**: Overview of all content types with quick stats
- **Content Lists**:
- Projects with preview thumbnails and status indicators
- Posts with publish status and type badges
- Albums with photo counts and metadata
- **Content Editors**: Type-specific editing interfaces with rich text support
- **Media Library**: Comprehensive media management with:
- Grid and list view modes
- Advanced search and filtering
- Usage tracking and reference display
- Alt text editing and accessibility features
- Bulk operations with multiselect interface
- Safe deletion with reference cleanup
### 10. Public Display Integration
- **Work page**: Dynamic project grid from database
- **Universe page**:
- Mixed feed of posts and albums (marked with `show_in_universe`)
- Chronological ordering
- Different card styles for posts vs photo albums
- **Photos page**: Album grid with masonry layout
- **Individual pages**: `/work/[slug]`, `/universe/[slug]`, `/photos/[slug]`
## Implementation Phases
### Phase 1: Foundation (Week 1)
- Set up PostgreSQL database with full schema
- Create database connection utilities
- Implement media upload infrastructure
- Build admin route structure and navigation
### Phase 2: Content Types (Week 2-3)
- **Posts**: Edra integration, CRUD APIs
- **Projects**: Form builder, gallery management
- **Albums/Photos**: Bulk upload, EXIF extraction
- Create content type list views in admin
### Phase 3: Public Display (Week 4)
- Replace static project data with dynamic
- Build Universe mixed feed (posts + albums)
- Update Photos page with dynamic albums
- Implement individual content pages
### Phase 4: Polish & Optimization (Week 5)
- Image optimization and CDN caching
- Admin UI improvements
- Search and filtering
- Performance optimization
## Technical Decisions
### Database Choice: PostgreSQL
- Native JSON support for Edra content
- Railway provides managed PostgreSQL
- Familiar, battle-tested solution
### Media Storage Options
1. **Cloudinary** (Recommended)
- Free tier sufficient for personal use
- Automatic image optimization
- Easy API integration
2. **AWS S3**
- More control but requires AWS account
- Additional complexity for signed URLs
### Image Integration Summary
- **Posts**: Use Edra's custom image blocks with inline placement
- **Projects**:
- Featured image: Single media reference
- Gallery: Array of media IDs with ordering
- Case studies: Edra blocks (same as posts)
- **Albums**: Direct photos table relationship
- **Storage**: All images go through media table for consistent handling
- **Association**: Track usage with `used_in` JSONB field to prevent orphans
### Authentication (Future)
- Initially: No auth (rely on obscure admin URL)
- Future: Add simple password protection or OAuth
## Development Checklist
### Infrastructure
- [ ] Set up PostgreSQL on Railway
- [ ] Create database schema and migrations
- [ ] Set up Cloudinary/S3 for media storage
- [ ] Configure environment variables
### Dependencies
- [x] `edra` (Edra editor) - Integrated and configured
- [x] `@prisma/client` - Set up with complete schema
- [x] `cloudinary` - SDK integrated for image processing and storage
- [x] Form validation with built-in validation
- [ ] `exifr` for EXIF data extraction (needed for photos system)
### Admin Interface
- [x] Admin layout and navigation
- [x] Content type switcher (Dashboard, Projects, Universe, Media)
- [x] List views for projects and posts
- [x] Complete form system for Projects (metadata, branding, styling)
- [x] Edra wrapper for Posts with all post types
- [x] Comprehensive admin component library
- [ ] Photo uploader with drag-and-drop (for albums system)
- [ ] Media library browser modal
### APIs
- [x] CRUD endpoints for projects and posts
- [x] Media upload with progress
- [x] Bulk upload operations for media
- [x] Media usage tracking endpoints
- [ ] Albums CRUD endpoints (schema ready, UI needed)
- [ ] Bulk operations (delete, publish) for content
- [ ] Search and filtering endpoints
### Public Display
- [ ] Dynamic Work page
- [ ] Mixed Universe feed
- [ ] Photos masonry grid
- [ ] Individual content pages
- [ ] SEO meta tags
## Design Decisions
Based on requirements discussion:
1. **Albums**: No featured flag needed
2. **Version History**: Nice-to-have feature for future implementation
3. **Photo Publishing**: Individual photos can be published separately from albums
4. **Project Templates**: Defer case study layout templates for later phase
5. **Scheduled Publishing**: Not needed initially
6. **RSS Feeds**: Required for all content types (projects, posts, photos)
7. **Post Types**: Simplified to two main types:
- **Post**: Simple content with optional attachments (replaces microblog, link, photo posts)
- **Essay**: Full editor with title/metadata + optional attachments (replaces blog posts)
8. **Albums & Photo Curation**: Albums serve dual purposes:
- **Regular Albums**: Collections for case studies, UI galleries, design process
- **Photography Albums**: Curated collections for photo-centric experience
- Both album and media levels have `isPhotography` flags for flexible curation
9. **Photo Curation Strategy**: Media items can be "starred for photos" regardless of usage context
- Same photo can exist in posts AND photo collections
- Editorial control over what constitutes "photography" vs "UI screenshots/sketches"
- Photography albums can contain mixed content if editorially appropriate
## Current Status (June 2024)
### Completed
- ✅ Database setup with Prisma and PostgreSQL
- ✅ Media management system with Cloudinary integration
- ✅ Admin foundation (layout, navigation, auth, forms, data tables)
- ✅ Edra rich text editor integration for case studies and posts
- ✅ Edra image and gallery extensions with MediaLibraryModal integration
- ✅ Local development mode for media uploads (no Cloudinary usage)
- ✅ Project CRUD system with metadata fields and enhanced schema
- ✅ Project list view in admin with enhanced UI
- ✅ Project forms with branding (logo, colors) and styling
- ✅ Posts CRUD system with all post types (blog, microblog, link, photo, album)
- ✅ Posts attachments field for multiple image support
- ✅ Posts list view and editor in admin
- ✅ Complete database schema with MediaUsage tracking table
- ✅ Media API endpoints with upload, bulk upload, and usage tracking
- ✅ Component library for admin interface (buttons, inputs, modals, etc.)
- ✅ 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
- 🔄 Content Simplification & Photo Curation System
### Next Steps
1. **Content Model Updates** (Immediate Priority)
- Add `isPhotography` field to Media and Album tables via migration
- Simplify post types to just "post" and "essay"
- Update post creation UI to use simplified types
- Add photography toggle to media details modal
- Add photography indicator pills in admin interface
2. **Albums & Photos Management Interface**
- Album creation and management UI with photography toggle
- Bulk photo upload interface with progress
- Photo ordering within albums
- Album cover selection
- EXIF data extraction and display
- Photography album filtering and management
3. **Enhanced Content Features**
- Featured image picker for projects (using MediaLibraryModal)
- Technology tag selector for projects
- Auto-save functionality for all editors
- 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
### Phase 0: Local Development Setup
- [x] Install local PostgreSQL (via Homebrew or Postgres.app)
- [x] Create local database
- [x] Set up local environment variables
- [x] Run Prisma migrations locally
- [x] Create mock data for testing
- [x] Test basic CRUD operations locally
### Phase 1: Database & Infrastructure Setup
- [x] Create all database tables with updated schema
- [x] Set up Prisma ORM with models
- [x] Create base API route structure
- [x] Implement database connection utilities
- [x] Set up error handling and logging
- [ ] Configure Cloudinary account (deferred to production setup)
- [ ] Set up PostgreSQL on Railway (deferred to production setup)
### Phase 2: Media Management System
- [x] Create media upload endpoint with Cloudinary integration
- [x] Implement image processing pipeline (multiple sizes)
- [x] Build media library API endpoints with pagination and filtering
- [x] Create advanced MediaUsage tracking system
- [x] Add bulk upload endpoint for photos
- [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
- [x] Create admin layout component
- [x] Build admin navigation with content type switcher
- [x] Implement admin authentication (basic for now)
- [x] Create reusable form components (Button, Input, Modal, etc.)
- [x] Build data table component for list views
- [x] Add loading and error states
- [x] Create comprehensive admin UI component library
- [x] Build complete media library system with modals and management
### Phase 4: Posts System (All Types)
- [x] Create Edra Svelte wrapper component
- [x] Implement custom image and gallery blocks for Edra
- [x] Build post type selector UI
- [x] Create blog/microblog post editor
- [x] Build link post form
- [x] Create posts list view in admin
- [x] Implement post CRUD APIs with attachments support
- [x] Post editor page with type-specific fields
- [x] Complete posts database schema with attachments field
- [x] Posts administration interface
- [x] UniverseComposer with photo attachment support
- [x] Integrate MediaLibraryModal with Edra editor
- [ ] Build album post selector (needs albums system)
- [ ] Add auto-save functionality
### Phase 5: Projects System
- [x] Build project form with all metadata fields
- [x] Enhanced schema with branding fields (logo, colors)
- [x] Project branding and styling forms with ImageUploader and GalleryUploader
- [x] Add optional Edra editor for case studies with media support
- [x] Create project CRUD APIs with usage tracking
- [x] Build project list view with enhanced UI
- [x] Integrate Browse Library functionality in project forms
- [ ] Create technology tag selector
- [ ] Build gallery manager with drag-and-drop ordering
- [ ] Add project ordering functionality
### Phase 6: Content Simplification & Photo Curation
- [x] Add `isPhotography` field to Media table (migration)
- [x] Add `isPhotography` field to Album table (migration)
- [x] Simplify post types to "post" and "essay" only
- [x] Update UniverseComposer to use simplified post types
- [x] Add photography toggle to MediaDetailsModal
- [x] Add photography indicator pills throughout admin interface
- [x] Update media and album APIs to handle photography flags
### Phase 7: Photos & Albums System
- [x] Complete database schema for albums and photos
- [x] Photo/album CRUD API endpoints (albums endpoint exists)
- [x] Create album management interface with photography toggle
- [x] **Album Photo Management** (Core functionality complete)
- [x] Add photos to albums interface using MediaLibraryModal
- [x] Remove photos from albums with confirmation
- [x] Photo grid display with hover overlays
- [x] Album-photo relationship API endpoints (POST /api/albums/[id]/photos, DELETE /api/photos/[id])
- [ ] Photo reordering within albums (drag-and-drop)
- [ ] Album cover photo selection
- [ ] Build bulk photo uploader with progress
- [ ] Implement EXIF data extraction for photos
- [ ] Add individual photo publishing UI
- [ ] Build photo metadata editor
- [ ] Add photography album filtering and management
- [ ] Add "show in universe" toggle for albums
### Phase 8: Public Display Updates
- [x] Replace static Work page with dynamic data
- [x] Update project detail pages
- [x] Build Universe mixed feed component
- [x] Create different card types for each post type
- [x] Update Photos page with dynamic albums/photos
- [x] Implement individual photo pages
- [x] Add Universe post detail pages
- [ ] Ensure responsive design throughout
### Phase 9: RSS Feeds & Final Polish
- [ ] Implement RSS feed for projects
- [ ] Create RSS feed for Universe posts
- [ ] Add RSS feed for photos/albums
- [ ] Implement combined RSS feed
- [ ] Add OpenGraph meta tags
- [ ] Optimize image loading and caching
- [ ] Add search functionality to admin
- [ ] Performance optimization pass
### Phase 10: Production Deployment
- [ ] Set up PostgreSQL on Railway
- [ ] Run migrations on production database
- [ ] Configure Cloudinary for production
- [ ] Set up environment variables on Railway
- [ ] Test all endpoints in production
- [ ] Set up database backups
- [ ] Configure proper authentication
- [ ] Monitor logs and performance
### Future Enhancements (Post-Launch)
- [ ] Version history system
- [ ] More robust authentication
- [ ] Project case study templates
- [ ] Advanced media organization (folders/tags)
- [ ] Analytics integration
- [ ] Backup system
## Albums & Photos System Implementation
### Design Decisions Made (May 2024)
1. **Simplified Post Types**: Reduced from 5 types (blog, microblog, link, photo, album) to 2 types:
- **Post**: Simple content with optional attachments (handles previous microblog, link, photo use cases)
- **Essay**: Full editor with title/metadata + attachments (handles previous blog use cases)
2. **Photo Curation Strategy**: Dual-level curation system:
- **Media Level**: `isPhotography` boolean - stars individual media for photo experience
- **Album Level**: `isPhotography` boolean - marks entire albums for photo experience
- **Mixed Content**: Photography albums can contain non-photography media (Option A)
- **Default Behavior**: Both flags default to `false` to prevent accidental photo inclusion
3. **Visual Indicators**: Pill-shaped tags to indicate photography status in admin interface
4. **Album Flexibility**: Albums serve multiple purposes:
- Regular albums for case studies, UI collections, design process
- Photography albums for curated photo experience (Japan Trip, Street Photography)
- Same album system, different curation flags
### Implementation Task List
#### Phase 1: Database Updates
- [x] Create migration to add `isPhotography` field to Media table
- [x] Create migration to add `isPhotography` field to Album table
- [x] Update Prisma schema with new fields
- [x] Test migrations on local database
#### Phase 2: API Updates
- [x] Update Media API endpoints to handle `isPhotography` flag
- [x] Update Album API endpoints to handle `isPhotography` flag
- [x] Update media usage tracking to work with new flags
- [x] Add filtering capabilities for photography content
#### Phase 3: Admin Interface Updates
- [x] Add photography toggle to MediaDetailsModal
- [x] Add photography indicator pills for media items (grid and list views)
- [x] Add photography indicator pills for albums
- [x] Update media library filtering to include photography status
- [x] Add bulk photography operations (mark/unmark multiple items)
#### Phase 4: Post Type Simplification
- [x] Update UniverseComposer to use only "post" and "essay" types
- [x] Remove complex post type selector UI
- [x] Update post creation flows
- [x] Migrate existing posts to simplified types (if needed)
- [x] Update post display logic to handle simplified types
#### Phase 5: Album Management System
- [x] Create album creation/editing interface with photography toggle
- [x] Build album list view with photography indicators
- [ ] **Critical Missing Feature: Album Photo Management**
- [ ] Add photo management section to album edit page
- [ ] Implement "Add Photos from Library" functionality using MediaLibraryModal
- [ ] Create photo grid display within album editor
- [ ] Add remove photo functionality (individual photos)
- [ ] Implement drag-and-drop photo reordering within albums
- [ ] Add album cover photo selection interface
- [ ] Update album API to handle photo associations
- [ ] Create album-photo relationship endpoints
- [ ] Add bulk photo upload to albums with automatic photography detection
#### Phase 6: Photography Experience
- [ ] Build photography album filtering in admin
- [ ] Create photography-focused views and workflows
- [ ] Add batch operations for photo curation
- [ ] Implement photography album public display
- [ ] Add photography vs regular album distinction in frontend
### Success Criteria
- Admin can quickly toggle media items between regular and photography status
- Albums can be easily marked for photography experience
- Post creation is simplified to 2 clear choices
- Photography albums display correctly in public photos section
- Mixed content albums (photography + other) display all content as intended
- Pill indicators clearly show photography status throughout admin interface
## Success Metrics
- Can create and publish any content type within 2-3 minutes
- Content appears on site immediately after publishing
- Bulk photo upload handles 50+ images smoothly
- No accidental data loss (auto-save works reliably)
- Page load performance remains fast (<2s)
- Admin interface works well on tablet/desktop
- Media uploads show progress and handle failures gracefully
- RSS feeds update automatically with new content

610
PRD-enhanced-tag-system.md Normal file
View file

@ -0,0 +1,610 @@
# Product Requirements Document: Enhanced Tag System
## Overview
Upgrade the current JSON-based tag system to a relational database model with advanced tagging features including tag filtering, related posts, tag management, and an improved tag input UI with typeahead functionality.
## Goals
- Enable efficient querying and filtering of posts by tags
- Provide tag management capabilities for content curation
- Show related posts based on shared tags
- Implement intuitive tag input with typeahead and keyboard shortcuts
- Build analytics and insights around tag usage
- Maintain backward compatibility during migration
## Technical Constraints
- **Framework**: SvelteKit with Svelte 5 runes mode
- **Database**: PostgreSQL with Prisma ORM
- **Hosting**: Railway (existing infrastructure)
- **Design System**: Use existing admin component library
- **Performance**: Tag operations should be sub-100ms
## Current State vs Target State
### Current Implementation
- Tags stored as JSON arrays: `tags: ['announcement', 'meta', 'cms']`
- Simple display-only functionality
- No querying capabilities
- Manual tag input with Add button
### Target Implementation
- Relational many-to-many tag system
- Full CRUD operations for tags
- Advanced filtering and search
- Typeahead tag input with keyboard navigation
- Tag analytics and management interface
## Database Schema Changes
### New Tables
```sql
-- Tags table
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
color VARCHAR(7), -- Hex color for tag styling
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Post-Tag junction table
CREATE TABLE post_tags (
id SERIAL PRIMARY KEY,
post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(post_id, tag_id)
);
-- Tag usage analytics (optional)
CREATE TABLE tag_analytics (
id SERIAL PRIMARY KEY,
tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
usage_count INTEGER DEFAULT 1,
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### Prisma Schema Updates
```prisma
model Tag {
id Int @id @default(autoincrement())
name String @unique @db.VarChar(100)
slug String @unique @db.VarChar(100)
description String? @db.Text
color String? @db.VarChar(7) // Hex color
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
posts PostTag[]
@@index([name])
@@index([slug])
}
model PostTag {
id Int @id @default(autoincrement())
postId Int
tagId Int
createdAt DateTime @default(now())
// Relations
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@unique([postId, tagId])
@@index([postId])
@@index([tagId])
}
// Update existing Post model
model Post {
// ... existing fields
tags PostTag[] // Replace: tags Json?
}
```
## Core Features
### 1. Tag Management Interface
#### Admin Tag Manager (`/admin/tags`)
- **Tag List View**
- DataTable with tag name, usage count, created date
- Search and filter capabilities
- Bulk operations (delete, merge, rename)
- Color coding and visual indicators
- **Tag Detail/Edit View**
- Edit tag name, description, color
- View all posts using this tag
- Usage analytics and trends
- Merge with other tags functionality
#### Tag Analytics Dashboard
- **Usage Statistics**
- Most/least used tags
- Tag usage trends over time
- Orphaned tags (no posts)
- Tag co-occurrence patterns
- **Tag Insights**
- Suggested tag consolidations
- Similar tags detection
- Tag performance metrics
### 2. Enhanced Tag Input Component (`TagInput.svelte`)
#### Features
- **Typeahead Search**: Real-time search of existing tags
- **Keyboard Navigation**: Arrow keys to navigate suggestions
- **Instant Add**: Press Enter to add tag without button click
- **Visual Feedback**: Highlight matching text in suggestions
- **Tag Validation**: Prevent duplicates and invalid characters
- **Quick Actions**: Backspace to remove last tag
#### Component API
```typescript
interface TagInputProps {
tags: string[] | Tag[] // Current tags
suggestions?: Tag[] // Available tags for typeahead
placeholder?: string // Input placeholder text
maxTags?: number // Maximum number of tags
allowNew?: boolean // Allow creating new tags
size?: 'small' | 'medium' | 'large'
disabled?: boolean
onTagAdd?: (tag: Tag) => void
onTagRemove?: (tag: Tag) => void
onTagCreate?: (name: string) => void
}
```
#### Svelte 5 Implementation
```svelte
<script lang="ts">
let {
tags = $bindable([]),
suggestions = [],
placeholder = "Add tags...",
maxTags = 10,
allowNew = true,
size = 'medium',
disabled = false,
onTagAdd,
onTagRemove,
onTagCreate
}: TagInputProps = $props()
let inputValue = $state('')
let showSuggestions = $state(false)
let selectedIndex = $state(-1)
let inputElement: HTMLInputElement
// Filtered suggestions based on input
let filteredSuggestions = $derived(
suggestions.filter(tag =>
tag.name.toLowerCase().includes(inputValue.toLowerCase()) &&
!tags.some(t => t.id === tag.id)
)
)
// Handle keyboard navigation
function handleKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'Enter':
e.preventDefault()
if (selectedIndex >= 0) {
addExistingTag(filteredSuggestions[selectedIndex])
} else if (inputValue.trim() && allowNew) {
createNewTag(inputValue.trim())
}
break
case 'ArrowDown':
e.preventDefault()
selectedIndex = Math.min(selectedIndex + 1, filteredSuggestions.length - 1)
break
case 'ArrowUp':
e.preventDefault()
selectedIndex = Math.max(selectedIndex - 1, -1)
break
case 'Backspace':
if (!inputValue && tags.length > 0) {
removeTag(tags[tags.length - 1])
}
break
case 'Escape':
showSuggestions = false
selectedIndex = -1
break
}
}
</script>
```
### 3. Post Filtering by Tags
#### Frontend Components
- **Tag Filter Bar**: Multi-select tag filtering
- **Tag Cloud**: Visual tag representation with usage counts
- **Search Integration**: Combine text search with tag filters
#### API Endpoints
```typescript
// GET /api/posts?tags=javascript,react&operation=AND
// GET /api/posts?tags=design,ux&operation=OR
interface PostsQueryParams {
tags?: string[] // Tag names or IDs
operation?: 'AND' | 'OR' // How to combine multiple tags
page?: number
limit?: number
status?: 'published' | 'draft'
}
// GET /api/tags/suggest?q=java
interface TagSuggestResponse {
tags: Array<{
id: number
name: string
slug: string
usageCount: number
}>
}
```
### 4. Related Posts Feature
#### Implementation
- **Algorithm**: Find posts sharing the most tags
- **Weighting**: Consider tag importance and recency
- **Exclusions**: Don't show current post in related list
- **Limit**: Show 3-6 related posts maximum
#### Component (`RelatedPosts.svelte`)
```svelte
<script lang="ts">
let { postId, tags, limit = 4 }: {
postId: number
tags: Tag[]
limit?: number
} = $props()
let relatedPosts = $state<Post[]>([])
$effect(async () => {
const tagIds = tags.map(t => t.id)
const response = await fetch(`/api/posts/related?postId=${postId}&tagIds=${tagIds.join(',')}&limit=${limit}`)
relatedPosts = await response.json()
})
</script>
```
## API Specification
### Tag Management APIs
```typescript
// GET /api/tags - List all tags
interface TagsResponse {
tags: Tag[]
total: number
page: number
limit: number
}
// POST /api/tags - Create new tag
interface CreateTagRequest {
name: string
description?: string
color?: string
}
// PUT /api/tags/[id] - Update tag
interface UpdateTagRequest {
name?: string
description?: string
color?: string
}
// DELETE /api/tags/[id] - Delete tag
// Returns: 204 No Content
// POST /api/tags/merge - Merge tags
interface MergeTagsRequest {
sourceTagIds: number[]
targetTagId: number
}
// GET /api/tags/[id]/posts - Get posts for tag
interface TagPostsResponse {
posts: Post[]
tag: Tag
total: number
}
// GET /api/tags/analytics - Tag usage analytics
interface TagAnalyticsResponse {
mostUsed: Array<{ tag: Tag; count: number }>
leastUsed: Array<{ tag: Tag; count: number }>
trending: Array<{ tag: Tag; growth: number }>
orphaned: Tag[]
}
```
### Enhanced Post APIs
```typescript
// GET /api/posts/related?postId=123&tagIds=1,2,3&limit=4
interface RelatedPostsResponse {
posts: Array<{
id: number
title: string
slug: string
excerpt?: string
publishedAt: string
tags: Tag[]
sharedTagsCount: number // Number of tags in common
}>
}
// PUT /api/posts/[id]/tags - Update post tags
interface UpdatePostTagsRequest {
tagIds: number[]
}
```
## User Interface Components
### 1. TagInput Component Features
#### Visual States
- **Default**: Clean input with placeholder
- **Focused**: Show suggestions dropdown
- **Typing**: Filter and highlight matches
- **Selected**: Navigate with keyboard
- **Adding**: Smooth animation for new tags
- **Full**: Disable input when max tags reached
#### Accessibility
- **ARIA Labels**: Proper labeling for screen readers
- **Keyboard Navigation**: Full keyboard accessibility
- **Focus Management**: Logical tab order
- **Announcements**: Screen reader feedback for actions
### 2. Tag Display Components
#### TagPill Component
```svelte
<script lang="ts">
let {
tag,
size = 'medium',
removable = false,
clickable = false,
showCount = false,
onRemove,
onClick
}: TagPillProps = $props()
</script>
<span
class="tag-pill tag-pill-{size}"
style="--tag-color: {tag.color}"
class:clickable
class:removable
onclick={onClick}
>
{tag.name}
{#if showCount}
<span class="tag-count">({tag.usageCount})</span>
{/if}
{#if removable}
<button onclick={onRemove} class="tag-remove">×</button>
{/if}
</span>
```
#### TagCloud Component
```svelte
<script lang="ts">
let {
tags,
maxTags = 50,
minFontSize = 12,
maxFontSize = 24,
onClick
}: TagCloudProps = $props()
// Calculate font sizes based on usage
let tagSizes = $derived(calculateTagSizes(tags, minFontSize, maxFontSize))
</script>
```
### 3. Admin Interface Updates
#### Posts List with Tag Filtering
- **Filter Bar**: Multi-select tag filter above posts list
- **Tag Pills**: Show tags on each post item
- **Quick Filter**: Click tag to filter by that tag
- **Clear Filters**: Easy way to reset all filters
#### Posts Edit Form Integration
- **Replace Current**: Swap existing tag input with new TagInput
- **Preserve UX**: Maintain current metadata popover
- **Tag Management**: Quick access to create/edit tags
## Migration Strategy
### Phase 1: Database Migration (Week 1)
1. **Create Migration Script**
- Create new tables (tags, post_tags)
- Migrate existing JSON tags to relational format
- Create indexes for performance
2. **Data Migration**
- Extract unique tags from existing posts
- Create tag records with auto-generated slugs
- Create post_tag relationships
- Validate data integrity
3. **Backward Compatibility**
- Keep original tags JSON field temporarily
- Dual-write to both systems during transition
### Phase 2: API Development (Week 1-2)
1. **Tag Management APIs**
- CRUD operations for tags
- Tag suggestions and search
- Analytics endpoints
2. **Enhanced Post APIs**
- Update post endpoints for relational tags
- Related posts algorithm
- Tag filtering capabilities
3. **Testing & Validation**
- Unit tests for all endpoints
- Performance testing for queries
- Data consistency checks
### Phase 3: Frontend Components (Week 2-3)
1. **Core Components**
- TagInput with typeahead
- TagPill and TagCloud
- Tag management interface
2. **Integration**
- Update MetadataPopover
- Add tag filtering to posts list
- Implement related posts component
3. **Admin Interface**
- Tag management dashboard
- Analytics views
- Bulk operations interface
### Phase 4: Features & Polish (Week 3-4)
1. **Advanced Features**
- Tag merging functionality
- Usage analytics
- Tag suggestions based on content
2. **Performance Optimization**
- Query optimization
- Caching strategies
- Load testing
3. **Cleanup**
- Remove JSON tags field
- Documentation updates
- Final testing
## Success Metrics
### Performance
- Tag search responses under 50ms
- Post filtering responses under 100ms
- Page load times maintained or improved
### Usability
- Reduced clicks to add tags (eliminate Add button)
- Faster tag input with typeahead
- Improved content discovery through related posts
### Content Management
- Ability to merge duplicate tags
- Insights into tag usage patterns
- Better content organization capabilities
### Analytics
- Track tag usage growth over time
- Identify content gaps through tag analysis
- Measure impact on content engagement
## Technical Considerations
### Performance
- **Database Indexes**: Proper indexing on tag names and relationships
- **Query Optimization**: Efficient joins for tag filtering
- **Caching**: Cache popular tag lists and related posts
- **Pagination**: Handle large tag lists efficiently
### Data Integrity
- **Constraints**: Prevent duplicate tag names
- **Cascading Deletes**: Properly handle tag/post deletions
- **Validation**: Ensure tag names follow naming conventions
- **Backup Strategy**: Safe migration with rollback capability
### User Experience
- **Progressive Enhancement**: Graceful degradation if JS fails
- **Loading States**: Smooth loading indicators
- **Error Handling**: Clear error messages for users
- **Responsive Design**: Works well on all device sizes
## Future Enhancements
### Advanced Features (Post-MVP)
- **Hierarchical Tags**: Parent/child tag relationships
- **Tag Synonyms**: Alternative names for the same concept
- **Auto-tagging**: ML-based tag suggestions from content
- **Tag Templates**: Predefined tag sets for different content types
### Integrations
- **External APIs**: Import tags from external sources
- **Search Integration**: Enhanced search with tag faceting
- **Analytics**: Deep tag performance analytics
- **Content Recommendations**: AI-powered related content
## Risk Assessment
### High Risk
- **Data Migration**: Complex migration of existing tag data
- **Performance Impact**: New queries might affect page load times
- **User Adoption**: Users need to learn new tag input interface
### Mitigation Strategies
- **Staged Rollout**: Deploy to staging first, then gradual production rollout
- **Performance Monitoring**: Continuous monitoring during migration
- **User Training**: Clear documentation and smooth UX transitions
- **Rollback Plan**: Ability to revert to JSON tags if needed
## Success Criteria
### Must Have
- ✅ All existing tags migrated successfully
- ✅ Tag input works with keyboard-only navigation
- ✅ Posts can be filtered by single or multiple tags
- ✅ Related posts show based on shared tags
- ✅ Performance remains acceptable (< 100ms for most operations)
### Should Have
- ✅ Tag management interface for admins
- ✅ Tag usage analytics and insights
- ✅ Ability to merge duplicate tags
- ✅ Tag color coding and visual improvements
### Could Have
- Tag auto-suggestions based on post content
- Tag trending and popularity metrics
- Advanced tag analytics and reporting
- Integration with external tag sources
## Timeline
**Total Duration**: 4 weeks
- **Week 1**: Database migration and API development
- **Week 2**: Core frontend components and basic integration
- **Week 3**: Advanced features and admin interface
- **Week 4**: Polish, testing, and production deployment
## Conclusion
This enhanced tag system will significantly improve content organization, discoverability, and management capabilities while providing a modern, intuitive user interface built with Svelte 5 runes. The migration strategy ensures minimal disruption while delivering substantial improvements in functionality and user experience.

576
PRD-media-library.md Normal file
View file

@ -0,0 +1,576 @@
# Product Requirements Document: Media Library Modal System
## 🎉 **PROJECT STATUS: CORE IMPLEMENTATION COMPLETE!**
We have successfully implemented a comprehensive Media Library system with both direct upload workflows and library browsing capabilities. **All major components are functional and integrated throughout the admin interface.**
### 🏆 Major Achievements
- **✅ Complete MediaLibraryModal system** with single/multiple selection
- **✅ Enhanced upload components** (ImageUploader, GalleryUploader) with MediaLibraryModal integration
- **✅ Full form integration** across projects, posts, albums, and editor
- **✅ Alt text support** throughout upload and editing workflows
- **✅ Edra editor integration** with `/image` and `/gallery` slash commands
- **✅ Media Library management** with clickable editing and metadata support
## Overview
Implement a comprehensive Media Library modal system that provides a unified interface for browsing, selecting, and managing media across all admin forms. **The primary workflow is direct upload from computer within forms**, with the Media Library serving as a secondary browsing interface and management tool for previously uploaded content.
## 📋 Updated Approach Summary
**🎯 Primary Focus**: Direct upload components that allow users to drag-and-drop or browse files directly within project/post/album forms, with immediate preview and alt text capture.
**🎯 Secondary Feature**: Media Library modal for selecting previously uploaded content when needed.
**🎯 Key Addition**: Alt text storage and editing capabilities for accessibility compliance and SEO.
## Goals
### Primary Goals (Direct Upload Workflow)
- **Enable direct file upload within forms** where content will be used (projects, posts, albums)
- **Provide immediate upload and preview** without requiring navigation to separate media management
- **Store comprehensive metadata** including alt text for accessibility and SEO
- **Support drag-and-drop and click-to-browse** for intuitive file selection
### Secondary Goals (Media Library Browser)
- Create a reusable media browser for **selecting previously uploaded content**
- Provide **media management interface** showing where files are referenced
- Enable **bulk operations** and **metadata editing** (especially alt text)
- Support **file organization** and **usage tracking**
### Technical Goals
- Maintain consistent UX across all media interactions
- Support different file type filtering based on context
- Integrate seamlessly with existing admin components
## Current State Analysis
### ✅ What We Have
- Complete media API (`/api/media`, `/api/media/upload`, `/api/media/bulk-upload`)
- Media management page with grid/list views and search/filtering
- Modal base component (`Modal.svelte`)
- Complete admin UI component library (Button, Input, etc.)
- Media upload infrastructure with Cloudinary integration
- Pagination and search functionality
- **✅ Database schema with alt text support** (altText field in Media table)
- **✅ MediaLibraryModal component** with single/multiple selection modes
- **✅ ImageUploader and GalleryUploader components** with MediaLibraryModal integration
- **✅ Enhanced admin form components** with Browse Library functionality
- **✅ Media details editing** with alt text support in Media Library page
- **✅ Edra editor integration** with image and gallery support via slash commands
### 🎯 What We Need
#### High Priority (Remaining Tasks)
- **Enhanced upload features** with drag & drop zones in all upload components
- **Bulk alt text editing** in Media Library for existing content
- **Usage tracking display** showing where media is referenced
- **Performance optimizations** for large media libraries
#### Medium Priority (Polish & Advanced Features)
- **Image optimization options** during upload
- **Advanced search capabilities** (by alt text, usage, etc.)
- **Bulk operations** (delete multiple, bulk metadata editing)
#### Low Priority (Future Enhancements)
- **AI-powered alt text suggestions**
- **Duplicate detection** and management
- **Advanced analytics** and usage reporting
## Workflow Priorities
### 🥇 Primary Workflow: Direct Upload in Forms
This is the **main workflow** that users will use 90% of the time:
1. **User creates content** (project, post, album)
2. **User uploads files directly** in the form where they'll be used
3. **Files are immediately processed** and previewed
4. **Alt text and metadata** are captured during upload
5. **Content is saved** with proper media references
**Key Components**:
- `ImageUploader` - Direct drag-and-drop/click upload with preview
- `GalleryUploader` - Multiple file upload with immediate gallery preview
- `MediaMetadataForm` - Alt text and description capture during upload
### 🥈 Secondary Workflow: Browse Existing Media
This workflow is for **reusing previously uploaded content**:
1. **User needs to select existing media** (rare case)
2. **User clicks "Browse Library"** (secondary button)
3. **Media Library Modal opens** showing all uploaded files
4. **User selects from existing content**
5. **Media references are updated**
**Key Components**:
- `MediaLibraryModal` - Browse and select existing media
- `MediaSelector` - Grid interface for selection
- `MediaManager` - Edit alt text and view usage
## Technical Requirements
### 1. Enhanced Upload Components (Primary)
#### ImageUploader Component
**Purpose**: Direct image upload with immediate preview and metadata capture
```typescript
interface ImageUploaderProps {
label: string
value?: Media | null
onUpload: (media: Media) => void
aspectRatio?: string
required?: boolean
error?: string
allowAltText?: boolean // Enable alt text input
maxFileSize?: number // MB limit
}
```
**Features**:
- Drag-and-drop upload zone with visual feedback
- Click to browse files from computer
- Immediate image preview with proper aspect ratio
- Alt text input field (when enabled)
- Upload progress indicator
- File validation with helpful error messages
- Replace/remove functionality
#### GalleryUploader Component
**Purpose**: Multiple file upload with gallery preview and reordering
```typescript
interface GalleryUploaderProps {
label: string
value?: Media[]
onUpload: (media: Media[]) => void
onReorder?: (media: Media[]) => void
maxItems?: number
allowAltText?: boolean
required?: boolean
error?: string
}
```
**Features**:
- Multiple file drag-and-drop
- Immediate gallery preview grid
- Individual alt text inputs for each image
- Drag-and-drop reordering
- Individual remove buttons
- Bulk upload progress
### 2. MediaLibraryModal Component (Secondary)
**Purpose**: Main modal component that wraps the media browser functionality
**Props Interface**:
```typescript
interface MediaLibraryModalProps {
isOpen: boolean
mode: 'single' | 'multiple'
fileType?: 'image' | 'video' | 'all'
onSelect: (media: Media | Media[]) => void
onClose: () => void
selectedIds?: number[] // Pre-selected items
title?: string // Modal title
confirmText?: string // Confirm button text
}
```
**Features**:
- Modal overlay with proper focus management
- Header with title and close button
- Media browser grid with selection indicators
- Search and filter controls
- Upload area with drag-and-drop
- Footer with selection count and action buttons
- Responsive design (desktop and tablet)
### 2. MediaSelector Component
**Purpose**: The actual media browsing interface within the modal
**Features**:
- Grid layout with thumbnail previews
- Individual item selection with visual feedback
- Keyboard navigation support
- Loading states and error handling
- "Select All" / "Clear Selection" bulk actions (for multiple mode)
**Item Display**:
- Thumbnail image
- Filename (truncated)
- File size and dimensions
- Usage indicator (if used elsewhere)
- Selection checkbox/indicator
### 3. MediaUploader Component
**Purpose**: Handle file uploads within the modal
**Features**:
- Drag-and-drop upload zone
- Click to browse files
- Upload progress indicators
- Error handling and validation
- Multiple file upload support
- Automatic refresh of media grid after upload
**Validation**:
- File type restrictions based on context
- File size limits (10MB per file)
- Maximum number of files for bulk upload
### 4. Form Integration Components
#### MediaInput Component
**Purpose**: Generic input field that opens media library modal
```typescript
interface MediaInputProps {
label: string
value?: Media | Media[] | null
mode: 'single' | 'multiple'
fileType?: 'image' | 'video' | 'all'
onSelect: (media: Media | Media[] | null) => void
placeholder?: string
required?: boolean
error?: string
}
```
**Display**:
- Label and optional required indicator
- Preview of selected media (thumbnail + filename)
- "Browse" button to open modal
- "Clear" button to remove selection
- Error state display
#### ImagePicker Component
**Purpose**: Specialized single image selector with enhanced preview
```typescript
interface ImagePickerProps {
label: string
value?: Media | null
onSelect: (media: Media | null) => void
aspectRatio?: string // e.g., "16:9", "1:1"
placeholder?: string
required?: boolean
error?: string
}
```
**Display**:
- Large preview area with placeholder
- Image preview with proper aspect ratio
- Overlay with "Change" and "Remove" buttons on hover
- Upload progress indicator
#### GalleryManager Component
**Purpose**: Multiple image selection with drag-and-drop reordering
```typescript
interface GalleryManagerProps {
label: string
value?: Media[]
onSelect: (media: Media[]) => void
onReorder?: (media: Media[]) => void
maxItems?: number
required?: boolean
error?: string
}
```
**Display**:
- Grid of selected images with reorder handles
- "Add Images" button to open modal
- Individual remove buttons on each image
- Drag-and-drop reordering with visual feedback
## User Experience Flows
### 🥇 Primary Flow: Direct Upload in Forms
#### 1. Single Image Upload (Project Featured Image)
1. **User creates/edits project** and reaches featured image field
2. **User drags image file** directly onto ImageUploader component OR clicks to browse
3. **File is immediately uploaded** with progress indicator
4. **Image preview appears** with proper aspect ratio
5. **Alt text input field appears** below preview (if enabled)
6. **User enters alt text** for accessibility
7. **Form can be saved** with media reference and metadata
#### 2. Multiple Image Upload (Project Gallery)
1. **User reaches gallery section** of project form
2. **User drags multiple files** onto GalleryUploader OR clicks to browse multiple
3. **Upload progress shown** for each file individually
4. **Gallery grid appears** with all uploaded images
5. **Alt text inputs available** for each image
6. **User can reorder** images with drag-and-drop
7. **User can remove** individual images with X button
8. **Form saves** with complete gallery and metadata
#### 3. Media Management and Alt Text Editing
1. **User visits Media Library page** to manage uploaded content
2. **User clicks on any media item** to open details modal
3. **User can edit alt text** and other metadata
4. **User can see usage references** (which projects/posts use this media)
5. **Changes are saved** and reflected wherever media is used
### 🥈 Secondary Flow: Browse Existing Media
#### 1. Selecting Previously Uploaded Image
1. **User clicks "Browse Library"** button (secondary option in forms)
2. **MediaLibraryModal opens** showing all previously uploaded media
3. **User browses or searches** existing content
4. **User selects image** and confirms selection
5. **Modal closes** and form shows selected media with existing alt text
#### 2. Managing Media Library
1. **User visits dedicated Media Library page**
2. **User can view all uploaded media** in grid/list format
3. **User can edit metadata** including alt text for any media
4. **User can see usage tracking** - which content references each media
5. **User can perform bulk operations** like deleting unused media
## Design Specifications
### Modal Layout
- **Width**: 1200px max, responsive on smaller screens
- **Height**: 80vh max with scroll
- **Grid**: 4-6 columns depending on screen size
- **Item Size**: 180px × 140px thumbnails
### Visual States
- **Default**: Border with subtle background
- **Selected**: Blue border and checkmark overlay
- **Hover**: Slight scale and shadow effect
- **Loading**: Skeleton loader animation
- **Upload**: Progress overlay with percentage
### Colors (Using Existing Variables)
- **Selection**: `$blue-60` for selected state
- **Hover**: `$grey-10` background
- **Upload Progress**: `$green-60` for success, `$red-60` for error
## API Integration
### Endpoints Used
- `GET /api/media` - Browse media with search/filter/pagination
- `POST /api/media/upload` - Single file upload
- `POST /api/media/bulk-upload` - Multiple file upload
### Search and Filtering
- **Search**: By filename (case-insensitive)
- **Filter by Type**: image/*, video/*, all
- **Filter by Usage**: unused only, all
- **Sort**: Most recent first
### Pagination
- 24 items per page
- Infinite scroll or traditional pagination
- Loading states during page changes
## Implementation Plan
### ✅ Phase 1: Database Schema Updates (COMPLETED)
1. **✅ Alt Text Support**
- Database schema includes `altText` and `description` fields
- API endpoints support alt text in upload and update operations
2. **⏳ Usage Tracking (IN PROGRESS)**
- Basic usage references working in forms
- Need dedicated tracking table for comprehensive usage analytics
### ✅ Phase 2: Direct Upload Components (COMPLETED)
1. **✅ ImageUploader Component**
- Drag-and-drop upload zone with visual feedback
- Immediate upload and preview functionality
- Alt text input integration
- MediaLibraryModal integration as secondary option
2. **✅ GalleryUploader Component**
- Multiple file drag-and-drop support
- Individual alt text inputs per image
- Drag-and-drop reordering functionality
- Remove individual images functionality
- MediaLibraryModal integration for existing media selection
3. **✅ Upload API Enhancement**
- Alt text accepted in upload requests
- Complete media object returned with metadata
- Batch uploads with individual alt text support
### ✅ Phase 3: Form Integration (COMPLETED)
1. **✅ Project Forms Enhancement**
- Logo field enhanced with ImageUploader + Browse Library
- Featured image support with ImageUploader
- Gallery section implemented with GalleryUploader
- Secondary "Browse Library" buttons throughout
2. **✅ Post Forms Enhancement**
- Photo post creation with PhotoPostForm
- Album creation with AlbumForm and GalleryUploader
- Universe Composer with photo attachments
- Enhanced Edra editor with inline image/gallery support
### ✅ Phase 4: Media Library Management (MOSTLY COMPLETED)
1. **✅ Enhanced Media Library Page**
- Alt text editing for existing media via MediaDetailsModal
- Clickable media items with edit functionality
- Grid and list view toggles
2. **✅ MediaLibraryModal for Selection**
- Browse existing media interface
- Single and multiple selection modes
- Integration throughout all form components
- File type filtering (image/video/all)
### 🎯 Phase 5: Remaining Enhancements (CURRENT PRIORITIES)
#### 🔥 High Priority (Next Sprint)
1. **Enhanced Media Library Features**
- **Bulk alt text editing** - Select multiple media items and edit alt text in batch
- **Usage tracking display** - Show where each media item is referenced
- **Advanced drag & drop zones** - More intuitive upload areas in all components
2. **Performance Optimizations**
- **Lazy loading** for large media libraries
- **Search optimization** with better indexing
- **Thumbnail optimization** for faster loading
#### 🔥 Medium Priority (Future Sprints)
1. **Advanced Upload Features**
- **Image resizing/optimization** options during upload
- **Duplicate detection** to prevent redundant uploads
- **Bulk upload improvements** with better progress tracking
2. **Usage Analytics & Management**
- **Usage analytics dashboard** showing media usage statistics
- **Unused media cleanup** tools for storage optimization
- **Advanced search** by alt text, usage status, date ranges
#### 🔥 Low Priority (Nice-to-Have)
1. **AI Integration**
- **Automatic alt text suggestions** using image recognition
- **Smart tagging** for better organization
- **Content-aware optimization** suggestions
## Success Criteria
### Functional Requirements
#### Primary Workflow (Direct Upload)
- [x] **Drag-and-drop upload works** in all form components
- [x] **Click-to-browse file selection** works reliably
- [x] **Immediate upload and preview** happens without page navigation
- [x] **Alt text input appears** and saves with uploaded media
- [x] **Upload progress** is clearly indicated with percentage
- [x] **Error handling** provides helpful feedback for failed uploads
- [x] **Multiple file upload** works with individual progress tracking
- [x] **Gallery reordering** works with drag-and-drop after upload
#### Secondary Workflow (Media Library)
- [x] **Media Library Modal** opens and closes properly with smooth animations
- [x] **Single and multiple selection** modes work correctly
- [x] **Search and filtering** return accurate results
- [ ] **Usage tracking** shows where media is referenced (IN PROGRESS)
- [x] **Alt text editing** works in Media Library management
- [x] **All components are keyboard accessible**
#### Edra Editor Integration
- [x] **Slash commands** work for image and gallery insertion
- [x] **MediaLibraryModal integration** in editor placeholders
- [x] **Gallery management** within rich text editor
- [x] **Image replacement** functionality in editor
### Performance Requirements
- [x] Modal opens in under 200ms
- [x] Media grid loads in under 1 second
- [x] Search results appear in under 500ms
- [x] Upload progress updates in real-time
- [x] No memory leaks when opening/closing modal multiple times
### UX Requirements
- [x] Interface is intuitive without instruction
- [x] Visual feedback is clear for all interactions
- [x] Error messages are helpful and actionable
- [x] Mobile/tablet interface is fully functional
- [x] Loading states prevent user confusion
## Technical Considerations
### State Management
- Use Svelte runes for reactive state
- Maintain selection state during modal lifecycle
- Handle API loading and error states properly
### Accessibility
- Proper ARIA labels and roles
- Keyboard navigation support
- Focus management when modal opens/closes
- Screen reader announcements for state changes
### Performance
- Lazy load thumbnails as they come into view
- Debounce search input to prevent excessive API calls
- Efficient reordering without full re-renders
- Memory cleanup when modal is closed
### Error Handling
- Network failure recovery
- Upload failure feedback
- File validation error messages
- Graceful degradation for missing thumbnails
## Future Enhancements
### Nice-to-Have Features
- **Bulk Operations**: Delete multiple files, bulk tag editing
- **Advanced Search**: Search by tags, date range, file size
- **Preview Mode**: Full-size preview with navigation
- **Folder Organization**: Create folders/categories for organization
- **Smart Suggestions**: Recently used, similar images
- **Crop Tool**: Basic cropping interface within modal
- **Alt Text Editor**: Quick alt text editing for accessibility
### Integration Opportunities
- **CDN Optimization**: Automatic image optimization settings
- **AI Tagging**: Automatic tag generation for uploaded images
- **Duplicate Detection**: Warn about similar/duplicate uploads
- **Usage Analytics**: Track which media is used most frequently
## Development Checklist
### Core Components
- [x] MediaLibraryModal base structure
- [x] MediaSelector with grid layout
- [x] MediaUploader with drag-and-drop
- [x] Search and filter interface
- [x] Pagination implementation
### Form Integration
- [x] MediaInput generic component (ImageUploader/GalleryUploader)
- [x] ImagePicker specialized component (ImageUploader)
- [x] GalleryManager with reordering (GalleryUploader)
- [x] Integration with existing project forms
- [x] Integration with post forms
- [x] Integration with Edra editor
### Polish and Testing
- [x] Responsive design implementation
- [x] Accessibility testing and fixes
- [x] Performance optimization
- [x] Error state handling
- [x] Cross-browser testing
- [x] Mobile device testing
### 🎯 Next Priority Items
- [ ] **Bulk alt text editing** in Media Library
- [ ] **Usage tracking display** for media references
- [ ] **Advanced drag & drop zones** with better visual feedback
- [ ] **Performance optimizations** for large libraries
This Media Library system will serve as the foundation for all media-related functionality in the CMS, enabling rich content creation across projects, posts, and albums.

View file

@ -0,0 +1,397 @@
# Product Requirements Document: Storybook Integration
## Overview
Implement Storybook as our component development and documentation platform to improve development workflow, component testing, and design system consistency across the jedmund-svelte project.
## Goals
- **Isolated Component Development**: Build and test components in isolation from business logic
- **Visual Documentation**: Create a living style guide for all UI components
- **Design System Consistency**: Ensure consistent component behavior across different states
- **Developer Experience**: Improve development workflow with hot reloading and component playground
- **Quality Assurance**: Test component edge cases and various prop combinations
- **Team Collaboration**: Provide a central place for designers and developers to review components
## Current State Analysis
### ✅ What We Have
- Comprehensive admin UI component library (Button, Input, Modal, etc.)
- Media Library components (MediaLibraryModal, ImagePicker, GalleryManager, etc.)
- SCSS-based styling system with global variables
- SvelteKit project with Svelte 5 runes mode
- TypeScript configuration
- Vite build system
### 🎯 What We Need
- Storybook installation and configuration
- Stories for existing components
- Visual regression testing setup
- Component documentation standards
- Integration with existing SCSS variables and themes
## Technical Requirements
### 1. Storybook Installation
**Installation Method**: Manual setup (not template-based since we have an existing project)
```bash
# Install Storybook CLI and initialize
npx storybook@latest init
# Or manual installation for better control
npm install --save-dev @storybook/svelte-vite @storybook/addon-essentials
```
**Expected File Structure**:
```
.storybook/
├── main.js # Storybook configuration
├── preview.js # Global decorators and parameters
└── manager.js # Storybook UI customization
src/
├── stories/ # Component stories
│ ├── Button.stories.js
│ ├── Input.stories.js
│ └── ...
└── components/ # Existing components
```
### 2. Configuration Requirements
#### Main Configuration (.storybook/main.js)
```javascript
export default {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|svelte)'],
addons: [
'@storybook/addon-essentials', // Controls, actions, viewport, etc.
'@storybook/addon-svelte-csf', // Svelte Component Story Format
'@storybook/addon-a11y', // Accessibility testing
'@storybook/addon-design-tokens', // Design system tokens
],
framework: {
name: '@storybook/svelte-vite',
options: {}
},
viteFinal: async (config) => {
// Integrate with existing Vite config
// Import SCSS variables and aliases
return mergeConfig(config, {
resolve: {
alias: {
'$lib': path.resolve('./src/lib'),
'$components': path.resolve('./src/lib/components'),
'$icons': path.resolve('./src/assets/icons'),
'$illos': path.resolve('./src/assets/illos'),
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `
@import './src/assets/styles/variables.scss';
@import './src/assets/styles/fonts.scss';
@import './src/assets/styles/themes.scss';
@import './src/assets/styles/globals.scss';
`
}
}
}
});
}
};
```
#### Preview Configuration (.storybook/preview.js)
```javascript
import '../src/assets/styles/reset.css';
import '../src/assets/styles/globals.scss';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
backgrounds: {
default: 'light',
values: [
{ name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#333333' },
{ name: 'admin', value: '#f5f5f5' },
],
},
viewport: {
viewports: {
mobile: { name: 'Mobile', styles: { width: '375px', height: '667px' } },
tablet: { name: 'Tablet', styles: { width: '768px', height: '1024px' } },
desktop: { name: 'Desktop', styles: { width: '1440px', height: '900px' } },
},
},
};
```
### 3. Component Story Standards
#### Story File Format
Each component should have a corresponding `.stories.js` file following this structure:
```javascript
// Button.stories.js
import Button from '../lib/components/admin/Button.svelte';
export default {
title: 'Admin/Button',
component: Button,
tags: ['autodocs'],
argTypes: {
variant: {
control: { type: 'select' },
options: ['primary', 'secondary', 'ghost', 'danger']
},
size: {
control: { type: 'select' },
options: ['small', 'medium', 'large']
},
disabled: {
control: 'boolean'
},
onclick: { action: 'clicked' }
}
};
export const Primary = {
args: {
variant: 'primary',
children: 'Primary Button'
}
};
export const Secondary = {
args: {
variant: 'secondary',
children: 'Secondary Button'
}
};
export const AllVariants = {
render: () => ({
Component: ButtonShowcase,
props: {}
})
};
```
#### Story Organization
```
src/stories/
├── admin/ # Admin interface components
│ ├── Button.stories.js
│ ├── Input.stories.js
│ ├── Modal.stories.js
│ └── forms/ # Form-specific components
│ ├── MediaInput.stories.js
│ ├── ImagePicker.stories.js
│ └── GalleryManager.stories.js
├── public/ # Public-facing components
│ ├── Header.stories.js
│ └── Footer.stories.js
└── examples/ # Complex examples and compositions
├── AdminDashboard.stories.js
└── MediaLibraryFlow.stories.js
```
## Implementation Plan
### Phase 1: Initial Setup (1-2 days)
1. **Install and Configure Storybook**
- Run `npx storybook@latest init`
- Configure Vite integration for SCSS and aliases
- Set up TypeScript support
- Configure preview with global styles
2. **Test Basic Setup**
- Create simple Button story
- Verify SCSS variables work
- Test hot reloading
### Phase 2: Core Component Stories (3-4 days)
1. **Basic UI Components**
- Button (all variants, states, sizes)
- Input (text, textarea, validation states)
- Modal (different sizes, content types)
- LoadingSpinner (different sizes)
2. **Form Components**
- MediaInput (single/multiple modes)
- ImagePicker (different aspect ratios)
- GalleryManager (with/without items)
3. **Complex Components**
- MediaLibraryModal (with mock data)
- DataTable (with sample data)
- AdminNavBar (active states)
### Phase 3: Advanced Features (2-3 days)
1. **Mock Data Setup**
- Create mock Media objects
- Set up API mocking for components that need data
- Create realistic test scenarios
2. **Accessibility Testing**
- Add @storybook/addon-a11y
- Test keyboard navigation
- Verify screen reader compatibility
3. **Visual Regression Testing**
- Set up Chromatic (optional)
- Create baseline screenshots
- Configure CI integration
### Phase 4: Documentation and Polish (1-2 days)
1. **Component Documentation**
- Add JSDoc comments to components
- Create usage examples
- Document props and events
2. **Design System Documentation**
- Color palette showcase
- Typography scale
- Spacing system
- Icon library
## Success Criteria
### Functional Requirements
- [ ] Storybook runs successfully with `npm run storybook`
- [ ] All existing components have basic stories
- [ ] SCSS variables and global styles work correctly
- [ ] Components render properly in isolation
- [ ] Hot reloading works for both component and story changes
- [ ] TypeScript support is fully functional
### Quality Requirements
- [ ] Stories cover all major component variants
- [ ] Interactive controls work for all props
- [ ] Actions are properly logged for events
- [ ] Accessibility addon reports no critical issues
- [ ] Components are responsive across viewport sizes
### Developer Experience Requirements
- [ ] Story creation is straightforward and documented
- [ ] Mock data is easily accessible and realistic
- [ ] Component API is clearly documented
- [ ] Common patterns have reusable templates
## Integration with Existing Workflow
### Development Workflow
1. **Component Development**: Start new components in Storybook
2. **Testing**: Test all states and edge cases in stories
3. **Documentation**: Stories serve as living documentation
4. **Review**: Use Storybook for design/code reviews
### Project Structure Integration
```
package.json # Add storybook scripts
├── "storybook": "storybook dev -p 6006"
├── "build-storybook": "storybook build"
.storybook/ # Storybook configuration
src/
├── lib/components/ # Existing components (unchanged)
├── stories/ # New: component stories
└── assets/styles/ # Existing styles (used by Storybook)
```
### Scripts and Commands
```json
{
"scripts": {
"dev": "vite dev",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"storybook:test": "test-storybook"
}
}
```
## Technical Considerations
### SCSS Integration
- Import global variables in Storybook preview
- Ensure component styles render correctly
- Test responsive breakpoints
### SvelteKit Compatibility
- Handle SvelteKit-specific imports (like `$app/stores`)
- Mock SvelteKit modules when needed
- Ensure aliases work in Storybook context
### TypeScript Support
- Configure proper type checking
- Use TypeScript for story definitions where beneficial
- Ensure IntelliSense works for story arguments
### Performance
- Optimize bundle size for faster story loading
- Use lazy loading for large story collections
- Configure appropriate caching
## Future Enhancements
### Advanced Testing
- **Visual Regression Testing**: Use Chromatic for automated visual testing
- **Interaction Testing**: Add @storybook/addon-interactions for user flow testing
- **Accessibility Automation**: Automated a11y testing in CI/CD
### Design System Evolution
- **Design Tokens**: Implement design tokens addon
- **Figma Integration**: Connect with Figma designs
- **Component Status**: Track component implementation status
### Collaboration Features
- **Published Storybook**: Deploy Storybook for team access
- **Design Review Process**: Use Storybook for design approvals
- **Documentation Site**: Evolve into full design system documentation
## Risks and Mitigation
### Technical Risks
- **Build Conflicts**: Vite configuration conflicts
- *Mitigation*: Careful configuration merging and testing
- **SCSS Import Issues**: Global styles not loading
- *Mitigation*: Test SCSS integration early in setup
### Workflow Risks
- **Adoption Resistance**: Team not using Storybook
- *Mitigation*: Start with high-value components, show immediate benefits
- **Maintenance Overhead**: Stories become outdated
- *Mitigation*: Include story updates in component change process
## Success Metrics
### Development Efficiency
- Reduced time to develop new components
- Faster iteration on component designs
- Fewer bugs in component edge cases
### Code Quality
- Better component API consistency
- Improved accessibility compliance
- More comprehensive component testing
### Team Collaboration
- Faster design review cycles
- Better communication between design and development
- More consistent component usage across the application
## Conclusion
Implementing Storybook will significantly improve our component development workflow, provide better documentation, and create a foundation for a mature design system. The investment in setup and story creation will pay dividends in development speed, component quality, and team collaboration.
The implementation should be done incrementally, starting with the most commonly used components and gradually expanding coverage. This approach minimizes risk while providing immediate value to the development process.

View file

@ -1,3 +1,6 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import storybook from 'eslint-plugin-storybook'
import js from '@eslint/js'
import ts from 'typescript-eslint'
import svelte from 'eslint-plugin-svelte'
@ -29,5 +32,6 @@ export default [
},
{
ignores: ['build/', '.svelte-kit/', 'dist/']
}
},
...storybook.configs['flat/recommended']
]

14343
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,46 +10,108 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
"format": "prettier --write .",
"db:migrate": "prisma migrate dev",
"db:seed": "prisma db seed",
"db:studio": "prisma studio",
"setup:local": "./scripts/setup-local.sh",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@musicorum/lastfm": "github:jedmund/lastfm",
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
"@storybook/addon-a11y": "^9.0.1",
"@storybook/addon-docs": "^9.0.1",
"@storybook/addon-svelte-csf": "^5.0.3",
"@storybook/sveltekit": "^9.0.1",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
"@types/eslint": "^8.56.7",
"@types/node": "^22.0.2",
"autoprefixer": "^10.4.19",
"eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-storybook": "^9.0.1",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"postcss": "^8.4.39",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"sass": "^1.77.8",
"storybook": "^9.0.1",
"svelte": "^5.0.0-next.1",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
"tsx": "^4.19.4",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.0-alpha.20",
"vite": "^5.0.3"
},
"type": "module",
"dependencies": {
"@aarkue/tiptap-math-extension": "^1.3.6",
"@prisma/client": "^6.8.2",
"@sveltejs/adapter-node": "^5.2.0",
"@tiptap/core": "^2.12.0",
"@tiptap/extension-bubble-menu": "^2.12.0",
"@tiptap/extension-character-count": "^2.12.0",
"@tiptap/extension-code-block-lowlight": "^2.12.0",
"@tiptap/extension-color": "^2.12.0",
"@tiptap/extension-floating-menu": "^2.12.0",
"@tiptap/extension-highlight": "^2.12.0",
"@tiptap/extension-image": "^2.12.0",
"@tiptap/extension-link": "^2.12.0",
"@tiptap/extension-placeholder": "^2.12.0",
"@tiptap/extension-subscript": "^2.12.0",
"@tiptap/extension-superscript": "^2.12.0",
"@tiptap/extension-table": "^2.12.0",
"@tiptap/extension-table-header": "^2.12.0",
"@tiptap/extension-table-row": "^2.12.0",
"@tiptap/extension-task-item": "^2.12.0",
"@tiptap/extension-task-list": "^2.12.0",
"@tiptap/extension-text": "^2.12.0",
"@tiptap/extension-text-align": "^2.12.0",
"@tiptap/extension-text-style": "^2.12.0",
"@tiptap/extension-typography": "^2.12.0",
"@tiptap/extension-underline": "^2.12.0",
"@tiptap/pm": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@types/multer": "^1.4.12",
"@types/redis": "^4.0.10",
"@types/steamapi": "^2.2.5",
"dotenv": "^16.4.5",
"cloudinary": "^2.6.1",
"dotenv": "^16.5.0",
"exifr": "^7.1.3",
"giantbombing-api": "^1.0.4",
"gray-matter": "^4.0.3",
"ioredis": "^5.4.1",
"katex": "^0.16.22",
"lowlight": "^3.3.0",
"lucide-svelte": "^0.511.0",
"marked": "^15.0.12",
"multer": "^2.0.0",
"node-itunes-search": "^1.2.3",
"prisma": "^6.8.2",
"psn-api": "github:jedmund/psn-api",
"redis": "^4.7.0",
"sharp": "^0.34.2",
"steamapi": "^3.0.11",
"tinyduration": "^3.3.1"
"svelte-tiptap": "^2.1.0",
"svgo": "^3.3.2",
"tinyduration": "^3.3.1",
"tippy.js": "^6.3.7",
"tiptap-extension-auto-joiner": "^0.1.3",
"tiptap-extension-global-drag-handle": "^0.1.18",
"tiptap-markdown": "^0.8.10",
"zod": "^3.25.30"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"overrides": {
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.6"
}
}

View file

@ -0,0 +1,149 @@
-- CreateTable
CREATE TABLE "Project" (
"id" SERIAL NOT NULL,
"slug" VARCHAR(255) NOT NULL,
"title" VARCHAR(255) NOT NULL,
"subtitle" VARCHAR(255),
"description" TEXT,
"year" INTEGER NOT NULL,
"client" VARCHAR(255),
"role" VARCHAR(255),
"technologies" JSONB,
"featuredImage" VARCHAR(500),
"gallery" JSONB,
"externalUrl" VARCHAR(500),
"caseStudyContent" JSONB,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"status" VARCHAR(50) NOT NULL DEFAULT 'draft',
"publishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Post" (
"id" SERIAL NOT NULL,
"slug" VARCHAR(255) NOT NULL,
"postType" VARCHAR(50) NOT NULL,
"title" VARCHAR(255),
"content" JSONB,
"excerpt" TEXT,
"linkUrl" VARCHAR(500),
"linkDescription" TEXT,
"photoId" INTEGER,
"albumId" INTEGER,
"featuredImage" VARCHAR(500),
"tags" JSONB,
"status" VARCHAR(50) NOT NULL DEFAULT 'draft',
"publishedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Album" (
"id" SERIAL NOT NULL,
"slug" VARCHAR(255) NOT NULL,
"title" VARCHAR(255) NOT NULL,
"description" TEXT,
"date" TIMESTAMP(3),
"location" VARCHAR(255),
"coverPhotoId" INTEGER,
"status" VARCHAR(50) NOT NULL DEFAULT 'draft',
"showInUniverse" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Album_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Photo" (
"id" SERIAL NOT NULL,
"albumId" INTEGER,
"filename" VARCHAR(255) NOT NULL,
"url" VARCHAR(500) NOT NULL,
"thumbnailUrl" VARCHAR(500),
"width" INTEGER,
"height" INTEGER,
"exifData" JSONB,
"caption" TEXT,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"slug" VARCHAR(255),
"title" VARCHAR(255),
"description" TEXT,
"status" VARCHAR(50) NOT NULL DEFAULT 'draft',
"publishedAt" TIMESTAMP(3),
"showInPhotos" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Photo_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Media" (
"id" SERIAL NOT NULL,
"filename" VARCHAR(255) NOT NULL,
"mimeType" VARCHAR(100) NOT NULL,
"size" INTEGER NOT NULL,
"url" TEXT NOT NULL,
"thumbnailUrl" TEXT,
"width" INTEGER,
"height" INTEGER,
"usedIn" JSONB NOT NULL DEFAULT '[]',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Media_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug");
-- CreateIndex
CREATE INDEX "Project_slug_idx" ON "Project"("slug");
-- CreateIndex
CREATE INDEX "Project_status_idx" ON "Project"("status");
-- CreateIndex
CREATE UNIQUE INDEX "Post_slug_key" ON "Post"("slug");
-- CreateIndex
CREATE INDEX "Post_slug_idx" ON "Post"("slug");
-- CreateIndex
CREATE INDEX "Post_status_idx" ON "Post"("status");
-- CreateIndex
CREATE INDEX "Post_postType_idx" ON "Post"("postType");
-- CreateIndex
CREATE UNIQUE INDEX "Album_slug_key" ON "Album"("slug");
-- CreateIndex
CREATE INDEX "Album_slug_idx" ON "Album"("slug");
-- CreateIndex
CREATE INDEX "Album_status_idx" ON "Album"("status");
-- CreateIndex
CREATE UNIQUE INDEX "Photo_slug_key" ON "Photo"("slug");
-- CreateIndex
CREATE INDEX "Photo_slug_idx" ON "Photo"("slug");
-- CreateIndex
CREATE INDEX "Photo_status_idx" ON "Photo"("status");
-- AddForeignKey
ALTER TABLE "Post" ADD CONSTRAINT "Post_photoId_fkey" FOREIGN KEY ("photoId") REFERENCES "Photo"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Post" ADD CONSTRAINT "Post_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Photo" ADD CONSTRAINT "Photo_albumId_fkey" FOREIGN KEY ("albumId") REFERENCES "Album"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "backgroundColor" VARCHAR(50),
ADD COLUMN "highlightColor" VARCHAR(50);

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "logoUrl" VARCHAR(500);

View file

@ -0,0 +1,14 @@
/*
Warnings:
- Added the required column `updatedAt` to the `Media` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Media" ADD COLUMN "altText" TEXT,
ADD COLUMN "description" TEXT,
ADD COLUMN "originalName" VARCHAR(255),
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- Set originalName to filename for existing records where it's null
UPDATE "Media" SET "originalName" = "filename" WHERE "originalName" IS NULL;

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,2 @@
-- AlterTable
ALTER TABLE "Post" DROP COLUMN "excerpt";

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

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

166
prisma/schema.prisma Normal file
View file

@ -0,0 +1,166 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// Projects table (for /work)
model Project {
id Int @id @default(autoincrement())
slug String @unique @db.VarChar(255)
title String @db.VarChar(255)
subtitle String? @db.VarChar(255)
description String? @db.Text
year Int
client String? @db.VarChar(255)
role String? @db.VarChar(255)
featuredImage String? @db.VarChar(500)
logoUrl String? @db.VarChar(500)
gallery Json? // Array of image URLs
externalUrl String? @db.VarChar(500)
caseStudyContent Json? // BlockNote JSON format
backgroundColor String? @db.VarChar(50) // For project card styling
highlightColor String? @db.VarChar(50) // For project card accent
projectType String @default("work") @db.VarChar(50) // "work" or "labs"
displayOrder Int @default(0)
status String @default("draft") @db.VarChar(50) // "draft", "published", "list-only", "password-protected"
password String? @db.VarChar(255) // Required when status is "password-protected"
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([slug])
@@index([status])
}
// Posts table (for /universe)
model Post {
id Int @id @default(autoincrement())
slug String @unique @db.VarChar(255)
postType String @db.VarChar(50) // blog, microblog, link, photo, album
title String? @db.VarChar(255) // Optional for microblog posts
content Json? // BlockNote JSON for blog/microblog
// Type-specific fields
linkUrl String? @db.VarChar(500)
linkDescription String? @db.Text
photoId Int?
albumId Int?
featuredImage String? @db.VarChar(500)
attachments Json? // Array of media IDs for photo attachments
tags Json? // Array of tags
status String @default("draft") @db.VarChar(50)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
photo Photo? @relation(fields: [photoId], references: [id])
album Album? @relation(fields: [albumId], references: [id])
@@index([slug])
@@index([status])
@@index([postType])
}
// Albums table
model Album {
id Int @id @default(autoincrement())
slug String @unique @db.VarChar(255)
title String @db.VarChar(255)
description String? @db.Text
date DateTime?
location String? @db.VarChar(255)
coverPhotoId Int?
isPhotography Boolean @default(false) // Show in photos experience
status String @default("draft") @db.VarChar(50)
showInUniverse Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
photos Photo[]
posts Post[]
@@index([slug])
@@index([status])
}
// Photos table
model Photo {
id Int @id @default(autoincrement())
albumId Int?
filename String @db.VarChar(255)
url String @db.VarChar(500)
thumbnailUrl String? @db.VarChar(500)
width Int?
height Int?
exifData Json?
caption String? @db.Text
displayOrder Int @default(0)
// Individual publishing support
slug String? @unique @db.VarChar(255)
title String? @db.VarChar(255)
description String? @db.Text
status String @default("draft") @db.VarChar(50)
publishedAt DateTime?
showInPhotos Boolean @default(true)
createdAt DateTime @default(now())
// Relations
album Album? @relation(fields: [albumId], references: [id], onDelete: Cascade)
posts Post[]
@@index([slug])
@@index([status])
}
// Media table (general uploads)
model Media {
id Int @id @default(autoincrement())
filename String @db.VarChar(255)
originalName String? @db.VarChar(255) // Original filename from user (optional for backward compatibility)
mimeType String @db.VarChar(100)
size Int
url String @db.Text
thumbnailUrl String? @db.Text
width Int?
height Int?
exifData Json? // EXIF data for photos
altText String? @db.Text // Alt text for accessibility
description String? @db.Text // Optional description
isPhotography Boolean @default(false) // Star for photos experience
usedIn Json @default("[]") // Track where media is used (legacy)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
usage MediaUsage[]
}
// Media usage tracking table
model MediaUsage {
id Int @id @default(autoincrement())
mediaId Int
contentType String @db.VarChar(50) // 'project', 'post', 'album'
contentId Int
fieldName String @db.VarChar(100) // 'featuredImage', 'logoUrl', 'gallery', 'content'
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
@@unique([mediaId, contentType, contentId, fieldName])
@@index([mediaId])
@@index([contentType, contentId])
}

376
prisma/seed.ts Normal file
View file

@ -0,0 +1,376 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function main() {
console.log('🌱 Starting seed...')
// Clear existing data
await prisma.photo.deleteMany({})
await prisma.album.deleteMany({})
await prisma.post.deleteMany({})
await prisma.media.deleteMany({})
await prisma.project.deleteMany({})
console.log('✅ Cleared existing data')
// Create real projects from ProjectList
const projects = await Promise.all([
prisma.project.create({
data: {
slug: 'maitsu',
title: 'Maitsu',
subtitle: 'A hobby journal for weekly creativity',
description: 'Maitsu is a hobby journal that helps people make something new every week.',
year: 2023,
client: 'Personal Project',
role: 'Founder & Designer',
projectType: 'work',
featuredImage: '/images/projects/maitsu-cover.png',
backgroundColor: '#FFF7EA',
highlightColor: '#F77754',
displayOrder: 1,
status: 'published',
publishedAt: new Date()
}
}),
prisma.project.create({
data: {
slug: 'slack',
title: 'Slack',
subtitle: 'Redefining automation strategy',
description:
'At Slack, I helped redefine strategy for Workflows and other features in under the automation umbrella.',
year: 2022,
client: 'Slack Technologies',
role: 'Senior Product Designer',
projectType: 'work',
featuredImage: '/images/projects/slack-cover.png',
backgroundColor: '#4a154b',
highlightColor: '#611F69',
displayOrder: 2,
status: 'published',
publishedAt: new Date()
}
}),
prisma.project.create({
data: {
slug: 'figma',
title: 'Figma',
subtitle: 'Pioneering prototyping features',
description:
'At Figma, I designed features and led R&D and strategy for the nascent prototyping team.',
year: 2019,
client: 'Figma Inc.',
role: 'Product Designer',
projectType: 'work',
featuredImage: '/images/projects/figma-cover.png',
backgroundColor: '#2c2c2c',
highlightColor: '#0ACF83',
displayOrder: 3,
status: 'published',
publishedAt: new Date()
}
}),
prisma.project.create({
data: {
slug: 'pinterest',
title: 'Pinterest',
subtitle: 'Building from the ground up',
description:
'At Pinterest, I was the first product design hired, and touched almost every part of the product.',
year: 2011,
client: 'Pinterest',
role: 'Product Designer #1',
projectType: 'work',
featuredImage: '/images/projects/pinterest-cover.png',
backgroundColor: '#f7f7f7',
highlightColor: '#CB1F27',
displayOrder: 4,
status: 'published',
publishedAt: new Date()
}
})
])
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 using new simplified types
const posts = await Promise.all([
prisma.post.create({
data: {
slug: 'hello-world',
postType: 'essay',
title: 'Hello World',
content: {
blocks: [
{ type: 'paragraph', content: 'This is my first essay on the new CMS!' },
{
type: 'paragraph',
content:
'The system now uses a simplified post type system with just essays and posts.'
},
{
type: 'paragraph',
content:
'Essays are perfect for longer-form content with titles and excerpts, while posts are great for quick thoughts and updates.'
}
]
},
excerpt: 'Welcome to my new blog powered by a custom CMS with simplified post types.',
tags: ['announcement', 'meta', 'cms'],
status: 'published',
publishedAt: new Date()
}
}),
prisma.post.create({
data: {
slug: 'quick-thought',
postType: 'post',
content: {
blocks: [
{
type: 'paragraph',
content:
'Just pushed a major update to the site. The new simplified post types are working great! 🎉'
}
]
},
tags: ['update', 'development'],
status: 'published',
publishedAt: new Date(Date.now() - 86400000) // Yesterday
}
}),
prisma.post.create({
data: {
slug: 'design-systems-thoughts',
postType: 'essay',
title: 'Thoughts on Design Systems',
content: {
blocks: [
{
type: 'paragraph',
content:
'Design systems have become essential for maintaining consistency across large products.'
},
{
type: 'paragraph',
content: 'The key is finding the right balance between flexibility and constraints.'
},
{
type: 'paragraph',
content:
'Too rigid, and designers feel boxed in. Too flexible, and you lose consistency.'
}
]
},
excerpt: 'Exploring the balance between flexibility and constraints in design systems.',
tags: ['design', 'systems', 'ux'],
status: 'published',
publishedAt: new Date(Date.now() - 172800000) // 2 days ago
}
}),
prisma.post.create({
data: {
slug: 'morning-coffee',
postType: 'post',
content: {
blocks: [
{
type: 'paragraph',
content: 'Perfect morning for coding with a fresh cup of coffee ☕'
}
]
},
tags: ['life', 'coffee'],
status: 'published',
publishedAt: new Date(Date.now() - 259200000) // 3 days ago
}
}),
prisma.post.create({
data: {
slug: 'weekend-project',
postType: 'post',
content: {
blocks: [
{
type: 'paragraph',
content:
'Built a small CLI tool over the weekend. Sometimes the best projects come from scratching your own itch.'
}
]
},
tags: ['projects', 'cli', 'weekend'],
status: 'draft'
}
})
])
console.log(`✅ Created ${posts.length} posts`)
// Create test album and photos
const album = await prisma.album.create({
data: {
slug: 'tokyo-trip-2024',
title: 'Tokyo Trip 2024',
description: 'Photos from my recent trip to Tokyo',
date: new Date('2024-03-15'),
location: 'Tokyo, Japan',
status: 'published',
isPhotography: true,
showInUniverse: true
}
})
const photos = await Promise.all([
prisma.photo.create({
data: {
albumId: album.id,
filename: 'tokyo-tower.jpg',
url: '/local-uploads/tokyo-tower.jpg',
thumbnailUrl: '/local-uploads/thumb-tokyo-tower.jpg',
width: 1920,
height: 1080,
caption: 'Tokyo Tower at sunset',
displayOrder: 1,
status: 'published',
showInPhotos: true,
exifData: {
camera: 'Sony A7III',
lens: '24-70mm f/2.8',
iso: 400,
aperture: 'f/5.6',
shutterSpeed: '1/250s'
}
}
}),
prisma.photo.create({
data: {
albumId: album.id,
filename: 'shibuya-crossing.jpg',
url: '/local-uploads/shibuya-crossing.jpg',
thumbnailUrl: '/local-uploads/thumb-shibuya-crossing.jpg',
width: 1920,
height: 1080,
caption: 'The famous Shibuya crossing',
displayOrder: 2,
status: 'published',
showInPhotos: true
}
})
])
await prisma.album.update({
where: { id: album.id },
data: { coverPhotoId: photos[0].id }
})
console.log(`✅ Created album with ${photos.length} photos`)
// Create test media entries
const media = await Promise.all([
prisma.media.create({
data: {
filename: 'blog-header.jpg',
mimeType: 'image/jpeg',
size: 245000,
url: '/local-uploads/blog-header.jpg',
thumbnailUrl: '/local-uploads/thumb-blog-header.jpg',
width: 1920,
height: 1080,
usedIn: [{ type: 'post', id: posts[0].id }]
}
})
])
console.log(`✅ Created ${media.length} media items`)
console.log('🎉 Seed completed!')
}
main()
.catch((e) => {
console.error('❌ Seed failed:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})

39
scripts/setup-local.sh Executable file
View file

@ -0,0 +1,39 @@
#!/bin/bash
echo "🚀 Setting up local development environment..."
# Check if PostgreSQL is installed
if ! command -v psql &> /dev/null; then
echo "❌ PostgreSQL is not installed. Please install it first:"
echo " brew install postgresql@15"
echo " brew services start postgresql@15"
exit 1
fi
# Check if .env exists
if [ ! -f .env ]; then
echo "📝 Creating .env file from .env.local.example..."
cp .env.local.example .env
echo "✅ .env file created. Please update it with your local settings."
else
echo "✅ .env file already exists"
fi
# Create database
echo "🗄️ Creating local database..."
createdb universe 2>/dev/null || echo "Database already exists"
# Run Prisma commands
echo "🔧 Generating Prisma client..."
npx prisma generate
echo "📊 Running database migrations..."
npx prisma migrate dev --name initial_setup
echo "✨ Local setup complete!"
echo ""
echo "Next steps:"
echo "1. Make sure PostgreSQL is running: brew services start postgresql@15"
echo "2. Update .env with your local PostgreSQL connection string"
echo "3. Run: npm run dev"
echo "4. Visit: http://localhost:5173/api/health"

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 217 B

View file

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="6" height="6" stroke="currentColor" stroke-width="1.5" fill="none" rx="1" />
<rect x="11" y="3" width="6" height="6" stroke="currentColor" stroke-width="1.5" fill="none" rx="1" />
<rect x="3" y="11" width="6" height="6" stroke="currentColor" stroke-width="1.5" fill="none" rx="1" />
<rect x="11" y="11" width="6" height="6" stroke="currentColor" stroke-width="1.5" fill="none" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 518 B

View file

@ -0,0 +1,3 @@
<svg fill="currentColor" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg">
<path d="M 36.4023 19.3164 C 38.8398 19.3164 40.9257 17.7461 41.6992 15.5898 L 49.8085 15.5898 C 50.7695 15.5898 51.6133 14.7461 51.6133 13.6914 C 51.6133 12.6367 50.7695 11.8164 49.8085 11.8164 L 41.7226 11.8164 C 40.9257 9.6367 38.8398 8.0430 36.4023 8.0430 C 33.9648 8.0430 31.8789 9.6367 31.1054 11.8164 L 6.2851 11.8164 C 5.2304 11.8164 4.3867 12.6367 4.3867 13.6914 C 4.3867 14.7461 5.2304 15.5898 6.2851 15.5898 L 31.1054 15.5898 C 31.8789 17.7461 33.9648 19.3164 36.4023 19.3164 Z M 6.1913 26.1133 C 5.2304 26.1133 4.3867 26.9570 4.3867 28.0117 C 4.3867 29.0664 5.2304 29.8867 6.1913 29.8867 L 14.5586 29.8867 C 15.3320 32.0898 17.4179 33.6601 19.8554 33.6601 C 22.3164 33.6601 24.4023 32.0898 25.1757 29.8867 L 49.7149 29.8867 C 50.7695 29.8867 51.6133 29.0664 51.6133 28.0117 C 51.6133 26.9570 50.7695 26.1133 49.7149 26.1133 L 25.1757 26.1133 C 24.3789 23.9570 22.2929 22.3867 19.8554 22.3867 C 17.4413 22.3867 15.3554 23.9570 14.5586 26.1133 Z M 36.4023 47.9570 C 38.8398 47.9570 40.9257 46.3867 41.6992 44.2070 L 49.8085 44.2070 C 50.7695 44.2070 51.6133 43.3867 51.6133 42.3320 C 51.6133 41.2773 50.7695 40.4336 49.8085 40.4336 L 41.6992 40.4336 C 40.9257 38.2539 38.8398 36.7070 36.4023 36.7070 C 33.9648 36.7070 31.8789 38.2539 31.1054 40.4336 L 6.2851 40.4336 C 5.2304 40.4336 4.3867 41.2773 4.3867 42.3320 C 4.3867 43.3867 5.2304 44.2070 6.2851 44.2070 L 31.1054 44.2070 C 31.8789 46.3867 33.9648 47.9570 36.4023 47.9570 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 118.04 122.88"><path d="M16.08,59.26A8,8,0,0,1,0,59.26a59,59,0,0,1,97.13-45V8a8,8,0,1,1,16.08,0V33.35a8,8,0,0,1-8,8L80.82,43.62a8,8,0,1,1-1.44-15.95l8-.73A43,43,0,0,0,16.08,59.26Zm22.77,19.6a8,8,0,0,1,1.44,16l-10.08.91A42.95,42.95,0,0,0,102,63.86a8,8,0,0,1,16.08,0A59,59,0,0,1,22.3,110v4.18a8,8,0,0,1-16.08,0V89.14h0a8,8,0,0,1,7.29-8l25.31-2.3Z"/></svg>

After

Width:  |  Height:  |  Size: 439 B

View file

@ -3,14 +3,22 @@
// Work icon - cursor wiggle
@keyframes cursorWiggle {
0%, 100% { transform: rotate(0deg) scale(1); }
25% { transform: rotate(-8deg) scale(1.05); }
75% { transform: rotate(8deg) scale(1.05); }
0%,
100% {
transform: rotate(0deg) scale(1);
}
25% {
transform: rotate(-8deg) scale(1.05);
}
75% {
transform: rotate(8deg) scale(1.05);
}
}
// Photos icon - masonry height changes
@keyframes masonryRect1 {
0%, 100% {
0%,
100% {
height: 10px;
y: 2px;
}
@ -21,7 +29,8 @@
}
@keyframes masonryRect2 {
0%, 100% {
0%,
100% {
height: 6px;
y: 2px;
}
@ -32,7 +41,8 @@
}
@keyframes masonryRect3 {
0%, 100% {
0%,
100% {
height: 4px;
y: 14px;
}
@ -43,7 +53,8 @@
}
@keyframes masonryRect4 {
0%, 100% {
0%,
100% {
height: 8px;
y: 10px;
}
@ -55,21 +66,41 @@
// Labs icon - test tube rotation
@keyframes tubeRotate {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-10deg); }
75% { transform: rotate(10deg); }
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-10deg);
}
75% {
transform: rotate(10deg);
}
}
// Universe icon - star spin
@keyframes starSpin {
0% { transform: rotate(0deg); }
100% { transform: rotate(720deg); }
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(720deg);
}
}
// Placeholder animations (not currently used)
@keyframes photoMasonry {
0%, 100% { transform: scale(1); }
25% { transform: scale(1); }
50% { transform: scale(1); }
75% { transform: scale(1); }
0%,
100% {
transform: scale(1);
}
25% {
transform: scale(1);
}
50% {
transform: scale(1);
}
75% {
transform: scale(1);
}
}

View file

@ -0,0 +1,35 @@
// Global font family setting
// This applies the cstd font to all elements by default
* {
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
}
// Global body styles
body {
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
font-weight: 400;
line-height: 1.4;
}
// Heading font weights
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 500;
}
// Button and input font inheritance
button,
input,
textarea,
select {
font-family: inherit;
}
input,
textarea {
box-sizing: border-box;
}

View file

@ -20,6 +20,8 @@ $unit-8x: $unit * 8;
$unit-10x: $unit * 10;
$unit-12x: $unit * 12;
$unit-14x: $unit * 14;
$unit-16x: $unit * 16;
$unit-18x: $unit * 18;
$unit-20x: $unit * 20;
/* Page properties
@ -64,18 +66,46 @@ $letter-spacing: -0.02em;
/* Colors
* -------------------------------------------------------------------------- */
$grey-100: #ffffff;
$grey-97: #fafafa;
$grey-95: #f5f5f5;
$grey-90: #f7f7f7;
$grey-85: #ebebeb;
$grey-80: #e8e8e8;
$grey-70: #dfdfdf;
$grey-60: #cccccc;
$grey-5: #f9f9f9;
$grey-50: #b2b2b2;
$grey-40: #999999;
$grey-30: #808080;
$grey-20: #666666;
$grey-10: #4d4d4d;
$grey-00: #333333;
$red-90: #ff9d8f;
$red-80: #ff6a54;
$red-60: #e33d3d;
$red-50: #d33;
$red-40: #d31919;
$red-00: #3d0c0c;
$blue-60: #2e8bc0;
$blue-50: #1482c1;
$blue-40: #126fa8;
$blue-20: #0f5d8f;
$blue-10: #e6f3ff;
$yellow-90: #fff9e6;
$yellow-80: #ffeb99;
$yellow-70: #ffdd4d;
$yellow-60: #ffcc00;
$yellow-50: #f5c500;
$yellow-40: #e6b800;
$yellow-30: #cc9900;
$yellow-20: #996600;
$yellow-10: #664400;
$salmon-pink: #ffd5cf; // Desaturated salmon pink for hover states
$bg-color: #e8e8e8;
$page-color: #ffffff;
$card-color: #f7f7f7;
@ -85,6 +115,7 @@ $text-color-body: #666666;
$accent-color: #e33d3d;
$grey-color: #f0f0f0;
$primary-color: #1482c1; // Using labs color as primary
$image-border-color: rgba(0, 0, 0, 0.03);

48
src/lib/admin-auth.ts Normal file
View file

@ -0,0 +1,48 @@
// Simple admin authentication helper for client-side use
// In a real application, this would use proper JWT tokens or session cookies
let adminCredentials: string | null = null
// Initialize auth (call this when the admin logs in)
export function setAdminAuth(username: string, password: string) {
adminCredentials = btoa(`${username}:${password}`)
}
// Get auth headers for API requests
export function getAuthHeaders(): HeadersInit {
if (!adminCredentials) {
// For development, use default credentials
// In production, this should redirect to login
adminCredentials = btoa('admin:localdev')
}
return {
Authorization: `Basic ${adminCredentials}`
}
}
// Check if user is authenticated (basic check)
export function isAuthenticated(): boolean {
return adminCredentials !== null
}
// Clear auth (logout)
export function clearAuth() {
adminCredentials = null
}
// Make authenticated API request
export async function authenticatedFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
const headers = {
...getAuthHeaders(),
...options.headers
}
return fetch(url, {
...options,
headers
})
}

View file

@ -1,37 +1,35 @@
<script>
// What if we have a headphones avatar that is head bopping if the last scrobble was < 5 mins ago
// We can do a thought bubble-y thing with the album art that takes you to the album section of the page
import { onMount } from 'svelte'
import { onMount, onDestroy } from 'svelte'
import { spring } from 'svelte/motion'
let isHovering = $state(false)
let isBlinking = $state(false)
let isHovering = false
let isBlinking = false
const scale = spring(1, {
stiffness: 0.1,
damping: 0.125
})
$effect(() => {
if (isHovering) {
scale.set(1.25)
} else {
scale.set(1)
}
})
function handleMouseEnter() {
isHovering = true
scale.set(1.25)
}
function handleMouseLeave() {
isHovering = false
scale.set(1)
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function setBlinkState(state) {
isBlinking = state
}
async function singleBlink(duration) {
setBlinkState(true)
isBlinking = true
await sleep(duration)
setBlinkState(false)
isBlinking = false
}
async function doubleBlink() {
@ -48,25 +46,27 @@
}
}
function startBlinking() {
const blinkInterval = setInterval(() => {
let blinkInterval
onMount(() => {
blinkInterval = setInterval(() => {
if (!isHovering) {
blink()
}
}, 4000)
return () => clearInterval(blinkInterval)
}
onMount(() => {
return startBlinking()
return () => {
if (blinkInterval) {
clearInterval(blinkInterval)
}
}
})
</script>
<div
class="face-container"
on:mouseenter={() => (isHovering = true)}
on:mouseleave={() => (isHovering = false)}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
style="transform: scale({$scale})"
>
<svg

View file

@ -0,0 +1,21 @@
<script>
import jedmundIcon from '$illos/jedmund.svg?raw'
</script>
<div class="avatar-simple">
{@html jedmundIcon}
</div>
<style lang="scss">
.avatar-simple {
display: inline-block;
width: 100%;
height: 100%;
:global(svg) {
width: 100%;
height: 100%;
border-radius: $avatar-radius;
}
}
</style>

View file

@ -0,0 +1,364 @@
<script lang="ts">
import LinkCard from './LinkCard.svelte'
import Slideshow from './Slideshow.svelte'
import { formatDate } from '$lib/utils/date'
import { renderEdraContent } from '$lib/utils/content'
import { goto } from '$app/navigation'
let { post }: { post: any } = $props()
const renderedContent = $derived(post.content ? renderEdraContent(post.content) : '')
</script>
<article class="post-content {post.postType}">
<header class="post-header">
<div class="post-meta">
<a href="/universe/{post.slug}" class="post-date-link">
<time class="post-date" datetime={post.publishedAt}>
{formatDate(post.publishedAt)}
</time>
</a>
</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.album && post.album.photos && post.album.photos.length > 0}
<!-- Album slideshow -->
<div class="post-album">
<div class="album-header">
<h3>{post.album.title}</h3>
{#if post.album.description}
<p class="album-description">{post.album.description}</p>
{/if}
</div>
<Slideshow
items={post.album.photos.map((photo) => ({
url: photo.url,
thumbnailUrl: photo.thumbnailUrl,
caption: photo.caption,
alt: photo.caption || post.album.title
}))}
alt={post.album.title}
aspectRatio="4/3"
/>
</div>
{:else if post.attachments && Array.isArray(post.attachments) && post.attachments.length > 0}
<!-- Regular attachments -->
<div class="post-attachments">
<h3>Photos</h3>
<Slideshow
items={post.attachments.map((attachment) => ({
url: attachment.url,
thumbnailUrl: attachment.thumbnailUrl,
caption: attachment.caption,
alt: attachment.caption || 'Photo'
}))}
alt="Post photos"
aspectRatio="4/3"
/>
</div>
{/if}
{#if renderedContent}
<div class="post-body">
{@html renderedContent}
</div>
{/if}
<footer class="post-footer">
<button onclick={() => goto('/universe')} class="back-button">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" class="back-arrow">
<path
d="M15 8H3.5M3.5 8L8 3.5M3.5 8L8 12.5"
stroke="currentColor"
stroke-width="2.25"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Back to Universe
</button>
</footer>
</article>
<style lang="scss">
.post-content {
display: flex;
flex-direction: column;
max-width: 784px;
gap: $unit-3x;
margin: 0 auto;
padding: 0 $unit-3x;
@include breakpoint('phone') {
padding: 0 $unit-2x;
}
// Post type styles
&.post {
.post-body {
font-size: 1.125rem;
}
}
&.essay {
.post-body {
font-size: 1.125rem;
line-height: 1.7;
}
}
}
.post-header {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.post-meta {
display: flex;
align-items: center;
gap: $unit-2x;
}
.post-date-link {
text-decoration: none;
transition: color 0.2s ease;
&:hover {
.post-date {
color: $red-60;
}
}
}
.post-date {
font-size: 0.9rem;
color: $grey-40;
font-weight: 400;
transition: color 0.2s ease;
}
.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-album,
.post-attachments {
margin-bottom: $unit-4x;
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 $unit-2x;
color: $grey-20;
}
}
.album-header {
margin-bottom: $unit-3x;
h3 {
margin-bottom: $unit;
}
.album-description {
margin: 0;
font-size: 1.125rem;
color: $grey-10;
line-height: 1.5;
}
}
.post-body {
color: $grey-10;
line-height: 1.5;
: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(p:last-child) {
margin-bottom: 0;
}
: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 {
padding-bottom: $unit-2x;
}
.back-button {
color: $red-60;
background-color: transparent;
border: 1px solid transparent;
font: inherit;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
display: inline-flex;
align-items: center;
gap: $unit;
border-radius: 24px;
outline: none;
&:hover:not(:disabled) {
.back-arrow {
transform: translateX(-3px);
}
}
&:focus-visible {
box-shadow: 0 0 0 3px rgba($red-60, 0.25);
}
.back-arrow {
flex-shrink: 0;
transition: transform 0.2s ease;
margin-left: -$unit-half;
}
}
</style>

View file

@ -3,27 +3,38 @@
import SegmentedController from './SegmentedController.svelte'
let scrollY = $state(0)
let hasScrolled = $state(false)
let gradientOpacity = $derived(Math.min(scrollY / 40, 1))
// Smooth gradient opacity from 0 to 1 over the first 100px of scroll
let gradientOpacity = $derived(Math.min(scrollY / 100, 1))
// Padding transition happens more quickly
let paddingProgress = $derived(Math.min(scrollY / 50, 1))
$effect(() => {
const handleScroll = () => {
scrollY = window.scrollY
let ticking = false
// Add hysteresis to prevent flickering
if (!hasScrolled && scrollY > 30) {
hasScrolled = true
} else if (hasScrolled && scrollY < 20) {
hasScrolled = false
const updateScroll = () => {
scrollY = window.scrollY
ticking = false
}
const handleScroll = () => {
if (!ticking) {
requestAnimationFrame(updateScroll)
ticking = true
}
}
window.addEventListener('scroll', handleScroll)
// Set initial value
scrollY = window.scrollY
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
})
</script>
<header class="site-header {hasScrolled ? 'scrolled' : ''}" style="--gradient-opacity: {gradientOpacity}">
<header
class="site-header"
style="--gradient-opacity: {gradientOpacity}; --padding-progress: {paddingProgress}"
>
<div class="header-content">
<a href="/about" class="header-link" aria-label="@jedmund">
<Avatar />
@ -39,32 +50,33 @@
z-index: 100;
display: flex;
justify-content: center;
padding: $unit-5x 0;
transition:
padding 0.3s ease,
background 0.3s ease;
// Smooth padding transition based on scroll
padding: calc($unit-5x - ($unit-5x - $unit-2x) * var(--padding-progress)) 0;
pointer-events: none;
// Add a very subtle transition to smooth out any remaining jitter
transition: padding 0.1s ease-out;
&.scrolled {
padding: $unit-2x 0;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 120px;
background: linear-gradient(to bottom, rgba(0, 0, 0, calc(0.15 * var(--gradient-opacity))), transparent);
backdrop-filter: blur(calc(6px * var(--gradient-opacity)));
-webkit-backdrop-filter: blur(calc(6px * var(--gradient-opacity)));
mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
pointer-events: none;
z-index: -1;
opacity: var(--gradient-opacity);
transition: opacity 0.2s ease;
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 120px;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.15),
transparent
);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
pointer-events: none;
z-index: -1;
opacity: var(--gradient-opacity);
// Add a very subtle transition to smooth out any remaining jitter
transition: opacity 0.1s ease-out;
}
}

View file

@ -1,6 +1,5 @@
<script lang="ts">
import Lightbox from './Lightbox.svelte'
import TiltCard from './TiltCard.svelte'
import Slideshow from './Slideshow.svelte'
let {
images = [],
@ -10,191 +9,9 @@
alt?: string
} = $props()
let selectedIndex = $state(0)
let lightboxOpen = $state(false)
let windowWidth = $state(0)
// Calculate columns based on breakpoints
const columnsPerRow = $derived(windowWidth <= 400 ? 3 : windowWidth <= 600 ? 4 : 6)
const totalSlots = $derived(Math.ceil(images.length / columnsPerRow) * columnsPerRow)
const selectImage = (index: number) => {
selectedIndex = index
}
const openLightbox = (index?: number) => {
if (index !== undefined) {
selectedIndex = index
}
lightboxOpen = true
}
// Track window width for responsive columns
$effect(() => {
windowWidth = window.innerWidth
const handleResize = () => {
windowWidth = window.innerWidth
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
// Convert string array to slideshow items
const slideshowItems = $derived(images.map(url => ({ url, alt })))
</script>
{#if images.length === 1}
<!-- Single image -->
<TiltCard>
<button class="single-image image-button" onclick={() => openLightbox()}>
<img src={images[0]} {alt} />
</button>
</TiltCard>
{:else if images.length > 1}
<!-- Slideshow -->
<div class="slideshow">
<TiltCard>
<button class="main-image image-button" onclick={() => openLightbox()}>
<img src={images[selectedIndex]} alt="{alt} {selectedIndex + 1}" />
</button>
</TiltCard>
<div class="thumbnails">
{#each Array(totalSlots) as _, index}
{#if index < images.length}
<button
class="thumbnail"
class:active={index === selectedIndex}
onclick={() => selectImage(index)}
aria-label="View image {index + 1}"
>
<img src={images[index]} alt="{alt} thumbnail {index + 1}" />
</button>
{:else}
<div class="thumbnail placeholder" aria-hidden="true"></div>
{/if}
{/each}
</div>
</div>
{/if}
<Slideshow items={slideshowItems} {alt} aspectRatio="4/3" />
<Lightbox {images} bind:selectedIndex bind:isOpen={lightboxOpen} {alt} />
<style lang="scss">
.image-button {
border: none;
padding: 0;
background: none;
cursor: pointer;
display: block;
width: 100%;
&:focus {
outline: 2px solid $red-60;
outline-offset: 2px;
}
}
.single-image,
.main-image {
aspect-ratio: 4 / 3;
border-radius: $image-corner-radius;
overflow: hidden;
// Force GPU acceleration and proper clipping
transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.slideshow {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.thumbnails {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: $unit-2x;
@media (max-width: 600px) {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 400px) {
grid-template-columns: repeat(3, 1fr);
}
}
.thumbnail {
position: relative;
aspect-ratio: 1;
border-radius: $image-corner-radius;
overflow: hidden;
border: none;
padding: 0;
background: none;
cursor: pointer;
transition: all 0.2s ease;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: $image-corner-radius;
border: 4px solid transparent;
z-index: 2;
pointer-events: none;
transition: border-color 0.2s ease;
}
&::after {
content: '';
position: absolute;
inset: 4px;
border-radius: calc($image-corner-radius - 4px);
border: 4px solid transparent;
z-index: 3;
pointer-events: none;
transition: border-color 0.2s ease;
}
&:hover {
transform: scale(0.98);
}
&.active {
&::before {
border-color: $red-60;
}
&::after {
border-color: $grey-100;
}
}
&.placeholder {
background: $grey-90;
cursor: default;
&:hover {
transform: none;
}
}
img {
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
z-index: 1;
}
}
</style>

View file

@ -1,68 +1,242 @@
<script lang="ts">
import type { LabProject } from '$lib/types/labs'
import type { Project } from '$lib/types/project'
import Button from './admin/Button.svelte'
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}`)
// Tilt card functionality
let cardElement: HTMLElement
let isHovering = $state(false)
let transform = $state('')
function handleMouseMove(e: MouseEvent) {
if (!cardElement || !isHovering) return
const rect = cardElement.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const centerX = rect.width / 2
const centerY = rect.height / 2
const rotateX = ((y - centerY) / centerY) * -3 // Subtle tilt
const rotateY = ((x - centerX) / centerX) * 3
transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.02, 1.02, 1.02)`
}
function handleMouseEnter() {
isHovering = true
}
function handleMouseLeave() {
isHovering = false
transform = 'perspective(1000px) rotateX(0) rotateY(0) scale3d(1, 1, 1)'
}
</script>
<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.url || project.github}
<div class="project-links">
{#if project.url}
<a href={project.url} target="_blank" rel="noopener noreferrer" class="project-link primary">
<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"/>
{#if isClickable}
<a
href={projectUrl}
class="lab-card clickable"
bind:this={cardElement}
onmousemove={handleMouseMove}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
style:transform
>
<div class="card-header">
<div class="project-title-container">
<h3 class="project-title">{project.title}</h3>
<span class="project-year">{project.year}</span>
</div>
{#if project.externalUrl}
<Button
variant="primary"
buttonSize="medium"
href={project.externalUrl}
target="_blank"
rel="noopener noreferrer"
iconPosition="right"
onclick={(e) => e.stopPropagation()}
>
Visit site
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
Visit Project
</a>
{/if}
{#if project.github}
<a href={project.github} target="_blank" rel="noopener noreferrer" class="project-link secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
GitHub
</a>
</Button>
{/if}
</div>
{/if}
</article>
<p class="project-description">{project.description}</p>
<!-- 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"
bind:this={cardElement}
onmousemove={handleMouseMove}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
style:transform
>
<div class="card-header">
<div class="project-title-container">
<h3 class="project-title">{project.title}</h3>
<span class="project-year">{project.year}</span>
</div>
{#if project.externalUrl}
<div class="project-links">
<a
href={project.externalUrl}
target="_blank"
rel="noopener noreferrer"
class="project-link primary"
>
<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
</a>
</div>
{/if}
</div>
<p class="project-description">{project.description}</p>
<!-- Add status indicators for different project states -->
{#if project.status === 'list-only'}
<div class="status-indicator list-only">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M1 1l22 22"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>View Only</span>
</div>
{/if}
</article>
{/if}
<style lang="scss">
.lab-card {
background: $grey-100;
border-radius: $card-corner-radius;
padding: $unit-3x;
transition: transform 0.2s ease, box-shadow 0.2s ease;
display: flex;
flex-direction: column;
gap: $unit-3x;
transition:
transform 0.15s ease-out,
box-shadow 0.15s ease-out;
text-decoration: none;
color: inherit;
transform-style: preserve-3d;
will-change: transform;
// Prevent overflow issues with 3D transforms
-webkit-mask-image: -webkit-radial-gradient(white, black);
mask-image: radial-gradient(white, black);
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.12),
0 2px 10px rgba(0, 0, 0, 0.08);
.project-title {
color: $red-60;
}
}
&.clickable {
cursor: pointer;
}
@include breakpoint('phone') {
padding: $unit-2x;
}
p {
margin-bottom: 0;
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: $unit-2x;
align-items: flex-start;
gap: $unit-2x;
// Style the Button component when used in card header
:global(.btn) {
flex-shrink: 0;
margin-top: 2px; // Align with title baseline
}
}
.project-title-container {
display: flex;
flex-direction: column;
gap: $unit-half;
}
.project-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
font-size: 1.125rem;
font-weight: 400;
color: $grey-00;
line-height: 1.3;
@ -74,13 +248,13 @@
.project-year {
font-size: 0.875rem;
color: $grey-40;
font-weight: 500;
font-weight: 400;
white-space: nowrap;
}
.project-description {
margin: 0 0 $unit-3x 0;
font-size: 1rem;
font-size: 1.125rem;
line-height: 1.5;
color: $grey-20;
@ -93,6 +267,7 @@
display: flex;
gap: $unit-2x;
flex-wrap: wrap;
margin-bottom: $unit-2x;
}
.project-link {
@ -115,6 +290,10 @@
background: darken($labs-color, 10%);
transform: translateY(-1px);
}
&.external {
pointer-events: none; // Prevent clicking when it's inside a clickable card
}
}
&.secondary {
@ -132,4 +311,34 @@
flex-shrink: 0;
}
}
.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

@ -108,8 +108,19 @@
</div>
<button class="lightbox-close" onclick={close} aria-label="Close lightbox">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18 6L6 18M6 6l12 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
</div>

View file

@ -109,7 +109,6 @@
width: 100%;
text-align: left;
cursor: pointer;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
transition: border-color 0.2s ease;
&:hover {
@ -163,7 +162,6 @@
gap: $unit;
font-size: 0.875rem;
color: $grey-40;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
.favicon {
@ -184,7 +182,6 @@
font-weight: 600;
color: $grey-00;
line-height: 1.3;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
@ -196,7 +193,6 @@
font-size: 0.875rem;
color: $grey-40;
line-height: 1.4;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
@ -225,7 +221,8 @@
}
@keyframes pulse {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {

View file

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

View file

@ -1,24 +1,32 @@
<script lang="ts">
import type { PhotoItem, Photo, PhotoAlbum } from '$lib/types/photos'
import { isAlbum } from '$lib/types/photos'
import { goto } from '$app/navigation'
const {
item,
onPhotoClick
albumSlug // For when this is used within an album context
}: {
item: PhotoItem
onPhotoClick: (photo: Photo, albumPhotos?: Photo[]) => void
albumSlug?: string
} = $props()
let imageLoaded = $state(false)
function handleClick() {
if (isAlbum(item)) {
// For albums, open the cover photo with album navigation
onPhotoClick(item.coverPhoto, item.photos)
// Navigate to album page using the slug
goto(`/photos/${item.slug}`)
} else {
// For individual photos, open just that photo
onPhotoClick(item)
// For individual photos, check if we have album context
if (albumSlug) {
// Navigate to photo within album
const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix
goto(`/photos/${albumSlug}/${photoId}`)
} else {
// For standalone photos, navigate to a generic photo page (to be implemented)
console.log('Individual photo navigation not yet implemented')
}
}
}
@ -79,10 +87,10 @@
<style lang="scss">
.photo-item {
break-inside: avoid;
margin-bottom: $unit-2x;
margin-bottom: $unit-3x;
@include breakpoint('tablet') {
margin-bottom: $unit;
margin-bottom: $unit-2x;
}
}
@ -96,7 +104,9 @@
border-radius: $corner-radius;
overflow: hidden;
position: relative;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);

View file

@ -88,7 +88,12 @@
<!-- Close button -->
<button class="close-button" onclick={onClose} type="button">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M6 6l12 12M18 6l-12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path
d="M6 6l12 12M18 6l-12 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
@ -96,24 +101,31 @@
{#if hasNavigation}
<button class="nav-button nav-prev" onclick={() => onNavigate('prev')} type="button">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path
d="M15 18l-6-6 6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<button class="nav-button nav-next" onclick={() => onNavigate('next')} type="button">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path
d="M9 18l6-6-6-6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{/if}
<!-- Photo -->
<div class="photo-container">
<img
src={photo.src}
alt={photo.alt}
onload={handleImageLoad}
class:loaded={imageLoaded}
/>
<img src={photo.src} alt={photo.alt} onload={handleImageLoad} class:loaded={imageLoaded} />
{#if !imageLoaded}
<div class="loading-indicator">
<div class="spinner"></div>

View file

@ -32,7 +32,6 @@
color: $grey-20; // #666
font-size: 1rem;
font-weight: 400;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
transition: all 0.2s ease;
:global(svg) {

View file

@ -25,13 +25,13 @@
</time>
</header>
{#if post.type === 'image' && post.images}
{#if post.images && post.images.length > 0}
<div class="post-images">
<ImagePost images={post.images} alt={post.title || 'Post image'} />
</div>
{/if}
{#if post.type === 'link' && post.link}
{#if post.link}
<div class="post-link-preview">
<LinkCard link={post.link} />
</div>
@ -51,24 +51,44 @@
max-width: 784px;
margin: 0 auto;
&.note {
// Post type styles for simplified post types
&.post {
.post-body {
font-size: 1.1rem;
font-size: 1.125rem;
}
}
&.image {
.post-images {
margin-bottom: $unit-4x;
&.essay {
.post-body {
font-size: 1.125rem;
line-height: 1.5;
}
}
&.link {
.post-link-preview {
margin-bottom: $unit-4x;
max-width: 600px;
// Legacy type support
&.note,
&.microblog {
.post-body {
font-size: 1.125rem;
}
}
&.blog {
.post-body {
font-size: 1.125rem;
line-height: 1.5;
}
}
}
// Content-specific styles
.post-images {
margin-bottom: $unit-4x;
}
.post-link-preview {
margin-bottom: $unit-4x;
max-width: 600px;
}
.post-header {
@ -91,8 +111,8 @@
}
.post-body {
color: $grey-20;
line-height: 1.6;
color: $grey-10;
line-height: 1.5;
:global(h2) {
margin: $unit-4x 0 $unit-2x;

View file

@ -57,7 +57,7 @@
&.note {
.post-excerpt {
font-size: 1rem;
font-size: 1.125rem;
}
}
@ -107,8 +107,8 @@
.post-excerpt {
margin: 0;
color: $grey-00;
font-size: 1rem;
line-height: 1.3;
font-size: 1.125rem;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;

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

@ -1,26 +1,77 @@
<script lang="ts">
import SVGHoverEffect from '$components/SVGHoverEffect.svelte'
import type { SvelteComponent } from 'svelte'
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
export let SVGComponent: typeof SvelteComponent
export let backgroundColor: string
export let name: string
export let description: string
export let highlightColor: string
export let index: number = 0
interface Props {
logoUrl: string | null
backgroundColor: string
name: string
slug: string
description: string
highlightColor: string
status?: 'draft' | 'published' | 'list-only' | 'password-protected'
index?: number
}
$: isEven = index % 2 === 0
let {
logoUrl,
backgroundColor,
name,
slug,
description,
highlightColor,
status = 'published',
index = 0
}: Props = $props()
const isEven = $derived(index % 2 === 0)
const isClickable = $derived(status === 'published' || status === 'password-protected')
const isListOnly = $derived(status === 'list-only')
const isPasswordProtected = $derived(status === 'password-protected')
// Create highlighted description
$: highlightedDescription = description.replace(
new RegExp(`(${name})`, 'gi'),
`<span style="color: ${highlightColor};">$1</span>`
const highlightedDescription = $derived(
description.replace(
new RegExp(`(${name})`, 'gi'),
`<span style="color: ${highlightColor};">$1</span>`
)
)
// 3D tilt effect
let cardElement: HTMLDivElement
let isHovering = false
let transform = ''
let logoElement: HTMLElement
let isHovering = $state(false)
let transform = $state('')
let svgContent = $state('')
// Logo gravity effect
let logoTransform = $state('')
onMount(async () => {
// Load SVG content
if (logoUrl) {
try {
const response = await fetch(logoUrl)
if (response.ok) {
const text = await response.text()
const parser = new DOMParser()
const doc = parser.parseFromString(text, 'image/svg+xml')
const svgElement = doc.querySelector('svg')
if (svgElement) {
svgElement.removeAttribute('width')
svgElement.removeAttribute('height')
svgContent = svgElement.outerHTML
}
}
} catch (error) {
console.error('Failed to load SVG:', error)
}
}
return () => {
// Cleanup if needed
}
})
function handleMouseMove(e: MouseEvent) {
if (!cardElement || !isHovering) return
@ -32,10 +83,19 @@
const centerX = rect.width / 2
const centerY = rect.height / 2
const rotateX = ((y - centerY) / centerY) * -4 // -4 to 4 degrees
const rotateY = ((x - centerX) / centerX) * 4 // -4 to 4 degrees
// 3D tilt for card
const rotateX = ((y - centerY) / centerY) * -4
const rotateY = ((x - centerX) / centerX) * 4
transform = `perspective(1000px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale3d(1.014, 1.014, 1.014)`
// Gravity-based logo animation
// Logo slides in the same direction as the tilt
// When tilting down (mouse at bottom), logo slides down
// When tilting up (mouse at top), logo slides up
const logoX = -rotateY * 3 // Same direction as tilt
const logoY = rotateX * 3 // Same direction as tilt
logoTransform = `translate(${logoX}px, ${logoY}px)`
}
function handleMouseEnter() {
@ -45,28 +105,101 @@
function handleMouseLeave() {
isHovering = false
transform = 'perspective(1000px) rotateX(0) rotateY(0) scale3d(1, 1, 1)'
logoTransform = 'translate(0, 0)'
}
function handleClick() {
if (isClickable) {
goto(`/work/${slug}`)
}
}
</script>
<div
class="project-item {isEven ? 'even' : 'odd'}"
class:clickable={isClickable}
class:list-only={isListOnly}
class:password-protected={isPasswordProtected}
bind:this={cardElement}
on:mousemove={handleMouseMove}
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
onclick={handleClick}
onkeydown={(e) => e.key === 'Enter' && handleClick()}
onmousemove={isClickable ? handleMouseMove : undefined}
onmouseenter={isClickable ? handleMouseEnter : undefined}
onmouseleave={isClickable ? handleMouseLeave : undefined}
style="transform: {transform};"
role={isClickable ? 'button' : 'article'}
tabindex={isClickable ? 0 : -1}
>
<div class="project-logo">
<SVGHoverEffect
{SVGComponent}
{backgroundColor}
maxMovement={10}
containerHeight="80px"
bounceDamping={0.2}
/>
<div class="project-logo" style="background-color: {backgroundColor}">
{#if svgContent}
<div bind:this={logoElement} class="logo-svg" style="transform: {logoTransform}">
{@html svgContent}
</div>
{:else if logoUrl}
<img
src={logoUrl}
alt="{name} logo"
class="logo-image"
bind:this={logoElement}
style="transform: {logoTransform}"
/>
{/if}
</div>
<div class="project-content">
<p class="project-description">{@html highlightedDescription}</p>
{#if isListOnly}
<div class="status-indicator list-only">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
<path
d="M20.188 10.934c.388.472.612 1.057.612 1.686 0 .63-.224 1.214-.612 1.686a11.79 11.79 0 01-1.897 1.853c-1.481 1.163-3.346 2.24-5.291 2.24-1.945 0-3.81-1.077-5.291-2.24A11.79 11.79 0 016.812 14.32C6.224 13.648 6 13.264 6 12.62c0-.63.224-1.214.612-1.686A11.79 11.79 0 018.709 9.08c1.481-1.163 3.346-2.24 5.291-2.24 1.945 0 3.81 1.077 5.291 2.24a11.79 11.79 0 011.897 1.853z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M2 2l20 20" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<span>Coming Soon</span>
</div>
{:else if isPasswordProtected}
<div class="status-indicator password-protected">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
ry="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="12" cy="16" r="1" fill="currentColor" />
<path
d="M7 11V7a5 5 0 0 1 10 0v4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>Password Required</span>
</div>
{/if}
</div>
</div>
@ -79,19 +212,35 @@
padding: $unit-3x;
background: $grey-100;
border-radius: $card-corner-radius;
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out;
transition:
transform 0.15s ease-out,
box-shadow 0.15s ease-out,
opacity 0.15s ease-out;
transform-style: preserve-3d;
will-change: transform;
cursor: pointer;
cursor: default;
&:hover {
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.1),
0 1px 8px rgba(0, 0, 0, 0.06);
&.clickable {
cursor: pointer;
&:hover {
box-shadow:
0 10px 30px rgba(0, 0, 0, 0.1),
0 1px 8px rgba(0, 0, 0, 0.06);
}
}
&.list-only {
opacity: 0.7;
background: $grey-97;
}
&.password-protected {
// Keep full interactivity for password-protected items
}
&.odd {
flex-direction: row-reverse;
// flex-direction: row-reverse;
}
}
@ -99,19 +248,33 @@
flex-shrink: 0;
width: 80px;
height: 80px;
border-radius: $unit-2x;
display: flex;
align-items: center;
justify-content: center;
padding: $unit-2x;
box-sizing: border-box;
:global(.svg-container) {
width: 80px !important;
height: 80px !important;
border-radius: $unit-2x;
.logo-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transition: transform 0.4s cubic-bezier(0.2, 2.1, 0.3, 0.95);
will-change: transform;
}
.logo-svg {
display: flex;
align-items: center;
justify-content: center;
}
width: 100%;
height: 100%;
transition: transform 0.4s cubic-bezier(0.2, 2.1, 0.3, 0.95);
:global(svg) {
width: 48px !important;
height: 48px !important;
:global(svg) {
width: 48px;
height: 48px;
}
}
}
@ -127,6 +290,27 @@
color: $grey-00;
}
.status-indicator {
display: flex;
align-items: center;
gap: $unit-half;
margin-top: $unit;
font-size: 0.875rem;
font-weight: 500;
&.list-only {
color: $grey-60;
}
&.password-protected {
color: $blue-50;
}
svg {
flex-shrink: 0;
}
}
@include breakpoint('phone') {
.project-item {
flex-direction: column !important;
@ -137,16 +321,6 @@
.project-logo {
width: 60px;
height: 60px;
:global(.svg-container) {
width: 60px !important;
height: 60px !important;
}
:global(svg) {
width: 36px !important;
height: 36px !important;
}
}
}
</style>

View file

@ -1,52 +1,12 @@
<script lang="ts">
import ProjectItem from '$components/ProjectItem.svelte'
import type { Project } from '$lib/types/project'
import MaitsuLogo from '$illos/logo-maitsu.svg?component'
import SlackLogo from '$illos/logo-slack.svg?component'
import FigmaLogo from '$illos/logo-figma.svg?component'
import PinterestLogo from '$illos/logo-pinterest.svg?component'
import SVGHoverEffect from '$components/SVGHoverEffect.svelte'
interface Project {
SVGComponent: typeof SvelteComponent
backgroundColor: string
name: string
description: string
highlightColor: string
interface Props {
projects: Project[]
}
const projects: Project[] = [
{
SVGComponent: MaitsuLogo,
backgroundColor: '#FFF7EA',
name: 'Maitsu',
description: "Maitsu is a hobby journal that helps people make something new every week.",
highlightColor: '#F77754'
},
{
SVGComponent: SlackLogo,
backgroundColor: '#4a154b',
name: 'Slack',
description:
'At Slack, I helped redefine strategy for Workflows and other features in under the automation umbrella.',
highlightColor: '#611F69'
},
{
SVGComponent: FigmaLogo,
backgroundColor: '#2c2c2c',
name: 'Figma',
description: 'At Figma, I designed features and led R&D and strategy for the nascent prototyping team.',
highlightColor: '#0ACF83'
},
{
SVGComponent: PinterestLogo,
backgroundColor: '#f7f7f7',
name: 'Pinterest',
description:
'At Pinterest, I was the first product design hired, and touched almost every part of the product.',
highlightColor: '#CB1F27'
}
]
let { projects = [] }: Props = $props()
</script>
<section class="projects">
@ -54,16 +14,32 @@
<li>
<div class="intro-card">
<p class="intro-text">
<span class="highlighted">@jedmund</span> is a software designer and strategist based out of San Francisco.
<span class="highlighted">@jedmund</span> is a software designer and strategist based out of
San Francisco.
</p>
<p class="intro-text">
In his 15 year career, he's focused his design practice on building tools that help people connect with technology—and their own creativity.
In his 15 year career, he's focused his design practice on building tools that help people
connect with technology—and their own creativity.
</p>
</div>
</li>
{#if projects.length === 0}
<li>
<div class="no-projects">No projects found</div>
</li>
{/if}
{#each projects as project, index}
<li>
<ProjectItem {...project} {index} />
<ProjectItem
logoUrl={project.logoUrl}
backgroundColor={project.backgroundColor || '#f7f7f7'}
name={project.title}
slug={project.slug}
description={project.description || ''}
highlightColor={project.highlightColor || '#333'}
status={project.status}
{index}
/>
</li>
{/each}
</ul>
@ -108,7 +84,13 @@
}
.highlighted {
color: #D0290D;
color: #d0290d;
}
}
.no-projects {
padding: $unit-3x;
text-align: center;
color: $grey-40;
}
</style>

View file

@ -0,0 +1,251 @@
<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

@ -16,9 +16,9 @@
const navItems: NavItem[] = [
{ icon: WorkIcon, text: 'Work', href: '/', variant: 'work' },
{ icon: UniverseIcon, text: 'Universe', href: '/universe', variant: 'universe' },
{ icon: PhotosIcon, text: 'Photos', href: '/photos', variant: 'photos' },
{ icon: LabsIcon, text: 'Labs', href: '/labs', variant: 'labs' },
{ icon: UniverseIcon, text: 'Universe', href: '/universe', variant: 'universe' }
{ icon: LabsIcon, text: 'Labs', href: '/labs', variant: 'labs' }
]
// Track hover state for each item
@ -26,11 +26,15 @@
// Calculate active index based on current path
const activeIndex = $derived(
currentPath === '/' ? 0 :
currentPath.startsWith('/photos') ? 1 :
currentPath.startsWith('/labs') ? 2 :
currentPath.startsWith('/universe') ? 3 :
-1
currentPath === '/'
? 0
: currentPath.startsWith('/universe')
? 1
: currentPath.startsWith('/photos')
? 2
: currentPath.startsWith('/labs')
? 3
: -1
)
// Calculate pill position and width
@ -67,22 +71,32 @@
// Get background color based on variant
function getBgColor(variant: string): string {
switch (variant) {
case 'work': return '#ffcdc5' // $work-bg
case 'photos': return '#e8c5ff' // $photos-bg (purple)
case 'universe': return '#ffebc5' // $universe-bg
case 'labs': return '#c5eaff' // $labs-bg
default: return '#c5eaff'
case 'work':
return '#ffcdc5' // $work-bg
case 'photos':
return '#e8c5ff' // $photos-bg (purple)
case 'universe':
return '#ffebc5' // $universe-bg
case 'labs':
return '#c5eaff' // $labs-bg
default:
return '#c5eaff'
}
}
// Get text color based on variant
function getTextColor(variant: string): string {
switch (variant) {
case 'work': return '#d0290d' // $work-color
case 'photos': return '#7c3aed' // $photos-color (purple)
case 'universe': return '#b97d14' // $universe-color
case 'labs': return '#1482c1' // $labs-color
default: return '#1482c1'
case 'work':
return '#d0290d' // $work-color
case 'photos':
return '#7c3aed' // $photos-color (purple)
case 'universe':
return '#b97d14' // $universe-color
case 'labs':
return '#1482c1' // $labs-color
default:
return '#1482c1'
}
}
</script>
@ -102,10 +116,13 @@
class:active={index === activeIndex}
bind:this={itemElements[index]}
style="color: {index === activeIndex ? getTextColor(item.variant) : '#666'};"
onmouseenter={() => hoveredIndex = index}
onmouseleave={() => hoveredIndex = null}
onmouseenter={() => (hoveredIndex = index)}
onmouseleave={() => (hoveredIndex = null)}
>
<svelte:component this={item.icon} class="nav-icon {hoveredIndex === index ? 'animate' : ''}" />
<svelte:component
this={item.icon}
class="nav-icon {hoveredIndex === index ? 'animate' : ''}"
/>
<span>{item.text}</span>
</a>
{/each}
@ -145,10 +162,11 @@
text-decoration: none;
font-size: 1rem;
font-weight: 400;
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
position: relative;
z-index: 2;
transition: color 0.2s ease, background-color 0.2s ease;
transition:
color 0.2s ease,
background-color 0.2s ease;
&:hover:not(.active) {
background-color: rgba(0, 0, 0, 0.05);
@ -165,43 +183,39 @@
animation: iconPulse 0.6s ease;
}
}
}
// Different animations for each nav item
// First item is Work (index 1)
// First item is Work
.nav-item:nth-of-type(1) :global(svg.animate) {
animation: cursorWiggle 0.6s ease;
}
// Second item is Photos (index 2) - animation handled by individual rect animations
// Third item is Labs (index 3)
.nav-item:nth-of-type(3) :global(svg.animate) {
animation: tubeRotate 0.6s ease;
transform-origin: center bottom;
}
// Fourth item is Universe (index 4)
.nav-item:nth-of-type(4) :global(svg.animate) {
// Second item is Universe
.nav-item:nth-of-type(2) :global(svg.animate) {
animation: starSpin 0.6s ease;
}
// Specific animation for photo masonry rectangles
.nav-item:nth-of-type(2) :global(svg.animate rect:nth-child(1)) {
// Third item is Photos - animation handled by individual rect animations
.nav-item:nth-of-type(3) :global(svg.animate rect:nth-child(1)) {
animation: masonryRect1 0.6s ease;
}
.nav-item:nth-of-type(2) :global(svg.animate rect:nth-child(2)) {
.nav-item:nth-of-type(3) :global(svg.animate rect:nth-child(2)) {
animation: masonryRect2 0.6s ease;
}
.nav-item:nth-of-type(2) :global(svg.animate rect:nth-child(3)) {
.nav-item:nth-of-type(3) :global(svg.animate rect:nth-child(3)) {
animation: masonryRect3 0.6s ease;
}
.nav-item:nth-of-type(2) :global(svg.animate rect:nth-child(4)) {
.nav-item:nth-of-type(3) :global(svg.animate rect:nth-child(4)) {
animation: masonryRect4 0.6s ease;
}
// Fourth item is Labs
.nav-item:nth-of-type(4) :global(svg.animate) {
animation: tubeRotate 0.6s ease;
transform-origin: center bottom;
}
</style>

View file

@ -0,0 +1,353 @@
<script lang="ts">
import Lightbox from './Lightbox.svelte'
import TiltCard from './TiltCard.svelte'
interface SlideItem {
url: string
thumbnailUrl?: string
caption?: string
alt?: string
}
let {
items = [],
alt = 'Image',
showThumbnails = true,
aspectRatio = '4/3',
maxThumbnails,
totalCount,
showMoreLink
}: {
items: SlideItem[]
alt?: string
showThumbnails?: boolean
aspectRatio?: string
maxThumbnails?: number
totalCount?: number
showMoreLink?: string
} = $props()
let selectedIndex = $state(0)
let lightboxOpen = $state(false)
let windowWidth = $state(0)
// Calculate columns based on breakpoints
const columnsPerRow = $derived(windowWidth <= 400 ? 3 : windowWidth <= 600 ? 4 : 6)
// Make maxThumbnails responsive - use fewer thumbnails on smaller screens
const responsiveMaxThumbnails = $derived(
maxThumbnails ? (windowWidth <= 400 ? 3 : windowWidth <= 600 ? 4 : maxThumbnails) : undefined
)
const showMoreThumbnail = $derived(
responsiveMaxThumbnails && totalCount && totalCount > responsiveMaxThumbnails - 1
)
// Determine how many thumbnails to show
const displayItems = $derived(
!responsiveMaxThumbnails || !showMoreThumbnail
? items
: items.slice(0, responsiveMaxThumbnails - 1) // Show actual thumbnails, leave last slot for "+N"
)
const remainingCount = $derived(
showMoreThumbnail ? (totalCount || items.length) - (responsiveMaxThumbnails - 1) : 0
)
const totalSlots = $derived(
responsiveMaxThumbnails
? responsiveMaxThumbnails
: Math.ceil((displayItems.length + (showMoreThumbnail ? 1 : 0)) / columnsPerRow) *
columnsPerRow
)
// Convert items to image URLs for lightbox
const lightboxImages = $derived(items.map((item) => item.url))
const selectImage = (index: number) => {
selectedIndex = index
}
const openLightbox = (index?: number) => {
if (index !== undefined) {
selectedIndex = index
}
lightboxOpen = true
}
// Track window width for responsive columns
$effect(() => {
windowWidth = window.innerWidth
const handleResize = () => {
windowWidth = window.innerWidth
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
</script>
{#if items.length === 1}
<!-- Single image -->
<TiltCard>
<div class="single-image image-container" onclick={() => openLightbox()}>
<img src={items[0].url} alt={items[0].alt || alt} />
{#if items[0].caption}
<div class="image-caption">{items[0].caption}</div>
{/if}
</div>
</TiltCard>
{:else if items.length > 1}
<!-- Slideshow -->
<div class="slideshow">
<TiltCard>
<div class="main-image image-container" onclick={() => openLightbox()}>
<img
src={items[selectedIndex].url}
alt={items[selectedIndex].alt || `${alt} ${selectedIndex + 1}`}
/>
{#if items[selectedIndex].caption}
<div class="image-caption">{items[selectedIndex].caption}</div>
{/if}
</div>
</TiltCard>
{#if showThumbnails}
<div class="thumbnails">
{#each Array(totalSlots) as _, index}
{#if index < displayItems.length}
<button
class="thumbnail"
class:active={index === selectedIndex}
onclick={() => selectImage(index)}
aria-label="View image {index + 1}"
>
<img
src={displayItems[index].thumbnailUrl || displayItems[index].url}
alt="{displayItems[index].alt || alt} thumbnail {index + 1}"
/>
</button>
{:else if index === displayItems.length && showMoreThumbnail}
<a
href={showMoreLink}
class="thumbnail show-more"
aria-label="View all {totalCount || items.length} photos"
>
{#if items[displayItems.length]}
<img
src={items[displayItems.length].thumbnailUrl || items[displayItems.length].url}
alt="View all photos"
class="blurred-bg"
/>
{:else if items[items.length - 1]}
<img
src={items[items.length - 1].thumbnailUrl || items[items.length - 1].url}
alt="View all photos"
class="blurred-bg"
/>
{/if}
<div class="show-more-overlay">
+{remainingCount}
</div>
</a>
{:else}
<div class="thumbnail placeholder" aria-hidden="true"></div>
{/if}
{/each}
</div>
{/if}
</div>
{/if}
<Lightbox images={lightboxImages} bind:selectedIndex bind:isOpen={lightboxOpen} {alt} />
<style lang="scss">
.image-container {
cursor: pointer;
display: block;
width: 100%;
position: relative;
&:focus {
outline: 2px solid $red-60;
outline-offset: 2px;
}
}
.single-image,
.main-image {
width: 100%;
aspect-ratio: v-bind(aspectRatio);
border-radius: $image-corner-radius;
overflow: hidden;
display: flex;
// Force GPU acceleration and proper clipping
transform: translateZ(0);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
flex-shrink: 0;
}
}
.image-caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
padding: $unit-3x $unit-2x $unit-2x;
font-size: 0.875rem;
line-height: 1.4;
}
.slideshow {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.thumbnails {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: $unit-2x;
@media (max-width: 600px) {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 400px) {
grid-template-columns: repeat(3, 1fr);
}
}
.thumbnail {
position: relative;
aspect-ratio: 1;
border-radius: $image-corner-radius;
overflow: hidden;
border: none;
padding: 0;
background: none;
cursor: pointer;
transition: all 0.2s ease;
&::before {
content: '';
position: absolute;
inset: 0;
border-radius: $image-corner-radius;
border: 4px solid transparent;
z-index: 2;
pointer-events: none;
transition: border-color 0.2s ease;
}
&::after {
content: '';
position: absolute;
inset: 4px;
border-radius: calc($image-corner-radius - 4px);
border: 4px solid transparent;
z-index: 3;
pointer-events: none;
transition: border-color 0.2s ease;
}
&:hover {
transform: scale(0.98);
}
&:focus-visible {
outline: none;
&::before {
border-color: $red-90;
}
&::after {
border-color: $grey-100;
}
}
&.active {
&::before {
border-color: $red-60;
}
&::after {
border-color: $grey-100;
}
}
&.placeholder {
background: $grey-90;
cursor: default;
&:hover {
transform: none;
}
}
&.show-more {
position: relative;
color: inherit;
text-decoration: none;
.blurred-bg {
filter: blur(3px);
transform: scale(1.1); // Slightly scale to hide blur edges
}
.show-more-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.6);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
font-weight: 600;
border-radius: $image-corner-radius;
z-index: 2;
}
&:hover {
.show-more-overlay {
background: rgba(0, 0, 0, 0.7);
}
}
&:focus-visible {
outline: none;
&::before {
border-color: $red-90;
}
.show-more-overlay {
box-shadow: inset 0 0 0 3px rgba($red-90, 0.5);
}
}
}
img {
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
z-index: 1;
}
}
</style>

View file

@ -0,0 +1,133 @@
<script lang="ts">
import type { Media } from '@prisma/client'
import { browser } from '$app/environment'
interface Props {
media: Media
alt?: string
class?: string
containerWidth?: number // If known, use this for smart sizing
loading?: 'lazy' | 'eager'
aspectRatio?: string
sizes?: string // For responsive images
}
let {
media,
alt = media.altText || media.filename || '',
class: className = '',
containerWidth,
loading = 'lazy',
aspectRatio,
sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px'
}: Props = $props()
let imgElement: HTMLImageElement
let actualContainerWidth = $state<number | undefined>(containerWidth)
let imageUrl = $state('')
let srcSet = $state('')
// Update image URL when container width changes
$effect(() => {
imageUrl = getImageUrl()
srcSet = getSrcSet()
})
// Detect container width if not provided
$effect(() => {
if (browser && !containerWidth && imgElement?.parentElement) {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
actualContainerWidth = entry.contentRect.width
}
})
resizeObserver.observe(imgElement.parentElement)
return () => {
resizeObserver.disconnect()
}
}
})
// Smart image URL selection
function getImageUrl(): string {
if (!media.url) return ''
// SVG files should always use the original URL (they're vector, no thumbnails needed)
if (media.mimeType === 'image/svg+xml' || media.url.endsWith('.svg')) {
return media.url
}
// For local development, use what we have
if (media.url.startsWith('/local-uploads')) {
// For larger containers, prefer original over thumbnail
if (actualContainerWidth && actualContainerWidth > 400) {
return media.url // Original image
}
return media.thumbnailUrl || media.url
}
// For Cloudinary images, we could implement smart URL generation here
// For now, use the same logic as local
if (actualContainerWidth && actualContainerWidth > 400) {
return media.url
}
return media.thumbnailUrl || media.url
}
// Generate responsive srcset for better performance
function getSrcSet(): string {
// SVG files don't need srcset (they're vector and scale infinitely)
if (media.mimeType === 'image/svg+xml' || media.url.endsWith('.svg')) {
return ''
}
if (!media.url || media.url.startsWith('/local-uploads')) {
// For local images, just provide the main options
const sources = []
if (media.thumbnailUrl) {
sources.push(`${media.thumbnailUrl} 800w`)
}
if (media.url) {
sources.push(`${media.url} ${media.width || 1920}w`)
}
return sources.join(', ')
}
// For Cloudinary, we could generate multiple sizes
// This is a placeholder for future implementation
return ''
}
// Compute styles
function getImageStyles(): string {
let styles = ''
if (aspectRatio) {
styles += `aspect-ratio: ${aspectRatio.replace(':', '/')};`
}
return styles
}
</script>
<img
bind:this={imgElement}
src={imageUrl}
{alt}
class={className}
style={getImageStyles()}
{loading}
srcset={srcSet || undefined}
{sizes}
width={media.width || undefined}
height={media.height || undefined}
/>
<style>
img {
max-width: 100%;
height: auto;
}
</style>

View file

@ -0,0 +1,94 @@
<script lang="ts">
import UniverseCard from './UniverseCard.svelte'
import Slideshow from './Slideshow.svelte'
import type { UniverseItem } from '../../routes/api/universe/+server'
let { album }: { album: UniverseItem } = $props()
// Convert photos to slideshow items
const slideshowItems = $derived(
album.photos && album.photos.length > 0
? album.photos.map((photo) => ({
url: photo.url,
thumbnailUrl: photo.thumbnailUrl,
caption: photo.caption,
alt: photo.caption || album.title
}))
: album.coverPhoto
? [
{
url: album.coverPhoto.url,
thumbnailUrl: album.coverPhoto.thumbnailUrl,
caption: album.coverPhoto.caption,
alt: album.coverPhoto.caption || album.title
}
]
: []
)
</script>
<UniverseCard item={album} type="album">
{#if slideshowItems.length > 0}
<div class="album-slideshow">
<Slideshow
items={slideshowItems}
alt={album.title}
aspectRatio="3/2"
showThumbnails={slideshowItems.length > 1}
maxThumbnails={6}
totalCount={album.photosCount}
showMoreLink="/photos/{album.slug}"
/>
</div>
{/if}
<div class="album-info">
<h2 class="card-title">
<a
href="/photos/{album.slug}"
class="card-title-link"
onclick={(e) => e.preventDefault()}
tabindex="-1">{album.title}</a
>
</h2>
{#if album.description}
<p class="album-description">{album.description}</p>
{/if}
</div>
</UniverseCard>
<style lang="scss">
.album-slideshow {
position: relative;
width: 100%;
margin-bottom: $unit-2x;
}
.album-info {
margin-bottom: 0;
}
.card-title {
margin: 0 0 $unit-2x;
font-size: 1.375rem;
font-weight: 600;
line-height: 1.3;
}
.card-title-link {
text-decoration: none;
transition: all 0.2s ease;
}
.album-description {
margin: 0;
color: $grey-10;
font-size: 1.125rem;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
</style>

View file

@ -0,0 +1,197 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import UniverseIcon from '$icons/universe.svg'
import PhotosIcon from '$icons/photos.svg'
import { formatDate } from '$lib/utils/date'
import { goto } from '$app/navigation'
interface UniverseItem {
slug: string
publishedAt: string
[key: string]: any
}
let {
item,
type = 'post',
children
}: {
item: UniverseItem
type?: 'post' | 'album'
children?: Snippet
} = $props()
const href = $derived(type === 'album' ? `/photos/${item.slug}` : `/universe/${item.slug}`)
const handleCardClick = (event: MouseEvent) => {
// Check if the click is on an interactive element
const target = event.target as HTMLElement
const isInteractive =
target.closest('a') ||
target.closest('button') ||
target.closest('.slideshow') ||
target.closest('.album-slideshow') ||
target.tagName === 'A' ||
target.tagName === 'BUTTON'
if (!isInteractive) {
goto(href)
}
}
</script>
<article class="universe-card universe-card--{type}">
<div
class="card-content"
onclick={handleCardClick}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && handleCardClick(e)}
>
{@render children?.()}
<div class="card-footer">
<a {href} class="card-link" tabindex="-1">
<time class="card-date" datetime={item.publishedAt}>
{formatDate(item.publishedAt)}
</time>
</a>
{#if type === 'album'}
<PhotosIcon class="card-icon" />
{:else}
<UniverseIcon class="card-icon" />
{/if}
</div>
</div>
</article>
<style lang="scss">
@import '../../assets/styles/animations';
.universe-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;
cursor: pointer;
outline: none;
&:hover {
border-color: $grey-85;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
&:focus-visible {
outline: 2px solid $red-60;
outline-offset: 2px;
}
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: $unit-2x;
padding-top: $unit-2x;
}
.card-link {
text-decoration: none;
color: inherit;
}
.card-date {
color: $grey-40;
font-size: 0.875rem;
font-weight: 400;
transition: color 0.2s ease;
}
:global(.card-icon) {
width: 16px;
height: 16px;
fill: $grey-40;
transition: all 0.2s ease;
}
.universe-card--post {
.card-content:hover {
.card-date {
color: $red-60;
}
:global(.card-icon) {
fill: $red-60;
transform: rotate(15deg);
}
:global(.card-title-link) {
color: $red-60;
}
}
:global(.card-title-link) {
color: $grey-10;
text-decoration: none;
transition: all 0.2s ease;
}
}
.universe-card--album {
.card-content:hover {
.card-date {
color: $red-60;
}
:global(.card-icon) {
fill: $red-60;
}
:global(.card-icon rect:nth-child(1)) {
transition: all 0.3s ease;
height: 6px;
y: 2px;
}
:global(.card-icon rect:nth-child(2)) {
transition: all 0.3s ease;
height: 10px;
y: 2px;
}
:global(.card-icon rect:nth-child(3)) {
transition: all 0.3s ease;
height: 8px;
y: 10px;
}
:global(.card-icon rect:nth-child(4)) {
transition: all 0.3s ease;
height: 4px;
y: 14px;
}
:global(.card-title-link) {
color: $red-60;
}
}
// Base state for smooth transition back
:global(.card-icon rect) {
transition: all 0.3s ease;
}
:global(.card-title-link) {
color: $grey-10;
text-decoration: none;
transition: all 0.2s ease;
}
}
</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-3x;
}
.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,134 @@
<script lang="ts">
import UniverseCard from './UniverseCard.svelte'
import { getContentExcerpt } from '$lib/utils/content'
import type { UniverseItem } from '../../routes/api/universe/+server'
let { post }: { post: UniverseItem } = $props()
// Check if content is truncated
const isContentTruncated = $derived(() => {
if (post.content) {
// Check if the excerpt is shorter than the full content
const excerpt = getContentExcerpt(post.content)
return excerpt.endsWith('...')
}
return false
})
</script>
<UniverseCard item={post} type="post">
{#if post.title}
<h2 class="card-title">
<a href="/universe/{post.slug}" class="card-title-link" tabindex="-1">{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}
{#if post.content}
<div class="post-excerpt">
<p>{getContentExcerpt(post.content, 150)}</p>
</div>
{/if}
{#if post.postType === 'essay' && isContentTruncated}
<p>
<a href="/universe/{post.slug}" class="read-more" tabindex="-1">Continue reading</a>
</p>
{/if}
{#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}
</UniverseCard>
<style lang="scss">
.card-title {
margin: 0 0 $unit-3x;
font-size: 1.375rem;
font-weight: 600;
line-height: 1.3;
}
.card-title-link {
text-decoration: none;
transition: all 0.2s ease;
}
.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 {
p {
margin: 0;
color: $grey-10;
font-size: 1.125rem;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
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;
}
}
.read-more {
color: $red-60;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s ease;
}
</style>

View file

@ -0,0 +1,43 @@
<script lang="ts">
interface Props {
sections: string[]
}
let { sections }: Props = $props()
</script>
<div class="admin-byline">
{#each sections as section, index}
<span class="byline-section">{section}</span>
{#if index < sections.length - 1}
<span class="separator">&middot;</span>
{/if}
{/each}
</div>
<style lang="scss">
.admin-byline {
display: flex;
align-items: center;
gap: $unit;
font-size: 0.875rem;
color: $grey-40;
flex-wrap: wrap;
.byline-section {
// Remove text-transform: capitalize to allow proper sentence case
}
.separator {
color: $grey-40;
}
}
// Responsive adjustments
@media (max-width: 480px) {
.admin-byline {
font-size: 0.875rem;
gap: $unit-half;
}
}
</style>

View file

@ -0,0 +1,44 @@
<script lang="ts">
interface Props {
left?: any
right?: any
}
let { left, right }: Props = $props()
</script>
<div class="admin-filters">
<div class="filters-left">
{#if left}
{@render left()}
{/if}
</div>
<div class="filters-right">
{#if right}
{@render right()}
{/if}
</div>
</div>
<style lang="scss">
.admin-filters {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 $unit-2x 0 $unit;
margin-bottom: $unit-2x;
}
.filters-left {
display: flex;
gap: $unit-2x;
align-items: center;
}
.filters-right {
display: flex;
gap: $unit-2x;
align-items: center;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,39 @@
<script lang="ts">
interface Props {
title: string
actions?: any
}
let { title, actions }: Props = $props()
</script>
<header>
<h1>{title}</h1>
{#if actions}
<div class="header-actions">
{@render actions()}
</div>
{/if}
</header>
<style lang="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;
align-items: center;
gap: $unit-2x;
}
</style>

View file

@ -0,0 +1,241 @@
<script lang="ts">
import { page } from '$app/stores'
import { onMount } from 'svelte'
import AvatarSimple from '$lib/components/AvatarSimple.svelte'
import WorkIcon from '$icons/work.svg?component'
import UniverseIcon from '$icons/universe.svg?component'
import PhotosIcon from '$icons/photos.svg?component'
const currentPath = $derived($page.url.pathname)
let isScrolled = $state(false)
onMount(() => {
const handleScroll = () => {
isScrolled = window.scrollY > 0
}
window.addEventListener('scroll', handleScroll)
handleScroll() // Check initial scroll position
return () => {
window.removeEventListener('scroll', handleScroll)
}
})
interface NavItem {
text: string
href: string
icon: any
}
const navItems: NavItem[] = [
{ text: 'Projects', href: '/admin/projects', icon: WorkIcon },
{ text: 'Universe', href: '/admin/posts', icon: UniverseIcon },
{ text: 'Albums', href: '/admin/albums', icon: PhotosIcon },
{ text: 'Media', href: '/admin/media', icon: PhotosIcon }
]
// Calculate active index based on current path
const activeIndex = $derived(
currentPath.startsWith('/admin/projects')
? 0
: currentPath.startsWith('/admin/posts')
? 1
: currentPath.startsWith('/admin/albums')
? 2
: currentPath.startsWith('/admin/media')
? 3
: -1
)
</script>
<nav class="admin-nav-bar" class:scrolled={isScrolled}>
<div class="nav-container">
<div class="nav-content">
<a href="/" class="nav-brand">
<div class="brand-logo">
<AvatarSimple />
</div>
<span class="brand-text">Back to jedmund.com</span>
</a>
<div class="nav-links">
{#each navItems as item, index}
<a href={item.href} class="nav-link" class:active={index === activeIndex}>
<item.icon class="nav-icon" />
<span class="nav-text">{item.text}</span>
</a>
{/each}
</div>
</div>
</div>
</nav>
<style lang="scss">
// Breakpoint variables
$phone-max: 639px;
$tablet-min: 640px;
$tablet-max: 1023px;
$laptop-min: 1024px;
$laptop-max: 1439px;
$monitor-min: 1440px;
.admin-nav-bar {
position: sticky;
top: 0;
z-index: 100;
width: 100%;
background: $bg-color;
border-bottom: 1px solid transparent;
transition: border-bottom 0.2s ease;
&.scrolled {
border-bottom: 1px solid $grey-60;
}
}
.nav-container {
width: 100%;
padding: 0 $unit-3x;
// Phone: Full width with padding
@media (max-width: $phone-max) {
padding: 0 $unit-2x;
}
// Tablet: Constrained width
@media (min-width: $tablet-min) and (max-width: $tablet-max) {
max-width: 768px;
margin: 0 auto;
padding: 0 $unit-4x;
}
// Laptop: Wider constrained width
@media (min-width: $laptop-min) and (max-width: $laptop-max) {
max-width: 900px;
margin: 0 auto;
padding: 0 $unit-5x;
}
// Monitor: Maximum constrained width
@media (min-width: $monitor-min) {
max-width: 900px;
margin: 0 auto;
padding: 0 $unit-6x;
}
}
.nav-content {
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
gap: $unit-4x;
@media (max-width: $phone-max) {
height: 56px;
gap: $unit-2x;
}
}
.nav-brand {
display: flex;
align-items: center;
gap: $unit;
text-decoration: none;
color: $grey-30;
font-weight: 400;
font-size: 0.925rem;
transition: color 0.2s ease;
&:hover {
color: $grey-20;
}
.brand-logo {
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
:global(.face-container) {
--face-size: 32px;
width: 32px;
height: 32px;
}
:global(svg) {
width: 32px;
height: 32px;
}
}
.brand-text {
white-space: nowrap;
@media (max-width: $phone-max) {
display: none;
}
}
}
.nav-links {
display: flex;
align-items: center;
gap: $unit;
flex: 1;
justify-content: right;
@media (max-width: $phone-max) {
gap: 0;
}
}
.nav-link {
display: flex;
align-items: center;
gap: $unit;
padding: $unit $unit-2x;
border-radius: $card-corner-radius;
text-decoration: none;
font-size: 0.925rem;
font-weight: 500;
color: $grey-30;
transition: all 0.2s ease;
position: relative;
@media (max-width: $phone-max) {
padding: $unit-2x $unit;
}
&:hover {
background-color: $grey-70;
}
&.active {
color: $red-60;
background-color: $salmon-pink;
}
.nav-icon {
font-size: 1.1rem;
line-height: 1;
@media (max-width: $tablet-max) {
font-size: 1rem;
}
}
.nav-text {
@media (max-width: $phone-max) {
display: none;
}
}
}
.nav-actions {
// Placeholder for future actions if needed
}
</style>

View file

@ -0,0 +1,95 @@
<script lang="ts">
export let noHorizontalPadding = false
</script>
<section class="admin-page" class:no-horizontal-padding={noHorizontalPadding}>
<div class="page-header">
<slot name="header" />
</div>
<div class="page-content">
<slot />
</div>
{#if $$slots.fullwidth}
<div class="page-fullwidth">
<slot name="fullwidth" />
</div>
{/if}
</section>
<style lang="scss">
@import '$styles/variables.scss';
@import '$styles/mixins.scss';
.admin-page {
background: white;
border-radius: $card-corner-radius;
box-sizing: border-box;
display: flex;
flex-direction: column;
margin: 0 auto $unit-2x;
width: calc(100% - #{$unit-6x});
max-width: 900px; // Much wider for admin
min-height: calc(100vh - #{$unit-16x}); // Full height minus margins
overflow: hidden; // Ensure border-radius clips content
&:first-child {
margin-top: 0;
}
@include breakpoint('phone') {
margin-bottom: $unit-3x;
width: calc(100% - #{$unit-4x});
}
@include breakpoint('small-phone') {
width: calc(100% - #{$unit-3x});
}
}
.page-header {
box-sizing: border-box;
min-height: 110px;
padding: $unit-4x;
display: flex;
@include breakpoint('phone') {
padding: $unit-3x;
}
@include breakpoint('small-phone') {
padding: $unit-2x;
}
:global(header) {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
gap: $unit-2x;
}
}
.page-content {
padding: 0 $unit-2x $unit-4x;
@include breakpoint('phone') {
padding: 0 $unit-3x $unit-3x;
}
@include breakpoint('small-phone') {
padding: 0 $unit-2x $unit-2x;
}
}
.page-fullwidth {
padding: 0;
margin-top: $unit-3x;
@include breakpoint('small-phone') {
margin-top: $unit-2x;
}
}
</style>

View file

@ -0,0 +1,52 @@
<script lang="ts">
interface Props {
options: Array<{ value: string; label: string }>
value: string
onChange: (value: string) => void
}
let { options, value, onChange }: Props = $props()
</script>
<div class="segmented-control">
{#each options as option}
<button
class="segment"
class:active={value === option.value}
onclick={() => onChange(option.value)}
>
{option.label}
</button>
{/each}
</div>
<style lang="scss">
.segmented-control {
display: inline-flex;
background-color: $grey-95;
border-radius: 50px;
padding: $unit-half;
gap: $unit-half;
}
.segment {
padding: $unit $unit-3x;
background: transparent;
border: none;
border-radius: 50px;
font-size: 0.925rem;
color: $grey-40;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(.active) {
color: $grey-20;
}
&.active {
background-color: white;
color: $grey-10;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
}
</style>

View file

@ -0,0 +1,272 @@
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
const currentPath = $derived($page.url.pathname)
interface NavItem {
text: string
href: string
icon: string
}
const navItems: NavItem[] = [
{ text: 'Dashboard', href: '/admin', icon: '📊' },
{ text: 'Projects', href: '/admin/projects', icon: '💼' },
{ text: 'Universe', href: '/admin/posts', icon: '🌟' },
{ text: 'Media', href: '/admin/media', icon: '🖼️' }
]
// Track hover state and dropdown state
let hoveredIndex = $state<number | null>(null)
let showDropdown = $state(false)
// Calculate active index based on current path
const activeIndex = $derived(
currentPath === '/admin'
? 0
: currentPath.startsWith('/admin/projects')
? 1
: currentPath.startsWith('/admin/posts')
? 2
: currentPath.startsWith('/admin/media')
? 3
: -1
)
// Calculate pill position and width
let containerElement: HTMLElement
let itemElements: HTMLAnchorElement[] = []
let pillStyle = $state('')
function updatePillPosition() {
if (activeIndex >= 0 && itemElements[activeIndex] && containerElement) {
const activeElement = itemElements[activeIndex]
const containerRect = containerElement.getBoundingClientRect()
const activeRect = activeElement.getBoundingClientRect()
// Subtract the container padding (8px) from the left position
const left = activeRect.left - containerRect.left - 8
const width = activeRect.width
pillStyle = `transform: translateX(${left}px); width: ${width}px;`
}
}
$effect(() => {
updatePillPosition()
// Handle window resize
const handleResize = () => updatePillPosition()
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
})
function logout() {
localStorage.removeItem('admin_auth')
goto('/admin/login')
}
// Close dropdown when clicking outside
$effect(() => {
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.dropdown-container')) {
showDropdown = false
}
}
if (showDropdown) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
})
</script>
<nav class="admin-segmented-controller" bind:this={containerElement}>
<div class="pills-container">
{#if activeIndex >= 0}
<div class="active-pill" style={pillStyle}></div>
{/if}
{#each navItems as item, index}
<a
href={item.href}
class="nav-item"
class:active={index === activeIndex}
bind:this={itemElements[index]}
onmouseenter={() => (hoveredIndex = index)}
onmouseleave={() => (hoveredIndex = null)}
>
<span class="icon">{item.icon}</span>
<span>{item.text}</span>
</a>
{/each}
</div>
<div class="dropdown-container">
<button
class="dropdown-trigger"
onclick={() => (showDropdown = !showDropdown)}
aria-label="Menu"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" class:rotate={showDropdown}>
<path
d="M3 5L6 8L9 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
{#if showDropdown}
<div class="dropdown-menu">
<button class="dropdown-item" onclick={logout}>
<span>Log out</span>
</button>
</div>
{/if}
</div>
</nav>
<style lang="scss">
.admin-segmented-controller {
display: flex;
align-items: center;
gap: $unit;
background: $grey-100;
padding: $unit;
border-radius: 100px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
position: relative;
overflow: visible;
}
.pills-container {
display: flex;
align-items: center;
gap: 4px;
position: relative;
flex: 1;
}
.active-pill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: $grey-85;
border-radius: 100px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1;
}
.nav-item {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 16px;
border-radius: 100px;
text-decoration: none;
font-size: 1rem;
font-weight: 400;
color: $grey-20;
position: relative;
z-index: 2;
transition:
color 0.2s ease,
background-color 0.2s ease;
&:hover:not(.active) {
background-color: rgba(0, 0, 0, 0.05);
}
&.active {
color: $grey-10;
}
.icon {
font-size: 1.1rem;
line-height: 1;
width: 20px;
text-align: center;
}
}
.dropdown-container {
position: relative;
}
.dropdown-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: transparent;
border: none;
color: $grey-40;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
color: $grey-20;
}
svg {
transition: transform 0.2s ease;
&.rotate {
transform: rotate(180deg);
}
}
}
.dropdown-menu {
position: absolute;
top: calc(100% + $unit);
right: 0;
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 150px;
z-index: 1000;
overflow: hidden;
animation: slideDown 0.2s ease;
}
.dropdown-item {
display: block;
width: 100%;
padding: $unit-2x $unit-3x;
background: none;
border: none;
text-align: left;
font-size: 0.925rem;
color: $grey-20;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: $grey-95;
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View file

@ -0,0 +1,375 @@
<script lang="ts">
import { goto } from '$app/navigation'
import AdminPage from './AdminPage.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte'
import GalleryUploader from './GalleryUploader.svelte'
import Editor from './Editor.svelte'
import type { JSONContent } from '@tiptap/core'
import type { Media } from '@prisma/client'
interface Props {
postId?: number
initialData?: {
title?: string
content?: JSONContent
gallery?: Media[]
status: 'draft' | 'published'
tags?: string[]
}
mode: 'create' | 'edit'
}
let { postId, initialData, mode }: Props = $props()
// State
let isSaving = $state(false)
let error = $state('')
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
// Form data
let title = $state(initialData?.title || '')
let content = $state<JSONContent>({ type: 'doc', content: [] })
let gallery = $state<Media[]>([])
let tags = $state(initialData?.tags?.join(', ') || '')
// Editor ref
let editorRef: any
// Initialize data for edit mode
$effect(() => {
if (initialData && mode === 'edit') {
// Parse album content structure
if (
initialData.content &&
typeof initialData.content === 'object' &&
'type' in initialData.content
) {
const albumContent = initialData.content as any
if (albumContent.type === 'album') {
// Album content structure: { type: 'album', gallery: [mediaIds], description: JSONContent }
if (albumContent.gallery) {
// Load media objects from IDs (we'll need to fetch these)
loadGalleryMedia(albumContent.gallery)
}
if (albumContent.description) {
content = albumContent.description
}
}
} else {
// Fallback to regular content
content = initialData.content || { type: 'doc', content: [] }
}
// Load gallery from initialData if provided directly
if (initialData.gallery) {
gallery = initialData.gallery
}
}
})
async function loadGalleryMedia(mediaIds: number[]) {
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) return
const mediaPromises = mediaIds.map(async (id) => {
const response = await fetch(`/api/media/${id}`, {
headers: { Authorization: `Basic ${auth}` }
})
if (response.ok) {
return await response.json()
}
return null
})
const mediaResults = await Promise.all(mediaPromises)
gallery = mediaResults.filter((media) => media !== null)
} catch (error) {
console.error('Failed to load gallery media:', error)
}
}
// Validation
let isValid = $derived(title.trim().length > 0 && gallery.length > 0)
function handleGalleryUpload(newMedia: Media[]) {
gallery = [...gallery, ...newMedia]
}
function handleGalleryReorder(reorderedMedia: Media[]) {
gallery = reorderedMedia
}
function handleEditorChange(newContent: JSONContent) {
content = newContent
}
async function handleSave(newStatus: 'draft' | 'published' = status) {
if (!isValid) return
isSaving = true
error = ''
try {
const postData = {
title: title.trim(),
slug: generateSlug(title),
postType: 'album',
status: newStatus,
content,
gallery: gallery.map((media) => media.id),
featuredImage: gallery.length > 0 ? gallery[0].id : undefined,
tags: tags.trim() ? tags.split(',').map((tag) => tag.trim()) : []
}
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
const method = mode === 'edit' ? 'PUT' : 'POST'
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Basic ${auth}`
},
body: JSON.stringify(postData)
})
if (!response.ok) {
const errorData = await response.text()
throw new Error(`Failed to save album: ${errorData}`)
}
status = newStatus
goto('/admin/posts')
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to save album'
} finally {
isSaving = false
}
}
function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function handleCancel() {
if (hasChanges() && !confirm('Are you sure you want to cancel? Your changes will be lost.')) {
return
}
goto('/admin/posts')
}
function hasChanges(): boolean {
if (mode === 'create') {
return title.trim().length > 0 || gallery.length > 0 || tags.trim().length > 0
}
// For edit mode, compare with initial data
return (
title !== (initialData?.title || '') ||
gallery !== (initialData?.gallery || []) ||
tags !== (initialData?.tags?.join(', ') || '')
)
}
</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>📸 {mode === 'create' ? 'New Album' : 'Edit Album'}</h1>
</div>
<div class="header-actions">
{#if mode === 'create'}
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>Cancel</Button>
<Button
variant="secondary"
onclick={() => handleSave('draft')}
disabled={!isValid || isSaving}
>
{isSaving ? 'Saving...' : 'Save Draft'}
</Button>
<Button
variant="primary"
onclick={() => handleSave('published')}
disabled={!isValid || isSaving}
>
{isSaving ? 'Publishing...' : 'Publish Album'}
</Button>
{:else}
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>Cancel</Button>
<Button variant="primary" onclick={() => handleSave()} disabled={!isValid || isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
{/if}
</div>
</header>
<div class="album-form">
{#if error}
<div class="error-message">
{error}
</div>
{/if}
<div class="form-content">
<div class="form-section">
<Input
label="Album Title"
bind:value={title}
placeholder="Enter album title"
required={true}
error={title.trim().length === 0 ? 'Title is required' : undefined}
/>
</div>
<div class="form-section">
<GalleryUploader
label="Album Photos"
bind:value={gallery}
onUpload={handleGalleryUpload}
onReorder={handleGalleryReorder}
required={true}
showBrowseLibrary={true}
maxItems={50}
placeholder="Add photos to your album"
helpText="First photo will be used as the album cover"
error={gallery.length === 0 ? 'At least one photo is required' : undefined}
/>
</div>
<div class="form-section">
<div class="editor-wrapper">
<label class="form-label">Description</label>
<Editor
bind:this={editorRef}
bind:data={content}
onChange={handleEditorChange}
placeholder="Write a description for your album..."
simpleMode={false}
minHeight={200}
/>
</div>
</div>
<div class="form-section">
<Input
label="Tags"
bind:value={tags}
placeholder="travel, photography, nature"
helpText="Separate tags with commas"
/>
</div>
</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;
padding: $unit-3x;
}
.error-message {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: $unit-2x;
margin-bottom: $unit-3x;
color: #dc2626;
font-size: 0.875rem;
}
.form-content {
display: flex;
flex-direction: column;
gap: $unit-4x;
}
.form-section {
display: flex;
flex-direction: column;
}
.editor-wrapper {
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: $grey-20;
margin-bottom: $unit;
}
}
@include breakpoint('phone') {
.album-form {
padding: $unit-2x;
}
.header-actions {
flex-wrap: wrap;
gap: $unit;
}
}
</style>

View file

@ -0,0 +1,303 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { createEventDispatcher } from 'svelte'
import AdminByline from './AdminByline.svelte'
interface Photo {
id: number
url: string
thumbnailUrl: string | null
caption: string | null
}
interface Album {
id: number
slug: string
title: string
description: string | null
date: string | null
location: string | null
coverPhotoId: number | null
isPhotography: boolean
status: string
showInUniverse: boolean
publishedAt: string | null
createdAt: string
updatedAt: string
photos: Photo[]
_count: {
photos: number
}
}
interface Props {
album: Album
isDropdownActive?: boolean
}
let { album, isDropdownActive = false }: Props = $props()
const dispatch = createEventDispatcher<{
toggleDropdown: { albumId: number; event: MouseEvent }
edit: { album: Album; event: MouseEvent }
togglePublish: { album: Album; event: MouseEvent }
delete: { album: Album; event: MouseEvent }
}>()
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString)
const now = new Date()
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) return 'just now'
const minutes = Math.floor(diffInSeconds / 60)
if (diffInSeconds < 3600) return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`
const hours = Math.floor(diffInSeconds / 3600)
if (diffInSeconds < 86400) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`
const days = Math.floor(diffInSeconds / 86400)
if (diffInSeconds < 2592000) return `${days} ${days === 1 ? 'day' : 'days'} ago`
const months = Math.floor(diffInSeconds / 2592000)
if (diffInSeconds < 31536000) return `${months} ${months === 1 ? 'month' : 'months'} ago`
const years = Math.floor(diffInSeconds / 31536000)
return `${years} ${years === 1 ? 'year' : 'years'} ago`
}
function handleAlbumClick() {
goto(`/admin/albums/${album.id}/edit`)
}
function handleToggleDropdown(event: MouseEvent) {
dispatch('toggleDropdown', { albumId: album.id, event })
}
function handleEdit(event: MouseEvent) {
dispatch('edit', { album, event })
}
function handleTogglePublish(event: MouseEvent) {
dispatch('togglePublish', { album, event })
}
function handleDelete(event: MouseEvent) {
dispatch('delete', { album, event })
}
// Get thumbnail - try cover photo first, then first photo
function getThumbnailUrl(): string | null {
if (album.coverPhotoId && album.photos.length > 0) {
const coverPhoto = album.photos.find((p) => p.id === album.coverPhotoId)
if (coverPhoto) {
return coverPhoto.thumbnailUrl || coverPhoto.url
}
}
// Fallback to first photo
if (album.photos.length > 0) {
return album.photos[0].thumbnailUrl || album.photos[0].url
}
return null
}
function getPhotoCount(): number {
return album._count?.photos || 0
}
</script>
<div
class="album-item"
role="button"
tabindex="0"
onclick={handleAlbumClick}
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick()}
>
<div class="album-thumbnail">
{#if getThumbnailUrl()}
<img src={getThumbnailUrl()} alt="{album.title} thumbnail" class="thumbnail-image" />
{:else}
<div class="thumbnail-placeholder">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M19 3H5C3.89 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H19C20.11 21 21 20.1 21 19V5C21 3.9 20.11 3 19 3ZM19 19H5V5H19V19ZM13.96 12.29L11.21 15.83L9.25 13.47L6.5 17H17.5L13.96 12.29Z"
fill="currentColor"
/>
</svg>
</div>
{/if}
</div>
<div class="album-info">
<h3 class="album-title">{album.title}</h3>
<AdminByline
sections={[
album.isPhotography ? 'Photography' : 'Album',
album.status === 'published' ? 'Published' : 'Draft',
`${getPhotoCount()} ${getPhotoCount() === 1 ? 'photo' : 'photos'}`,
album.status === 'published' && album.publishedAt
? `Published ${formatRelativeTime(album.publishedAt)}`
: `Created ${formatRelativeTime(album.createdAt)}`
]}
/>
</div>
<div class="dropdown-container">
<button class="action-button" onclick={handleToggleDropdown} aria-label="Album actions">
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="10" cy="4" r="1.5" fill="currentColor" />
<circle cx="10" cy="10" r="1.5" fill="currentColor" />
<circle cx="10" cy="16" r="1.5" fill="currentColor" />
</svg>
</button>
{#if isDropdownActive}
<div class="dropdown-menu">
<button class="dropdown-item" onclick={handleEdit}> Edit album </button>
<button class="dropdown-item" onclick={handleTogglePublish}>
{album.status === 'published' ? 'Unpublish' : 'Publish'} album
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item delete" onclick={handleDelete}> Delete album </button>
</div>
{/if}
</div>
</div>
<style lang="scss">
.album-item {
display: flex;
box-sizing: border-box;
align-items: center;
gap: $unit-2x;
padding: $unit-2x;
background: white;
border-radius: $unit-2x;
cursor: pointer;
transition: all 0.2s ease;
width: 100%;
text-align: left;
&:hover {
background-color: $grey-95;
}
}
.album-thumbnail {
flex-shrink: 0;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
border-radius: $unit;
overflow: hidden;
background-color: $grey-90;
.thumbnail-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: $grey-50;
}
}
.album-info {
flex: 1;
display: flex;
flex-direction: column;
gap: $unit-half;
min-width: 0;
}
.album-title {
font-size: 1rem;
font-weight: 600;
color: $grey-10;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dropdown-container {
position: relative;
flex-shrink: 0;
}
.action-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: transparent;
border: none;
border-radius: $unit;
cursor: pointer;
color: $grey-30;
transition: all 0.2s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
}
.dropdown-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: $unit-half;
background: white;
border: 1px solid $grey-85;
border-radius: $unit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
min-width: 180px;
z-index: 10;
}
.dropdown-item {
width: 100%;
padding: $unit-2x $unit-3x;
background: none;
border: none;
text-align: left;
font-size: 0.875rem;
color: $grey-20;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: $grey-95;
}
&.delete {
color: $red-60;
}
}
.dropdown-divider {
height: 1px;
background-color: $grey-90;
margin: $unit-half 0;
}
</style>

View file

@ -0,0 +1,104 @@
<script lang="ts">
import GenericMetadataPopover, { type MetadataConfig } from './GenericMetadataPopover.svelte'
type Props = {
album: any
triggerElement: HTMLElement
onUpdate: (key: string, value: any) => void
onDelete: () => void
onClose?: () => void
}
let {
album = $bindable(),
triggerElement,
onUpdate,
onDelete,
onClose = () => {}
}: Props = $props()
// Convert album date to YYYY-MM-DD format for date input
const albumDate = $derived(album.date ? new Date(album.date).toISOString().split('T')[0] : '')
// Handle date changes - convert back to ISO string
function handleDateChange(key: string, value: string) {
if (key === 'date') {
const isoDate = value ? new Date(value).toISOString() : null
onUpdate(key, isoDate)
} else {
onUpdate(key, value)
}
}
const config: MetadataConfig = {
title: 'Album Settings',
fields: [
{
type: 'input',
key: 'slug',
label: 'Slug',
placeholder: 'album-url-slug',
helpText: 'Used in the album URL.'
},
{
type: 'date',
key: 'date',
label: 'Date',
helpText: 'When was this album created or photos taken?'
},
{
type: 'input',
key: 'location',
label: 'Location',
placeholder: 'Location where photos were taken'
},
{
type: 'section',
key: 'display-options',
label: 'Display Options'
},
{
type: 'toggle',
key: 'isPhotography',
label: 'Show in Photos',
helpText: 'Show this album in the photography experience'
},
{
type: 'toggle',
key: 'showInUniverse',
label: 'Show in Universe',
helpText: 'Display this album in the Universe feed'
},
{
type: 'metadata',
key: 'metadata'
}
],
deleteButton: {
label: 'Delete Album',
action: onDelete
}
}
// Create a reactive data object that includes the formatted date
let popoverData = $state({
...album,
date: albumDate
})
// Sync changes back to album
$effect(() => {
popoverData = {
...album,
date: albumDate
}
})
</script>
<GenericMetadataPopover
{config}
bind:data={popoverData}
{triggerElement}
onUpdate={handleDateChange}
{onClose}
/>

View file

@ -0,0 +1,416 @@
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements'
interface Props extends HTMLButtonAttributes {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'text' | 'overlay' | 'danger-text'
buttonSize?: 'small' | 'medium' | 'large' | 'icon'
iconOnly?: boolean
iconPosition?: 'left' | 'right'
pill?: boolean
fullWidth?: boolean
loading?: boolean
active?: boolean
href?: string
class?: string
}
let {
variant = 'primary',
buttonSize = 'medium',
iconOnly = false,
iconPosition = 'left',
pill = true,
fullWidth = false,
loading = false,
active = false,
disabled = false,
type = 'button',
href,
class: className = '',
children,
onclick,
...restProps
}: Props = $props()
// Compute button classes
const buttonClass = $derived.by(() => {
const classes = ['btn']
// Variant
classes.push(`btn-${variant}`)
// Size
if (!iconOnly) {
classes.push(`btn-${buttonSize}`)
} else {
classes.push('btn-icon')
classes.push(`btn-icon-${buttonSize}`)
}
// States
if (active) classes.push('active')
if (loading) classes.push('loading')
if (fullWidth) classes.push('full-width')
if (!pill && !iconOnly) classes.push('btn-square')
// Custom class
if (className) classes.push(className)
return classes.join(' ')
})
// Handle icon slot positioning
const hasIcon = $derived(!!$$slots.icon)
const hasDefaultSlot = $derived(!!$$slots.default)
const showSpinner = $derived(loading && !iconOnly)
</script>
{#if href}
<a {href} class={buttonClass} class:disabled={disabled || loading} {...restProps}>
{#if showSpinner}
<svg class="btn-spinner" width="16" height="16" viewBox="0 0 16 16">
<circle
cx="8"
cy="8"
r="6"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-dasharray="25"
stroke-dashoffset="25"
stroke-linecap="round"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 8 8"
to="360 8 8"
dur="1s"
repeatCount="indefinite"
/>
</circle>
</svg>
{/if}
{#if hasIcon && iconPosition === 'left' && !iconOnly}
<span class="btn-icon-wrapper">
<slot name="icon" />
</span>
{/if}
{#if hasDefaultSlot && !iconOnly}
<span class="btn-label">
<slot />
</span>
{:else if iconOnly && hasIcon}
<slot name="icon" />
{/if}
{#if hasIcon && iconPosition === 'right' && !iconOnly}
<span class="btn-icon-wrapper">
<slot name="icon" />
</span>
{/if}
</a>
{:else}
<button class={buttonClass} {type} disabled={disabled || loading} {onclick} {...restProps}>
{#if showSpinner}
<svg class="btn-spinner" width="16" height="16" viewBox="0 0 16 16">
<circle
cx="8"
cy="8"
r="6"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-dasharray="25"
stroke-dashoffset="25"
stroke-linecap="round"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 8 8"
to="360 8 8"
dur="1s"
repeatCount="indefinite"
/>
</circle>
</svg>
{/if}
{#if hasIcon && iconPosition === 'left' && !iconOnly}
<span class="btn-icon-wrapper">
<slot name="icon" />
</span>
{/if}
{#if hasDefaultSlot && !iconOnly}
<span class="btn-label">
<slot />
</span>
{:else if iconOnly && hasIcon}
<slot name="icon" />
{/if}
{#if hasIcon && iconPosition === 'right' && !iconOnly}
<span class="btn-icon-wrapper">
<slot name="icon" />
</span>
{/if}
</button>
{/if}
<style lang="scss">
@import '$styles/variables.scss';
// Base button styles
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: $unit;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
position: relative;
white-space: nowrap;
text-decoration: none;
box-sizing: border-box;
&:disabled,
&.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
&.loading {
color: transparent;
}
&.full-width {
width: 100%;
}
// Ensure consistent styling for both button and anchor elements
&:focus {
outline: 2px solid rgba(59, 130, 246, 0.5);
outline-offset: 2px;
}
}
// Size variations
.btn-small {
padding: $unit calc($unit * 1.5);
font-size: 13px;
border-radius: 20px;
min-height: 28px;
}
.btn-medium {
padding: $unit $unit-2x;
font-size: 14px;
border-radius: 24px;
min-height: 36px;
}
.btn-large {
padding: calc($unit * 1.5) $unit-3x;
font-size: 15px;
border-radius: 28px;
min-height: 44px;
}
// Square corners variant
.btn-square {
&.btn-small {
border-radius: 6px;
}
&.btn-medium {
border-radius: 8px;
}
&.btn-large {
border-radius: 10px;
}
}
// Icon-only button styles
.btn-icon {
padding: 0;
border-radius: 8px;
&.btn-icon-small {
width: 28px;
height: 28px;
border-radius: 6px;
}
&.btn-icon-medium {
width: 34px;
height: 34px;
}
&.btn-icon-large {
width: 44px;
height: 44px;
border-radius: 10px;
}
&.btn-icon-icon {
// For circular icon buttons
width: 34px;
height: 34px;
border-radius: 17px;
}
}
// Variant styles
.btn-primary {
background-color: $red-60;
color: white;
&:hover:not(:disabled) {
background-color: $red-80;
}
&:active:not(:disabled) {
background-color: $red-40;
}
}
.btn-secondary {
background-color: $grey-10;
color: $grey-80;
border: 1px solid $grey-20;
&:hover:not(:disabled) {
background-color: $grey-20;
border-color: $grey-30;
}
&:active:not(:disabled) {
background-color: $grey-30;
}
}
.btn-danger {
background-color: $yellow-60;
color: $yellow-10;
&:hover:not(:disabled) {
background-color: $yellow-50;
}
&:active:not(:disabled) {
background-color: $yellow-40;
}
}
.btn-ghost {
background-color: transparent;
color: $grey-20;
&:hover:not(:disabled) {
background-color: $grey-5;
color: $grey-00;
}
&:active:not(:disabled) {
background-color: $grey-10;
}
&.active {
background-color: $grey-10;
color: $grey-00;
}
}
.btn-text {
background: none;
color: $grey-40;
padding: $unit;
&:hover:not(:disabled) {
color: $grey-20;
background-color: $grey-5;
}
&:active:not(:disabled) {
color: $grey-00;
}
}
.btn-danger-text {
background: none;
color: #dc2626;
padding: $unit;
font-weight: 600;
&:hover:not(:disabled) {
background-color: $grey-90;
color: #dc2626;
}
&:active:not(:disabled) {
background-color: $grey-80;
color: #dc2626;
}
}
.btn-overlay {
background-color: white;
color: $grey-20;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
&:hover:not(:disabled) {
background-color: $grey-5;
color: $grey-00;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
&:active:not(:disabled) {
background-color: $grey-10;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
// Icon wrapper
.btn-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
// Loading spinner
.btn-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: currentColor;
}
// Label wrapper
.btn-label {
line-height: 1;
}
// Special states
.btn.active {
&.btn-ghost {
background-color: rgba($blue-50, 0.1);
color: $blue-50;
}
}
// Icon color inheritance
:global(.btn svg) {
color: currentColor;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,170 @@
<script lang="ts">
interface Column<T> {
key: string
label: string
render?: (item: T) => string
component?: any
width?: string
}
interface Props<T> {
data: T[]
columns: Column<T>[]
isLoading?: boolean
emptyMessage?: string
onRowClick?: (item: T) => void
unstyled?: boolean
}
let {
data = [],
columns = [],
isLoading = false,
emptyMessage = 'No data found',
onRowClick,
unstyled = false
}: Props<any> = $props()
function getCellValue(item: any, column: Column<any>) {
if (column.render) {
return column.render(item)
}
// Handle nested properties
const keys = column.key.split('.')
let value = item
for (const key of keys) {
value = value?.[key]
}
return value
}
</script>
<div class="data-table-wrapper" class:unstyled>
{#if isLoading}
<div class="loading">
<div class="spinner"></div>
<p>Loading...</p>
</div>
{:else if data.length === 0}
<div class="empty-state">
<p>{emptyMessage}</p>
</div>
{:else}
<table class="data-table">
<thead>
<tr>
{#each columns as column}
<th style={column.width ? `width: ${column.width}` : ''}>
{column.label}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each data as item}
<tr class:clickable={!!onRowClick} onclick={() => onRowClick?.(item)}>
{#each columns as column}
<td>
{#if column.component}
<svelte:component this={column.component} {item} />
{:else}
{getCellValue(item, column)}
{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<style lang="scss">
.data-table-wrapper {
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
&.unstyled {
border-radius: 0;
box-shadow: none;
}
}
.loading {
padding: $unit-8x;
text-align: center;
color: $grey-40;
.spinner {
width: 32px;
height: 32px;
border: 3px solid $grey-80;
border-top-color: $primary-color;
border-radius: 50%;
margin: 0 auto $unit-2x;
animation: spin 0.8s linear infinite;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-state {
padding: $unit-8x;
text-align: center;
color: $grey-40;
p {
margin: 0;
}
}
.data-table {
width: 100%;
border-collapse: collapse;
thead {
background-color: $grey-95;
border-bottom: 1px solid $grey-85;
}
th {
padding: $unit-3x $unit-4x;
text-align: left;
font-weight: 600;
font-size: 0.875rem;
color: $grey-30;
text-transform: uppercase;
letter-spacing: 0.05em;
}
tbody tr {
border-bottom: 1px solid $grey-90;
transition: background-color 0.2s ease;
&:hover {
background-color: $grey-97;
}
&.clickable {
cursor: pointer;
}
&:last-child {
border-bottom: none;
}
}
td {
padding: $unit-4x;
color: $grey-20;
}
}
</style>

View file

@ -0,0 +1,97 @@
<script lang="ts">
import Button from './Button.svelte'
interface Props {
isOpen: boolean
title?: string
message: string
confirmText?: string
cancelText?: string
onConfirm: () => void
onCancel?: () => void
}
let {
isOpen = $bindable(),
title = 'Delete item?',
message,
confirmText = 'Delete',
cancelText = 'Cancel',
onConfirm,
onCancel
}: Props = $props()
function handleConfirm() {
onConfirm()
}
function handleCancel() {
isOpen = false
onCancel?.()
}
function handleBackdropClick() {
isOpen = false
onCancel?.()
}
</script>
{#if isOpen}
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div class="modal" onclick={(e) => e.stopPropagation()}>
<h2>{title}</h2>
<p>{message}</p>
<div class="modal-actions">
<Button variant="secondary" onclick={handleCancel}>
{cancelText}
</Button>
<Button variant="danger" onclick={handleConfirm}>
{confirmText}
</Button>
</div>
</div>
</div>
{/if}
<style lang="scss">
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1050;
}
.modal {
background: white;
border-radius: $unit-2x;
padding: $unit-4x;
max-width: 400px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
h2 {
margin: 0 0 $unit-2x;
font-size: 1.25rem;
font-weight: 700;
color: $grey-10;
}
p {
margin: 0 0 $unit-4x;
color: $grey-20;
line-height: 1.5;
}
}
.modal-actions {
display: flex;
gap: $unit-2x;
justify-content: flex-end;
}
</style>

View file

@ -0,0 +1,54 @@
<script lang="ts">
interface Props {
onclick?: (event: MouseEvent) => void
variant?: 'default' | 'danger'
disabled?: boolean
}
let { onclick, variant = 'default', disabled = false, children }: Props = $props()
function handleClick(event: MouseEvent) {
if (disabled) return
event.stopPropagation()
onclick?.(event)
}
</script>
<button
class="dropdown-item"
class:danger={variant === 'danger'}
class:disabled
{disabled}
onclick={handleClick}
>
{@render children()}
</button>
<style lang="scss">
@import '$styles/variables.scss';
.dropdown-item {
width: 100%;
padding: $unit-2x $unit-3x;
background: none;
border: none;
text-align: left;
font-size: 0.875rem;
color: $grey-20;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover:not(:disabled) {
background-color: $grey-95;
}
&.danger {
color: $red-60;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
</style>

View file

@ -0,0 +1,129 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
import { browser } from '$app/environment'
interface Props {
isOpen: boolean
triggerElement?: HTMLElement
items: DropdownItem[]
onClose?: () => void
}
interface DropdownItem {
id: string
label: string
action: () => void
variant?: 'default' | 'danger'
divider?: boolean
}
let { isOpen = $bindable(), triggerElement, items, onClose }: Props = $props()
let dropdownElement: HTMLDivElement
const dispatch = createEventDispatcher()
// Calculate position dynamically when needed
const position = $derived(() => {
if (!isOpen || !triggerElement || !browser) {
return { top: 0, left: 0 }
}
const rect = triggerElement.getBoundingClientRect()
const dropdownWidth = 180
return {
top: rect.bottom + 4,
left: rect.right - dropdownWidth
}
})
function handleItemClick(item: DropdownItem, event: MouseEvent) {
event.stopPropagation()
item.action()
isOpen = false
onClose?.()
}
function handleOutsideClick(event: MouseEvent) {
if (!dropdownElement || !isOpen) return
const target = event.target as HTMLElement
if (!dropdownElement.contains(target) && !triggerElement?.contains(target)) {
isOpen = false
onClose?.()
}
}
$effect(() => {
if (browser && isOpen) {
document.addEventListener('click', handleOutsideClick)
return () => {
document.removeEventListener('click', handleOutsideClick)
}
}
})
</script>
{#if isOpen && browser}
<div
bind:this={dropdownElement}
class="dropdown-menu"
style="top: {position().top}px; left: {position().left}px"
>
{#each items as item}
{#if item.divider}
<div class="dropdown-divider"></div>
{:else}
<button
class="dropdown-item"
class:danger={item.variant === 'danger'}
onclick={(e) => handleItemClick(item, e)}
>
{item.label}
</button>
{/if}
{/each}
</div>
{/if}
<style lang="scss">
@import '$styles/variables.scss';
.dropdown-menu {
position: fixed;
background: white;
border: 1px solid $grey-85;
border-radius: $unit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
min-width: 180px;
z-index: 1000;
}
.dropdown-item {
width: 100%;
padding: $unit-2x $unit-3x;
background: none;
border: none;
text-align: left;
font-size: 0.875rem;
color: $grey-20;
cursor: pointer;
transition: background-color 0.2s ease;
display: block;
&:hover {
background-color: $grey-95;
}
&.danger {
color: $red-60;
}
}
.dropdown-divider {
height: 1px;
background-color: $grey-90;
margin: $unit-half 0;
}
</style>

View file

@ -0,0 +1,28 @@
<script lang="ts">
interface Props {
class?: string
}
let { class: className = '', children }: Props = $props()
</script>
<div class="dropdown-menu {className}">
{@render children()}
</div>
<style lang="scss">
@import '$styles/variables.scss';
.dropdown-menu {
position: absolute;
top: calc(100% + $unit-half);
right: 0;
background: white;
border: 1px solid $grey-85;
border-radius: $unit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
min-width: 180px;
z-index: 10;
}
</style>

View file

@ -0,0 +1,385 @@
<script lang="ts">
import EditorWithUpload from './EditorWithUpload.svelte'
import type { Editor } from '@tiptap/core'
import type { JSONContent } from '@tiptap/core'
interface Props {
data?: JSONContent
onChange?: (data: JSONContent) => void
placeholder?: string
readOnly?: boolean
minHeight?: number
autofocus?: boolean
class?: string
showToolbar?: boolean
simpleMode?: boolean
}
let {
data = $bindable({
type: 'doc',
content: [{ type: 'paragraph' }]
}),
onChange,
placeholder = 'Type "/" for commands...',
readOnly = false,
minHeight = 400,
autofocus = false,
class: className = '',
showToolbar = true,
simpleMode = false
}: Props = $props()
let editor = $state<Editor | undefined>()
let initialized = false
// Update content when editor changes
function onUpdate(props: { editor: Editor }) {
// Skip the first update to avoid circular updates
if (!initialized) {
initialized = true
return
}
const json = props.editor.getJSON()
data = json
onChange?.(json)
}
// Public API
export function save(): JSONContent | null {
return editor?.getJSON() || null
}
export function clear() {
editor?.commands.clearContent()
}
export function focus() {
editor?.commands.focus()
}
export function getIsDirty(): boolean {
// This would need to track changes since last save
return false
}
// Focus on mount if requested
$effect(() => {
if (editor && autofocus) {
// Only focus once on initial mount
const timer = setTimeout(() => {
editor.commands.focus()
}, 100)
return () => clearTimeout(timer)
}
})
</script>
<div class="editor-wrapper {className}" style="--min-height: {minHeight}px">
<div class="editor-container">
<EditorWithUpload
bind:editor
content={data}
{onUpdate}
editable={!readOnly}
showToolbar={!simpleMode && showToolbar}
{placeholder}
showSlashCommands={!simpleMode}
showLinkBubbleMenu={!simpleMode}
showTableBubbleMenu={false}
class="editor-content"
/>
</div>
</div>
<style lang="scss">
@import '$styles/variables.scss';
@import '$styles/mixins.scss';
.editor-wrapper {
width: 100%;
min-height: var(--min-height);
height: 100%;
background: white;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
}
.editor-container {
flex: 1;
overflow-y: auto;
position: relative;
display: flex;
flex-direction: column;
padding: 0;
}
:global(.editor-content) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.editor-content .edra) {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.editor-content .editor-toolbar) {
border-radius: $card-corner-radius;
box-sizing: border-box;
background: $grey-95;
padding: $unit-2x;
position: sticky;
top: 0;
z-index: 10;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
// Hide scrollbar but keep functionality
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
// Override Edra toolbar styles
:global(.edra-toolbar) {
overflow: visible;
width: auto;
padding: 0;
display: flex;
align-items: center;
gap: $unit;
}
}
// Override Edra styles to match our design
:global(.edra-editor) {
flex: 1;
min-height: 0;
height: 100%;
overflow-y: auto;
padding: 0 $unit-4x;
@include breakpoint('phone') {
padding: $unit-3x;
}
}
:global(.edra .ProseMirror) {
font-size: 16px;
line-height: 1.6;
color: $grey-10;
min-height: 100%;
padding-bottom: 30vh; // Give space for scrolling
}
:global(.edra .ProseMirror h1) {
font-size: 2rem;
font-weight: 700;
margin: $unit-3x 0 $unit-2x;
line-height: 1.2;
}
:global(.edra .ProseMirror h2) {
font-size: 1.5rem;
font-weight: 600;
margin: $unit-3x 0 $unit-2x;
line-height: 1.3;
}
:global(.edra .ProseMirror h3) {
font-size: 1.25rem;
font-weight: 600;
margin: $unit-3x 0 $unit-2x;
line-height: 1.4;
}
:global(.edra .ProseMirror p) {
margin: $unit-2x 0;
}
:global(.edra .ProseMirror ul),
:global(.edra .ProseMirror ol) {
padding-left: $unit-4x;
margin: $unit-2x 0;
}
:global(.edra .ProseMirror li) {
margin: $unit 0;
}
:global(.edra .ProseMirror blockquote) {
border-left: 3px solid $grey-80;
margin: $unit-3x 0;
padding-left: $unit-3x;
font-style: italic;
color: $grey-30;
}
:global(.edra .ProseMirror pre) {
background: $grey-95;
border: 1px solid $grey-80;
border-radius: 4px;
color: $grey-10;
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
font-size: 0.875rem;
line-height: 1.5;
margin: $unit-2x 0;
padding: $unit-2x;
overflow-x: auto;
}
:global(.edra .ProseMirror code) {
background: $grey-90;
border-radius: 0.25rem;
color: $grey-10;
font-family: 'SF Mono', Monaco, Consolas, 'Courier New', monospace;
font-size: 0.875em;
padding: 0.125rem 0.25rem;
}
:global(.edra .ProseMirror hr) {
border: none;
border-top: 1px solid $grey-80;
margin: $unit-4x 0;
}
:global(.edra .ProseMirror a) {
color: #3b82f6;
text-decoration: underline;
cursor: pointer;
}
:global(.edra .ProseMirror a:hover) {
color: #2563eb;
}
:global(.edra .ProseMirror ::selection) {
background: rgba(59, 130, 246, 0.15);
}
// Placeholder
:global(.edra .ProseMirror p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
color: #999;
pointer-events: none;
height: 0;
}
// Focus styles
:global(.edra .ProseMirror.ProseMirror-focused) {
outline: none;
}
// Loading state
:global(.edra-loading) {
display: flex;
align-items: center;
justify-content: center;
min-height: var(--min-height);
color: $grey-50;
gap: $unit;
}
// Image styles
:global(.edra .ProseMirror img) {
max-width: 100%;
width: auto;
max-height: 400px;
height: auto;
border-radius: 4px;
margin: $unit-2x auto;
display: block;
object-fit: contain;
}
:global(.edra-media-placeholder-wrapper) {
margin: $unit-2x 0;
}
:global(.edra-media-placeholder-content) {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
padding: $unit-4x;
border: 2px dashed $grey-80;
border-radius: 8px;
background: $grey-95;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: $grey-60;
background: $grey-90;
}
}
:global(.edra-media-placeholder-icon) {
width: 48px;
height: 48px;
color: $grey-50;
}
:global(.edra-media-placeholder-text) {
font-size: 1rem;
color: $grey-30;
}
// Image container styles
:global(.edra-media-container) {
margin: $unit-3x auto;
position: relative;
&.align-left {
margin-left: 0;
}
&.align-right {
margin-right: 0;
margin-left: auto;
}
&.align-center {
margin-left: auto;
margin-right: auto;
}
}
:global(.edra-media-content) {
width: 100%;
height: auto;
display: block;
}
:global(.edra-media-caption) {
width: 100%;
margin-top: $unit;
padding: $unit $unit-2x;
border: 1px solid $grey-80;
border-radius: 4px;
font-size: 0.875rem;
color: $grey-30;
background: $grey-95;
&:focus {
outline: none;
border-color: $grey-60;
background: white;
}
}
</style>

View file

@ -0,0 +1,866 @@
<script lang="ts">
import { type Editor } from '@tiptap/core'
import { onMount } from 'svelte'
import { initiateEditor } from '$lib/components/edra/editor.js'
import { EdraToolbar, EdraBubbleMenu } from '$lib/components/edra/headless/index.js'
import LoaderCircle from 'lucide-svelte/icons/loader-circle'
import { focusEditor, type EdraProps } from '$lib/components/edra/utils.js'
import EdraToolBarIcon from '$lib/components/edra/headless/components/EdraToolBarIcon.svelte'
import { commands } from '$lib/components/edra/commands/commands.js'
// Import all the same components as Edra
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight'
import { all, createLowlight } from 'lowlight'
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
import CodeExtended from '$lib/components/edra/headless/components/CodeExtended.svelte'
import { AudioPlaceholder } from '$lib/components/edra/extensions/audio/AudioPlaceholder.js'
import AudioPlaceholderComponent from '$lib/components/edra/headless/components/AudioPlaceholder.svelte'
import AudioExtendedComponent from '$lib/components/edra/headless/components/AudioExtended.svelte'
import { ImagePlaceholder } from '$lib/components/edra/extensions/image/ImagePlaceholder.js'
import ImageUploadPlaceholder from './ImageUploadPlaceholder.svelte' // Our custom component
import { VideoPlaceholder } from '$lib/components/edra/extensions/video/VideoPlaceholder.js'
import VideoPlaceholderComponent from '$lib/components/edra/headless/components/VideoPlaceholder.svelte'
import { ImageExtended } from '$lib/components/edra/extensions/image/ImageExtended.js'
import ImageExtendedComponent from '$lib/components/edra/headless/components/ImageExtended.svelte'
import VideoExtendedComponent from '$lib/components/edra/headless/components/VideoExtended.svelte'
import { VideoExtended } from '$lib/components/edra/extensions/video/VideoExtended.js'
import { AudioExtended } from '$lib/components/edra/extensions/audio/AudiExtended.js'
import LinkMenu from '$lib/components/edra/headless/menus/link-menu.svelte'
import TableRowMenu from '$lib/components/edra/headless/menus/table/table-row-menu.svelte'
import TableColMenu from '$lib/components/edra/headless/menus/table/table-col-menu.svelte'
import slashcommand from '$lib/components/edra/extensions/slash-command/slashcommand.js'
import SlashCommandList from '$lib/components/edra/headless/components/SlashCommandList.svelte'
import IFramePlaceholderComponent from '$lib/components/edra/headless/components/IFramePlaceholder.svelte'
import { IFramePlaceholder } from '$lib/components/edra/extensions/iframe/IFramePlaceholder.js'
import { IFrameExtended } from '$lib/components/edra/extensions/iframe/IFrameExtended.js'
import IFrameExtendedComponent from '$lib/components/edra/headless/components/IFrameExtended.svelte'
import { GalleryPlaceholder } from '$lib/components/edra/extensions/gallery/GalleryPlaceholder.js'
import GalleryPlaceholderComponent from '$lib/components/edra/headless/components/GalleryPlaceholder.svelte'
import { GalleryExtended } from '$lib/components/edra/extensions/gallery/GalleryExtended.js'
import GalleryExtendedComponent from '$lib/components/edra/headless/components/GalleryExtended.svelte'
// Import Edra styles
import '$lib/components/edra/headless/style.css'
import 'katex/dist/katex.min.css'
import '$lib/components/edra/editor.css'
import '$lib/components/edra/onedark.css'
const lowlight = createLowlight(all)
let {
class: className = '',
content = undefined,
editable = true,
limit = undefined,
editor = $bindable<Editor | undefined>(),
showSlashCommands = true,
showLinkBubbleMenu = true,
showTableBubbleMenu = true,
onUpdate,
showToolbar = true,
placeholder = 'Type "/" for commands...'
}: EdraProps & { showToolbar?: boolean; placeholder?: string } = $props()
let element = $state<HTMLElement>()
let isLoading = $state(true)
let showTextStyleDropdown = $state(false)
let showMediaDropdown = $state(false)
let dropdownTriggerRef = $state<HTMLElement>()
let mediaDropdownTriggerRef = $state<HTMLElement>()
let dropdownPosition = $state({ top: 0, left: 0 })
let mediaDropdownPosition = $state({ top: 0, left: 0 })
// Filter out unwanted commands
const getFilteredCommands = () => {
const filtered = { ...commands }
// Remove these groups entirely
delete filtered['undo-redo']
delete filtered['headings'] // In text style dropdown
delete filtered['lists'] // In text style dropdown
delete filtered['alignment'] // Not needed
delete filtered['table'] // Not needed
delete filtered['media'] // Will be in media dropdown
// Reorganize text-formatting commands
if (filtered['text-formatting']) {
const allCommands = filtered['text-formatting'].commands
const basicFormatting = []
const advancedFormatting = []
// Group basic formatting first
const basicOrder = ['bold', 'italic', 'underline', 'strike']
basicOrder.forEach((name) => {
const cmd = allCommands.find((c) => c.name === name)
if (cmd) basicFormatting.push(cmd)
})
// Then link and code
const advancedOrder = ['link', 'code']
advancedOrder.forEach((name) => {
const cmd = allCommands.find((c) => c.name === name)
if (cmd) advancedFormatting.push(cmd)
})
// Create two groups
filtered['basic-formatting'] = {
name: 'Basic Formatting',
label: 'Basic Formatting',
commands: basicFormatting
}
filtered['advanced-formatting'] = {
name: 'Advanced Formatting',
label: 'Advanced Formatting',
commands: advancedFormatting
}
// Remove original text-formatting
delete filtered['text-formatting']
}
return filtered
}
// Get media commands, but filter out iframe
const getMediaCommands = () => {
if (commands.media) {
return commands.media.commands.filter((cmd) => cmd.name !== 'iframe-placeholder')
}
return []
}
const filteredCommands = getFilteredCommands()
const colorCommands = commands.colors.commands
const fontCommands = commands.fonts.commands
const excludedCommands = ['colors', 'fonts']
// Get current text style for dropdown
const getCurrentTextStyle = (editor: Editor) => {
if (editor.isActive('heading', { level: 1 })) return 'Heading 1'
if (editor.isActive('heading', { level: 2 })) return 'Heading 2'
if (editor.isActive('heading', { level: 3 })) return 'Heading 3'
if (editor.isActive('bulletList')) return 'Bullet List'
if (editor.isActive('orderedList')) return 'Ordered List'
if (editor.isActive('taskList')) return 'Task List'
if (editor.isActive('codeBlock')) return 'Code Block'
if (editor.isActive('blockquote')) return 'Blockquote'
return 'Paragraph'
}
// Calculate dropdown position
const updateDropdownPosition = () => {
if (dropdownTriggerRef) {
const rect = dropdownTriggerRef.getBoundingClientRect()
dropdownPosition = {
top: rect.bottom + 4,
left: rect.left
}
}
}
// Toggle dropdown with position update
const toggleDropdown = () => {
if (!showTextStyleDropdown) {
updateDropdownPosition()
}
showTextStyleDropdown = !showTextStyleDropdown
}
// Update media dropdown position
const updateMediaDropdownPosition = () => {
if (mediaDropdownTriggerRef) {
const rect = mediaDropdownTriggerRef.getBoundingClientRect()
mediaDropdownPosition = {
top: rect.bottom + 4,
left: rect.left
}
}
}
// Toggle media dropdown
const toggleMediaDropdown = () => {
if (!showMediaDropdown) {
updateMediaDropdownPosition()
}
showMediaDropdown = !showMediaDropdown
}
// Close dropdown when clicking outside
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (!dropdownTriggerRef?.contains(target) && !target.closest('.dropdown-menu-portal')) {
showTextStyleDropdown = false
}
if (!mediaDropdownTriggerRef?.contains(target) && !target.closest('.media-dropdown-portal')) {
showMediaDropdown = false
}
}
$effect(() => {
if (showTextStyleDropdown || showMediaDropdown) {
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
}
}
})
// Custom paste handler for both images and text
function handlePaste(view: any, event: ClipboardEvent) {
const clipboardData = event.clipboardData
if (!clipboardData) return false
// Check for images first
const imageItem = Array.from(clipboardData.items).find(
(item) => item.type.indexOf('image') === 0
)
if (imageItem) {
const file = imageItem.getAsFile()
if (!file) return false
// Check file size (2MB max)
const filesize = file.size / 1024 / 1024
if (filesize > 2) {
alert(`Image too large! File size: ${filesize.toFixed(2)} MB (max 2MB)`)
return true
}
// Upload to our media API
uploadImage(file)
return true // Prevent default paste behavior
}
// Handle text paste - strip HTML formatting
const htmlData = clipboardData.getData('text/html')
const plainText = clipboardData.getData('text/plain')
if (htmlData && plainText) {
// If we have both HTML and plain text, use plain text to strip formatting
event.preventDefault()
// Use editor commands to insert text so all callbacks are triggered
const editorInstance = (view as any).editor
if (editorInstance) {
editorInstance.chain().focus().insertContent(plainText).run()
} else {
// Fallback to manual transaction
const { state, dispatch } = view
const { selection } = state
const transaction = state.tr.insertText(plainText, selection.from, selection.to)
dispatch(transaction)
}
return true // Prevent default paste behavior
}
// Let default handling take care of plain text only
return false
}
async function uploadImage(file: File) {
if (!editor) return
// Create a placeholder while uploading
const placeholderSrc = URL.createObjectURL(file)
editor.commands.setImage({ src: placeholderSrc })
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
throw new Error('Not authenticated')
}
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) {
throw new Error('Upload failed')
}
const media = await response.json()
// Replace placeholder with actual URL
// Set a reasonable default width (max 600px)
const displayWidth = media.width && media.width > 600 ? 600 : media.width
editor.commands.insertContent({
type: 'image',
attrs: {
src: media.url,
alt: media.filename || '',
width: displayWidth,
height: media.height,
align: 'center'
}
})
// Clean up the object URL
URL.revokeObjectURL(placeholderSrc)
} catch (error) {
console.error('Image upload failed:', error)
alert('Failed to upload image. Please try again.')
// Remove the placeholder on error
editor.commands.undo()
}
}
onMount(() => {
editor = initiateEditor(
element,
content,
limit,
[
CodeBlockLowlight.configure({
lowlight
}).extend({
addNodeView() {
return SvelteNodeViewRenderer(CodeExtended)
}
}),
AudioPlaceholder(AudioPlaceholderComponent),
ImagePlaceholder(ImageUploadPlaceholder), // Use our custom component
GalleryPlaceholder(GalleryPlaceholderComponent),
IFramePlaceholder(IFramePlaceholderComponent),
IFrameExtended(IFrameExtendedComponent),
VideoPlaceholder(VideoPlaceholderComponent),
AudioExtended(AudioExtendedComponent),
ImageExtended(ImageExtendedComponent),
GalleryExtended(GalleryExtendedComponent),
VideoExtended(VideoExtendedComponent),
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
],
{
editable,
onUpdate,
onTransaction: (props) => {
editor = undefined
editor = props.editor
},
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none'
},
handlePaste: handlePaste
}
},
placeholder
)
// Add placeholder
if (placeholder && editor) {
editor.extensionManager.extensions
.find((ext) => ext.name === 'placeholder')
?.configure({
placeholder
})
}
isLoading = false
return () => editor?.destroy()
})
</script>
<div class={`edra ${className}`}>
{#if showToolbar && editor && !isLoading}
<div class="editor-toolbar">
<div class="edra-toolbar">
<!-- Text Style Dropdown -->
<div class="text-style-dropdown">
<button bind:this={dropdownTriggerRef} class="dropdown-trigger" onclick={toggleDropdown}>
<span>{getCurrentTextStyle(editor)}</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<span class="separator"></span>
{#each Object.keys(filteredCommands).filter((key) => !excludedCommands.includes(key)) as keys}
{@const groups = filteredCommands[keys].commands}
{#each groups as command}
<EdraToolBarIcon {command} {editor} />
{/each}
<span class="separator"></span>
{/each}
<!-- Media Dropdown -->
<div class="text-style-dropdown">
<button
bind:this={mediaDropdownTriggerRef}
class="dropdown-trigger"
onclick={toggleMediaDropdown}
>
<span>Insert</span>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<span class="separator"></span>
<EdraToolBarIcon
command={colorCommands[0]}
{editor}
style={`color: ${editor.getAttributes('textStyle').color};`}
onclick={() => {
const color = editor.getAttributes('textStyle').color
const hasColor = editor.isActive('textStyle', { color })
if (hasColor) {
editor.chain().focus().unsetColor().run()
} else {
const color = prompt('Enter the color of the text:')
if (color !== null) {
editor.chain().focus().setColor(color).run()
}
}
}}
/>
<EdraToolBarIcon
command={colorCommands[1]}
{editor}
style={`background-color: ${editor.getAttributes('highlight').color};`}
onclick={() => {
const hasHightlight = editor.isActive('highlight')
if (hasHightlight) {
editor.chain().focus().unsetHighlight().run()
} else {
const color = prompt('Enter the color of the highlight:')
if (color !== null) {
editor.chain().focus().setHighlight({ color }).run()
}
}
}}
/>
</div>
</div>
{/if}
{#if editor}
{#if showLinkBubbleMenu}
<LinkMenu {editor} />
{/if}
{#if showTableBubbleMenu}
<TableRowMenu {editor} />
<TableColMenu {editor} />
{/if}
{/if}
{#if !editor}
<div class="edra-loading">
<LoaderCircle class="animate-spin" /> Loading...
</div>
{/if}
<div
bind:this={element}
role="button"
tabindex="0"
onclick={(event) => focusEditor(editor, event)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
focusEditor(editor, event)
}
}}
class="edra-editor"
></div>
</div>
<!-- Media Dropdown Portal -->
{#if showMediaDropdown}
<div
class="media-dropdown-portal"
style="position: fixed; top: {mediaDropdownPosition.top}px; left: {mediaDropdownPosition.left}px; z-index: 10000;"
>
<div class="dropdown-menu">
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().insertImagePlaceholder().run()
showMediaDropdown = false
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<rect
x="3"
y="5"
width="14"
height="10"
stroke="currentColor"
stroke-width="2"
fill="none"
rx="1"
/>
<circle cx="7" cy="9" r="1.5" stroke="currentColor" stroke-width="2" fill="none" />
<path
d="M3 12L7 8L10 11L13 8L17 12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
<span>Image</span>
</button>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().insertGalleryPlaceholder().run()
showMediaDropdown = false
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<rect
x="2"
y="4"
width="12"
height="9"
stroke="currentColor"
stroke-width="2"
fill="none"
rx="1"
/>
<rect
x="6"
y="7"
width="12"
height="9"
stroke="currentColor"
stroke-width="2"
fill="none"
rx="1"
/>
<circle cx="6.5" cy="9.5" r="1" stroke="currentColor" stroke-width="1.5" fill="none" />
<path
d="M6 12L8 10L10 12L12 10L15 13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
<span>Gallery</span>
</button>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().insertVideoPlaceholder().run()
showMediaDropdown = false
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<rect
x="3"
y="4"
width="14"
height="12"
stroke="currentColor"
stroke-width="2"
fill="none"
rx="2"
/>
<path d="M8 8.5L12 10L8 11.5V8.5Z" fill="currentColor" />
</svg>
<span>Video</span>
</button>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().insertAudioPlaceholder().run()
showMediaDropdown = false
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<path
d="M10 4L10 16M6 8L6 12M14 8L14 12M2 6L2 14M18 6L18 14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<span>Audio</span>
</button>
</div>
</div>
{/if}
<!-- Dropdown Menu Portal -->
{#if showTextStyleDropdown}
<div
class="dropdown-menu-portal"
style="position: fixed; top: {dropdownPosition.top}px; left: {dropdownPosition.left}px; z-index: 10000;"
>
<div class="dropdown-menu">
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().setParagraph().run()
showTextStyleDropdown = false
}}
>
Paragraph
</button>
<div class="dropdown-separator"></div>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleHeading({ level: 1 }).run()
showTextStyleDropdown = false
}}
>
Heading 1
</button>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleHeading({ level: 2 }).run()
showTextStyleDropdown = false
}}
>
Heading 2
</button>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleHeading({ level: 3 }).run()
showTextStyleDropdown = false
}}
>
Heading 3
</button>
<div class="dropdown-separator"></div>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleBulletList().run()
showTextStyleDropdown = false
}}
>
Unordered List
</button>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleOrderedList().run()
showTextStyleDropdown = false
}}
>
Ordered List
</button>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleTaskList().run()
showTextStyleDropdown = false
}}
>
Task List
</button>
<div class="dropdown-separator"></div>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleCodeBlock().run()
showTextStyleDropdown = false
}}
>
Code Block
</button>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().toggleBlockquote().run()
showTextStyleDropdown = false
}}
>
Blockquote
</button>
</div>
</div>
{/if}
<style>
.edra {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.editor-toolbar {
background: var(--edra-button-bg-color);
box-sizing: border-box;
padding: 0.5rem;
position: sticky;
top: 0;
z-index: 10;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
width: 100%;
flex-shrink: 0;
}
.edra-editor {
width: 100%;
flex: 1;
min-width: 0;
overflow-x: hidden;
box-sizing: border-box;
}
:global(.ProseMirror) {
width: 100%;
min-height: 100%;
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
cursor: auto;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
&:focus {
outline: none;
}
}
/* Text Style Dropdown Styles */
.text-style-dropdown {
position: relative;
display: inline-block;
}
.dropdown-trigger {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
color: var(--edra-text-color);
cursor: pointer;
transition: all 0.2s ease;
min-width: 120px;
justify-content: space-between;
height: 36px;
}
.dropdown-trigger:hover {
background: rgba(0, 0, 0, 0.06);
border-color: transparent;
}
.dropdown-menu {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
min-width: 160px;
overflow: hidden;
}
.dropdown-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 16px;
text-align: left;
background: none;
border: none;
font-size: 14px;
font-family: inherit;
color: var(--edra-text-color);
cursor: pointer;
transition: background-color 0.2s ease;
}
.dropdown-item:hover {
background-color: #f5f5f5;
}
.dropdown-icon {
flex-shrink: 0;
width: 20px;
height: 20px;
}
.dropdown-separator {
height: 1px;
background-color: #e0e0e0;
margin: 4px 0;
}
/* Separator in toolbar */
:global(.edra-toolbar .separator) {
display: inline-block;
width: 2px;
height: 24px;
background-color: #e0e0e0;
border-radius: 1px;
margin: 0 4px;
vertical-align: middle;
}
/* Remove default button backgrounds */
:global(.edra-toolbar button) {
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
transition: all 0.2s ease;
}
:global(.edra-toolbar button:hover) {
background: rgba(0, 0, 0, 0.06);
border-color: transparent;
}
:global(.edra-toolbar button.active),
:global(.edra-toolbar button[data-active='true']) {
background: rgba(0, 0, 0, 0.1);
border-color: transparent;
}
/* Thicker strokes for icons */
:global(.edra-toolbar svg) {
stroke-width: 2;
}
</style>

View file

@ -0,0 +1,545 @@
<script lang="ts">
import { goto } from '$app/navigation'
import AdminPage from './AdminPage.svelte'
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
import Editor from './Editor.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte'
import type { JSONContent } from '@tiptap/core'
interface Props {
postId?: number
initialData?: {
title: string
slug: string
content: JSONContent
tags: string[]
status: 'draft' | 'published'
}
mode: 'create' | 'edit'
}
let { postId, initialData, mode }: Props = $props()
// State
let isLoading = $state(false)
let isSaving = $state(false)
let error = $state('')
let successMessage = $state('')
let activeTab = $state('metadata')
let showPublishMenu = $state(false)
// Form data
let title = $state(initialData?.title || '')
let slug = $state(initialData?.slug || '')
let content = $state<JSONContent>(initialData?.content || { type: 'doc', content: [] })
let tags = $state<string[]>(initialData?.tags || [])
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
let tagInput = $state('')
// Ref to the editor component
let editorRef: any
const tabOptions = [
{ value: 'metadata', label: 'Metadata' },
{ value: 'content', label: 'Content' }
]
// Auto-generate slug from title
$effect(() => {
if (title && !slug) {
slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
})
function addTag() {
if (tagInput && !tags.includes(tagInput)) {
tags = [...tags, tagInput]
tagInput = ''
}
}
function removeTag(tag: string) {
tags = tags.filter((t) => t !== tag)
}
function handleEditorChange(newContent: JSONContent) {
content = newContent
}
async function handleSave() {
// Check if we're on the content tab and should save editor content
if (activeTab === 'content' && editorRef) {
const editorData = await editorRef.save()
if (editorData) {
content = editorData
}
}
if (!title) {
error = 'Title is required'
return
}
try {
isSaving = true
error = ''
successMessage = ''
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
const payload = {
title,
slug,
postType: 'blog', // 'blog' is the database value for essays
status,
content,
tags
}
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
const method = mode === 'edit' ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
if (!response.ok) {
throw new Error(`Failed to ${mode === 'edit' ? 'save' : 'create'} essay`)
}
const savedPost = await response.json()
successMessage = `Essay ${mode === 'edit' ? 'saved' : 'created'} successfully!`
setTimeout(() => {
successMessage = ''
if (mode === 'create') {
goto(`/admin/posts/${savedPost.id}/edit`)
}
}, 1500)
} catch (err) {
error = `Failed to ${mode === 'edit' ? 'save' : 'create'} essay`
console.error(err)
} finally {
isSaving = false
}
}
async function handlePublish() {
status = 'published'
await handleSave()
showPublishMenu = false
}
async function handleUnpublish() {
status = 'draft'
await handleSave()
showPublishMenu = false
}
function togglePublishMenu() {
showPublishMenu = !showPublishMenu
}
// Close menu when clicking outside
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement
if (!target.closest('.save-actions')) {
showPublishMenu = false
}
}
$effect(() => {
if (showPublishMenu) {
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('click', handleClickOutside)
}
}
})
</script>
<AdminPage>
<header slot="header">
<div class="header-left">
<Button variant="ghost" iconOnly onclick={() => goto('/admin/posts')}>
<svg slot="icon" 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>
</div>
<div class="header-center">
<AdminSegmentedControl
options={tabOptions}
value={activeTab}
onChange={(value) => (activeTab = value)}
/>
</div>
<div class="header-actions">
<div class="save-actions">
<Button variant="primary" onclick={handleSave} disabled={isSaving} class="save-button">
{isSaving ? 'Saving...' : status === 'published' ? 'Save' : 'Save Draft'}
</Button>
<Button
variant="primary"
iconOnly
buttonSize="medium"
active={showPublishMenu}
onclick={togglePublishMenu}
disabled={isSaving}
class="chevron-button"
>
<svg
slot="icon"
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 4.5L6 7.5L9 4.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
{#if showPublishMenu}
<div class="publish-menu">
{#if status === 'published'}
<Button variant="ghost" onclick={handleUnpublish} class="menu-item" fullWidth>
Unpublish
</Button>
{:else}
<Button variant="ghost" onclick={handlePublish} class="menu-item" fullWidth>
Publish
</Button>
{/if}
</div>
{/if}
</div>
</div>
</header>
<div class="admin-container">
{#if error}
<div class="error-message">{error}</div>
{/if}
{#if successMessage}
<div class="success-message">{successMessage}</div>
{/if}
<div class="tab-panels">
<!-- Metadata Panel -->
<div class="panel content-wrapper" class:active={activeTab === 'metadata'}>
<div class="form-content">
<form
onsubmit={(e) => {
e.preventDefault()
handleSave()
}}
>
<div class="form-section">
<Input label="Title" bind:value={title} required placeholder="Essay title" />
<Input label="Slug" bind:value={slug} placeholder="essay-url-slug" />
<div class="tags-field">
<label class="input-label">Tags</label>
<div class="tag-input-wrapper">
<Input
bind:value={tagInput}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
placeholder="Add tags..."
wrapperClass="tag-input"
/>
<Button variant="secondary" buttonSize="small" type="button" onclick={addTag}>
Add
</Button>
</div>
{#if tags.length > 0}
<div class="tags">
{#each tags as tag}
<span class="tag">
{tag}
<Button
variant="ghost"
iconOnly
buttonSize="small"
onclick={() => removeTag(tag)}
aria-label="Remove {tag}"
>
×
</Button>
</span>
{/each}
</div>
{/if}
</div>
</div>
</form>
</div>
</div>
<!-- Content Panel -->
<div class="panel content-wrapper" class:active={activeTab === 'content'}>
<div class="editor-content">
<Editor
bind:this={editorRef}
bind:data={content}
onChange={handleEditorChange}
placeholder="Write your essay..."
minHeight={400}
autofocus={false}
class="essay-editor"
/>
</div>
</div>
</div>
</div>
</AdminPage>
<style lang="scss">
header {
display: grid;
grid-template-columns: 250px 1fr 250px;
align-items: center;
width: 100%;
gap: $unit-2x;
.header-left {
width: 250px;
}
.header-center {
display: flex;
justify-content: center;
align-items: center;
}
.header-actions {
width: 250px;
display: flex;
justify-content: flex-end;
}
}
.admin-container {
width: 100%;
margin: 0 auto;
padding: 0 $unit-2x $unit-4x;
box-sizing: border-box;
@include breakpoint('phone') {
padding: 0 $unit-2x $unit-2x;
}
}
.save-actions {
position: relative;
display: flex;
}
// Custom styles for save/publish buttons to maintain grey color scheme
:global(.save-button.btn-primary) {
background-color: $grey-10;
&:hover:not(:disabled) {
background-color: $grey-20;
}
&:active:not(:disabled) {
background-color: $grey-30;
}
}
.save-button {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-right: $unit-2x;
}
:global(.chevron-button.btn-primary) {
background-color: $grey-10;
&:hover:not(:disabled) {
background-color: $grey-20;
}
&:active:not(:disabled) {
background-color: $grey-30;
}
&.active {
background-color: $grey-20;
}
}
.chevron-button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 1px solid rgba(255, 255, 255, 0.2);
svg {
transition: transform 0.2s ease;
}
&.active svg {
transform: rotate(180deg);
}
}
.publish-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: $unit;
background: white;
border-radius: $unit;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
overflow: hidden;
min-width: 120px;
z-index: 100;
.menu-item {
text-align: left;
}
}
.tab-panels {
position: relative;
.panel {
display: none;
box-sizing: border-box;
&.active {
display: block;
}
}
}
.content-wrapper {
background: white;
border-radius: $unit-2x;
padding: 0;
width: 100%;
margin: 0 auto;
}
.error-message,
.success-message {
padding: $unit-3x;
border-radius: $unit;
margin-bottom: $unit-4x;
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
.error-message {
background-color: #fee;
color: #d33;
}
.success-message {
background-color: #efe;
color: #363;
}
.form-section {
margin-bottom: $unit-6x;
&:last-child {
margin-bottom: 0;
}
}
.form-content {
padding: $unit-4x;
@include breakpoint('phone') {
padding: $unit-3x;
}
}
// Tags field styles
.tags-field {
margin-bottom: $unit-4x;
.input-label {
display: block;
margin-bottom: $unit;
font-size: 14px;
font-weight: 500;
color: $grey-20;
}
}
.tag-input-wrapper {
display: flex;
gap: $unit;
:global(.tag-input) {
flex: 1;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: $unit;
margin-top: $unit-2x;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: $unit $unit-2x;
background: $grey-90;
border-radius: 20px;
font-size: 0.875rem;
color: $grey-20;
:global(.btn) {
margin-left: 4px;
font-size: 1.125rem;
line-height: 1;
}
}
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
:global(.essay-editor) {
flex: 1;
overflow: auto;
}
}
</style>

View file

@ -0,0 +1,136 @@
<script lang="ts">
interface Props {
label: string
name: string
type?: string
value?: any
placeholder?: string
required?: boolean
error?: string
helpText?: string
disabled?: boolean
onchange?: (e: Event) => void
}
let {
label,
name,
type = 'text',
value = $bindable(),
placeholder = '',
required = false,
error = '',
helpText = '',
disabled = false,
onchange
}: Props = $props()
function handleChange(e: Event) {
const target = e.target as HTMLInputElement | HTMLTextAreaElement
value = target.value
onchange?.(e)
}
</script>
<div class="form-field" class:has-error={!!error}>
<label for={name}>
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
{#if type === 'textarea'}
<textarea
id={name}
{name}
{value}
{placeholder}
{required}
{disabled}
onchange={handleChange}
rows="4"
/>
{:else}
<input
id={name}
{name}
{type}
{value}
{placeholder}
{required}
{disabled}
onchange={handleChange}
/>
{/if}
{#if error}
<div class="error-text">{error}</div>
{:else if helpText}
<div class="help-text">{helpText}</div>
{/if}
</div>
<style lang="scss">
.form-field {
margin-bottom: $unit-4x;
&.has-error {
input,
textarea {
border-color: #c33;
}
}
}
label {
display: block;
margin-bottom: $unit;
font-weight: 500;
color: $grey-20;
.required {
color: #c33;
margin-left: 2px;
}
}
input,
textarea {
width: 100%;
padding: $unit-2x $unit-3x;
border: 1px solid $grey-80;
border-radius: 6px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.2s ease;
background-color: white;
&:focus {
outline: none;
border-color: $primary-color;
}
&:disabled {
background-color: $grey-95;
cursor: not-allowed;
}
}
textarea {
resize: vertical;
min-height: 100px;
}
.error-text {
margin-top: $unit;
color: #c33;
font-size: 0.875rem;
}
.help-text {
margin-top: $unit;
color: $grey-40;
font-size: 0.875rem;
}
</style>

View file

@ -0,0 +1,72 @@
<script lang="ts">
interface Props {
label: string
required?: boolean
helpText?: string
error?: string
children?: any
}
let { label, required = false, helpText, error, children }: Props = $props()
</script>
<div class="form-field" class:has-error={!!error}>
<label>
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
{#if helpText}
<p class="help-text">{helpText}</p>
{/if}
{@render children?.()}
{#if error}
<p class="error-text">{error}</p>
{/if}
</div>
<style lang="scss">
.form-field {
margin-bottom: $unit-3x;
&:last-child {
margin-bottom: 0;
}
&.has-error {
:global(input),
:global(textarea) {
border-color: #c33;
}
}
}
label {
display: block;
margin-bottom: $unit;
font-weight: 500;
color: $grey-20;
font-size: 0.925rem;
.required {
color: #c33;
margin-left: 2px;
}
}
.error-text {
margin-top: $unit;
color: #c33;
font-size: 0.875rem;
}
.help-text {
margin-top: $unit;
color: $grey-40;
font-size: 0.875rem;
}
</style>

View file

@ -0,0 +1,646 @@
<script lang="ts">
import Button from './Button.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import type { Media } from '@prisma/client'
interface Props {
label: string
value?: Media[]
maxItems?: number
required?: boolean
error?: string
showFileInfo?: boolean
}
let {
label,
value = $bindable([]),
maxItems,
required = false,
error,
showFileInfo = false
}: Props = $props()
let showModal = $state(false)
let draggedIndex = $state<number | null>(null)
let dragOverIndex = $state<number | null>(null)
function handleImagesSelect(media: Media[]) {
// Add new images to existing ones, avoiding duplicates
const existingIds = new Set(value.map((item) => item.id))
const newImages = media.filter((item) => !existingIds.has(item.id))
if (maxItems) {
const availableSlots = maxItems - value.length
value = [...value, ...newImages.slice(0, availableSlots)]
} else {
value = [...value, ...newImages]
}
showModal = false
}
function removeImage(index: number) {
value = value.filter((_, i) => i !== index)
}
function openModal() {
showModal = true
}
// Drag and Drop functionality
function handleDragStart(event: DragEvent, index: number) {
if (!event.dataTransfer) return
draggedIndex = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/html', '')
// Add dragging class to the dragged element
const target = event.target as HTMLElement
target.style.opacity = '0.5'
}
function handleDragEnd(event: DragEvent) {
const target = event.target as HTMLElement
target.style.opacity = '1'
draggedIndex = null
dragOverIndex = null
}
function handleDragOver(event: DragEvent, index: number) {
event.preventDefault()
if (!event.dataTransfer) return
event.dataTransfer.dropEffect = 'move'
dragOverIndex = index
}
function handleDragLeave() {
dragOverIndex = null
}
function handleDrop(event: DragEvent, dropIndex: number) {
event.preventDefault()
if (draggedIndex === null || draggedIndex === dropIndex) {
return
}
// Reorder the array
const newValue = [...value]
const draggedItem = newValue[draggedIndex]
// Remove the dragged item
newValue.splice(draggedIndex, 1)
// Insert at the new position (adjust index if necessary)
const insertIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex
newValue.splice(insertIndex, 0, draggedItem)
value = newValue
// Reset drag state
draggedIndex = null
dragOverIndex = null
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
// Computed properties
const hasImages = $derived(value.length > 0)
const canAddMore = $derived(!maxItems || value.length < maxItems)
const selectedIds = $derived(value.map((item) => item.id))
const itemsText = $derived(value.length === 1 ? '1 image' : `${value.length} images`)
</script>
<div class="gallery-manager">
<div class="header">
<label class="input-label">
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
{#if hasImages}
<span class="items-count">
{itemsText}
{#if maxItems}
of {maxItems} max
{/if}
</span>
{/if}
</div>
<!-- Gallery Grid -->
{#if hasImages}
<div class="gallery-grid" class:has-error={error}>
{#each value as item, index (item.id)}
<div
class="gallery-item"
class:drag-over={dragOverIndex === index}
draggable="true"
ondragstart={(e) => handleDragStart(e, index)}
ondragend={handleDragEnd}
ondragover={(e) => handleDragOver(e, index)}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, index)}
>
<!-- Drag Handle -->
<div class="drag-handle">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="9" cy="12" r="1" fill="currentColor" />
<circle cx="9" cy="5" r="1" fill="currentColor" />
<circle cx="9" cy="19" r="1" fill="currentColor" />
<circle cx="15" cy="12" r="1" fill="currentColor" />
<circle cx="15" cy="5" r="1" fill="currentColor" />
<circle cx="15" cy="19" r="1" fill="currentColor" />
</svg>
</div>
<!-- Image -->
<div class="image-container">
{#if item.thumbnailUrl}
<img src={item.thumbnailUrl} alt={item.filename} />
{:else}
<div class="image-placeholder">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path
d="M3 16l5-5 3 3 4-4 4 4"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
</svg>
</div>
{/if}
</div>
<!-- Image Info -->
{#if showFileInfo}
<div class="image-info">
<p class="filename">{item.filename}</p>
<p class="file-size">{formatFileSize(item.size)}</p>
</div>
{/if}
<!-- Remove Button -->
<button
type="button"
class="remove-button"
onclick={() => removeImage(index)}
aria-label="Remove image"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 6L18 18M6 18L18 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
<!-- Order Indicator -->
<div class="order-indicator">
{index + 1}
</div>
</div>
{/each}
<!-- Add More Button (if within grid) -->
{#if canAddMore}
<button type="button" class="add-more-item" onclick={openModal}>
<div class="add-icon">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</div>
<span>Add Images</span>
</button>
{/if}
</div>
{:else}
<!-- Empty State -->
<div class="empty-state" class:has-error={error}>
<div class="empty-content">
<div class="empty-icon">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="1.5"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg>
</div>
<p class="empty-text">No images added yet</p>
<Button variant="primary" onclick={openModal}>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
Add Images
</Button>
</div>
</div>
{/if}
<!-- Add More Button (outside grid) -->
{#if hasImages && canAddMore}
<div class="add-more-container">
<Button variant="ghost" onclick={openModal}>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
Add More Images
</Button>
</div>
{/if}
<!-- Error Message -->
{#if error}
<p class="error-message">{error}</p>
{/if}
<!-- Help Text -->
{#if hasImages}
<p class="help-text">Drag and drop to reorder images</p>
{/if}
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={showModal}
mode="multiple"
fileType="image"
{selectedIds}
title="Add Images to Gallery"
confirmText="Add Selected Images"
onselect={handleImagesSelect}
/>
</div>
<style lang="scss">
.gallery-manager {
display: flex;
flex-direction: column;
gap: $unit;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: $unit-2x;
}
.input-label {
font-size: 0.875rem;
font-weight: 500;
color: $grey-20;
.required {
color: $red-60;
margin-left: $unit-half;
}
}
.items-count {
font-size: 0.75rem;
color: $grey-40;
font-weight: 500;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: $unit-2x;
padding: $unit-2x;
border: 1px solid $grey-85;
border-radius: $card-corner-radius;
background-color: $grey-97;
&.has-error {
border-color: $red-60;
}
}
.gallery-item {
position: relative;
aspect-ratio: 1;
border-radius: $card-corner-radius;
overflow: hidden;
cursor: move;
transition: all 0.2s ease;
background-color: white;
border: 1px solid $grey-90;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
.remove-button {
opacity: 1;
}
}
&.drag-over {
border-color: $blue-60;
background-color: rgba(59, 130, 246, 0.05);
}
}
.drag-handle {
position: absolute;
top: $unit-half;
left: $unit-half;
z-index: 3;
background-color: rgba(0, 0, 0, 0.6);
color: white;
padding: $unit-half;
border-radius: 4px;
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
.gallery-item:hover & {
opacity: 1;
}
}
.image-container {
width: 100%;
height: 100%;
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.image-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background-color: $grey-95;
color: $grey-60;
}
.image-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
padding: $unit-2x $unit $unit;
color: white;
.filename {
margin: 0 0 $unit-fourth 0;
font-size: 0.75rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
margin: 0;
font-size: 0.625rem;
opacity: 0.8;
}
}
.remove-button {
position: absolute;
top: $unit-half;
right: $unit-half;
z-index: 3;
background-color: rgba(239, 68, 68, 0.9);
color: white;
border: none;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: all 0.2s ease;
&:hover {
background-color: $red-60;
transform: scale(1.1);
}
}
.order-indicator {
position: absolute;
top: $unit-half;
right: $unit-half;
z-index: 2;
background-color: rgba(0, 0, 0, 0.7);
color: white;
font-size: 0.75rem;
font-weight: 600;
padding: $unit-fourth $unit-half;
border-radius: 12px;
min-width: 20px;
text-align: center;
line-height: 1;
}
.add-more-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit;
aspect-ratio: 1;
border: 2px dashed $grey-70;
border-radius: $card-corner-radius;
background-color: transparent;
color: $grey-50;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
font-weight: 500;
&:hover {
border-color: $blue-60;
color: $blue-60;
background-color: rgba(59, 130, 246, 0.05);
}
}
.add-icon {
color: inherit;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
border: 2px dashed $grey-80;
border-radius: $card-corner-radius;
background-color: $grey-97;
&.has-error {
border-color: $red-60;
}
}
.empty-content {
display: flex;
flex-direction: column;
align-items: center;
gap: $unit-2x;
text-align: center;
padding: $unit-4x;
}
.empty-icon {
color: $grey-60;
}
.empty-text {
margin: 0;
font-size: 0.875rem;
color: $grey-40;
}
.add-more-container {
display: flex;
justify-content: center;
padding-top: $unit;
}
.error-message {
margin: 0;
font-size: 0.75rem;
color: $red-60;
}
.help-text {
margin: 0;
font-size: 0.75rem;
color: $grey-50;
text-align: center;
font-style: italic;
}
// Responsive adjustments
@media (max-width: 640px) {
.gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: $unit;
padding: $unit;
}
.header {
flex-direction: column;
align-items: flex-start;
gap: $unit;
}
.order-indicator {
font-size: 0.625rem;
padding: $unit-fourth $unit-half;
}
.remove-button {
opacity: 1; // Always visible on mobile
}
.image-info {
display: none; // Hide on mobile to save space
}
}
</style>

View file

@ -0,0 +1,930 @@
<script lang="ts">
import type { Media } from '@prisma/client'
import Button from './Button.svelte'
import Input from './Input.svelte'
import SmartImage from '../SmartImage.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import { authenticatedFetch } from '$lib/admin-auth'
interface Props {
label: string
value?: any[] // Changed from Media[] to any[] to be more flexible
onUpload: (media: any[]) => void
onReorder?: (media: any[]) => void
onRemove?: (item: any, index: number) => void // New callback for removals
maxItems?: number
allowAltText?: boolean
required?: boolean
error?: string
placeholder?: string
helpText?: string
showBrowseLibrary?: boolean
maxFileSize?: number // MB limit
}
let {
label,
value = $bindable([]),
onUpload,
onReorder,
onRemove,
maxItems = 20,
allowAltText = true,
required = false,
error,
placeholder = 'Drag and drop images here, or click to browse',
helpText,
showBrowseLibrary = false,
maxFileSize = 10
}: Props = $props()
// State
let isUploading = $state(false)
let uploadProgress = $state<Record<string, number>>({})
let uploadError = $state<string | null>(null)
let isDragOver = $state(false)
let fileInputElement: HTMLInputElement
let draggedIndex = $state<number | null>(null)
let draggedOverIndex = $state<number | null>(null)
let isMediaLibraryOpen = $state(false)
// Computed properties
const hasImages = $derived(value && value.length > 0)
const canAddMore = $derived(!maxItems || !value || value.length < maxItems)
const remainingSlots = $derived(maxItems ? maxItems - (value?.length || 0) : Infinity)
// File validation
function validateFile(file: File): string | null {
// Check file type
if (!file.type.startsWith('image/')) {
return 'Please select image files only'
}
// Check file size
const sizeMB = file.size / 1024 / 1024
if (sizeMB > maxFileSize) {
return `File size must be less than ${maxFileSize}MB`
}
return null
}
// Upload multiple files to server
async function uploadFiles(files: File[]): Promise<Media[]> {
const uploadPromises = files.map(async (file, index) => {
const formData = new FormData()
formData.append('file', file)
const response = await authenticatedFetch('/api/media/upload', {
method: 'POST',
body: formData
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || `Upload failed for ${file.name}`)
}
return await response.json()
})
return Promise.all(uploadPromises)
}
// Handle file selection/drop
async function handleFiles(files: FileList) {
if (files.length === 0) return
// Validate files
const filesToUpload: File[] = []
const errors: string[] = []
for (let i = 0; i < files.length; i++) {
const file = files[i]
const validationError = validateFile(file)
if (validationError) {
errors.push(`${file.name}: ${validationError}`)
} else if (filesToUpload.length < remainingSlots) {
filesToUpload.push(file)
} else {
errors.push(`${file.name}: Maximum ${maxItems} images allowed`)
}
}
if (errors.length > 0) {
uploadError = errors.join('\n')
return
}
if (filesToUpload.length === 0) return
uploadError = null
isUploading = true
try {
// Initialize progress tracking
const progressKeys = filesToUpload.map((file, index) => `${file.name}-${index}`)
uploadProgress = Object.fromEntries(progressKeys.map((key) => [key, 0]))
// Simulate progress for user feedback
const progressIntervals = progressKeys.map((key) => {
return setInterval(() => {
if (uploadProgress[key] < 90) {
uploadProgress[key] += Math.random() * 10
uploadProgress = { ...uploadProgress }
}
}, 100)
})
const uploadedMedia = await uploadFiles(filesToUpload)
// Clear progress intervals
progressIntervals.forEach((interval) => clearInterval(interval))
// Complete progress
progressKeys.forEach((key) => {
uploadProgress[key] = 100
})
uploadProgress = { ...uploadProgress }
// Brief delay to show completion
setTimeout(() => {
const newValue = [...(value || []), ...uploadedMedia]
value = newValue
// Only pass the newly uploaded media, not the entire gallery
onUpload(uploadedMedia)
isUploading = false
uploadProgress = {}
}, 500)
} catch (err) {
isUploading = false
uploadProgress = {}
uploadError = err instanceof Error ? err.message : 'Upload failed'
}
}
// Drag and drop handlers for file upload
function handleDragOver(event: DragEvent) {
event.preventDefault()
isDragOver = true
}
function handleDragLeave(event: DragEvent) {
event.preventDefault()
isDragOver = false
}
function handleDrop(event: DragEvent) {
event.preventDefault()
isDragOver = false
const files = event.dataTransfer?.files
if (files) {
handleFiles(files)
}
}
// Click to browse handler
function handleBrowseClick() {
fileInputElement?.click()
}
function handleFileInputChange(event: Event) {
const target = event.target as HTMLInputElement
if (target.files) {
handleFiles(target.files)
}
}
// Remove individual image - now passes the item to be removed instead of doing it locally
function handleRemoveImage(index: number) {
if (!value || !value[index]) return
const itemToRemove = value[index]
// Call the onRemove callback if provided, otherwise fall back to onUpload
if (onRemove) {
onRemove(itemToRemove, index)
} else {
// Fallback: remove locally and call onUpload
const newValue = value.filter((_, i) => i !== index)
value = newValue
onUpload(newValue)
}
uploadError = null
}
// Update alt text on server
async function handleAltTextChange(item: any, newAltText: string) {
if (!item) return
try {
// For album photos, use mediaId; for direct media objects, use id
const mediaId = item.mediaId || item.id
if (!mediaId) {
console.error('No media ID found for alt text update')
return
}
const response = await authenticatedFetch(`/api/media/${mediaId}/metadata`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
altText: newAltText.trim() || null
})
})
if (response.ok) {
const updatedData = await response.json()
if (value) {
const index = value.findIndex((v) => (v.mediaId || v.id) === mediaId)
if (index !== -1) {
value[index] = {
...value[index],
altText: updatedData.altText,
updatedAt: updatedData.updatedAt
}
value = [...value]
}
}
}
} catch (error) {
console.error('Failed to update alt text:', error)
}
}
// Drag and drop reordering handlers
function handleImageDragStart(event: DragEvent, index: number) {
draggedIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
function handleImageDragOver(event: DragEvent, index: number) {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
draggedOverIndex = index
}
function handleImageDragLeave() {
draggedOverIndex = null
}
function handleImageDrop(event: DragEvent, dropIndex: number) {
event.preventDefault()
if (draggedIndex === null || !value) return
const newValue = [...value]
const draggedItem = newValue[draggedIndex]
// Remove from old position
newValue.splice(draggedIndex, 1)
// Insert at new position (adjust index if dragging to later position)
const adjustedDropIndex = draggedIndex < dropIndex ? dropIndex - 1 : dropIndex
newValue.splice(adjustedDropIndex, 0, draggedItem)
value = newValue
onUpload(newValue)
if (onReorder) {
onReorder(newValue)
}
draggedIndex = null
draggedOverIndex = null
}
function handleImageDragEnd() {
draggedIndex = null
draggedOverIndex = null
}
// Browse library handler
function handleBrowseLibrary() {
isMediaLibraryOpen = true
}
function handleMediaSelect(selectedMedia: any | any[]) {
// For gallery mode, selectedMedia will be an array
const mediaArray = Array.isArray(selectedMedia) ? selectedMedia : [selectedMedia]
// Filter out duplicates before passing to parent
// Create a comprehensive set of existing IDs (both id and mediaId)
const existingIds = new Set()
value?.forEach((m) => {
if (m.id) existingIds.add(m.id)
if (m.mediaId) existingIds.add(m.mediaId)
})
// Filter out any media that already exists (check both id and potential mediaId)
const newMedia = mediaArray.filter((media) => {
return !existingIds.has(media.id) && !existingIds.has(media.mediaId)
})
if (newMedia.length > 0) {
// Don't modify the value array here - let the parent component handle it
// through the API calls and then update the bound value
onUpload(newMedia)
}
}
function handleMediaLibraryClose() {
isMediaLibraryOpen = false
}
</script>
<div class="gallery-uploader">
<!-- Upload Area -->
{#if !hasImages || (hasImages && canAddMore)}
<div
class="drop-zone"
class:drag-over={isDragOver}
class:uploading={isUploading}
class:has-error={!!uploadError}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={handleBrowseClick}
>
{#if isUploading}
<!-- Upload Progress -->
<div class="upload-progress">
<svg class="upload-spinner" width="24" height="24" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-dasharray="60"
stroke-dashoffset="60"
stroke-linecap="round"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 12 12"
to="360 12 12"
dur="1s"
repeatCount="indefinite"
/>
</circle>
</svg>
<p class="upload-text">Uploading images...</p>
<!-- Individual file progress -->
<div class="file-progress-list">
{#each Object.entries(uploadProgress) as [fileName, progress]}
<div class="file-progress-item">
<span class="file-name">{fileName.split('-')[0]}</span>
<div class="progress-bar">
<div class="progress-fill" style="width: {Math.round(progress)}%"></div>
</div>
<span class="progress-percent">{Math.round(progress)}%</span>
</div>
{/each}
</div>
</div>
{:else}
<!-- Upload Prompt -->
<div class="upload-prompt">
<svg
class="upload-icon"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="14,2 14,8 20,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="16"
y1="13"
x2="8"
y2="13"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<line
x1="16"
y1="17"
x2="8"
y2="17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<polyline
points="10,9 9,9 8,9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<p class="upload-main-text">{placeholder}</p>
<p class="upload-sub-text">
Supports JPG, PNG, GIF up to {maxFileSize}MB
{#if maxItems}
• Maximum {maxItems} images
{/if}
{#if hasImages && remainingSlots < Infinity}
{remainingSlots} slots remaining
{/if}
</p>
</div>
{/if}
</div>
{/if}
<!-- Action Buttons -->
{#if !isUploading && canAddMore}
<div class="action-buttons">
<Button variant="primary" onclick={handleBrowseClick}>
{hasImages ? 'Add More Images' : 'Choose Images'}
</Button>
{#if showBrowseLibrary}
<Button variant="ghost" onclick={handleBrowseLibrary}>Browse Library</Button>
{/if}
</div>
{/if}
<!-- Image Gallery -->
{#if hasImages}
<div class="image-gallery">
{#each value as media, index (`${media.mediaId || media.id || index}`)}
<div
class="gallery-item"
class:dragging={draggedIndex === index}
class:drag-over={draggedOverIndex === index}
draggable="true"
ondragstart={(e) => handleImageDragStart(e, index)}
ondragover={(e) => handleImageDragOver(e, index)}
ondragleave={handleImageDragLeave}
ondrop={(e) => handleImageDrop(e, index)}
ondragend={handleImageDragEnd}
>
<!-- Drag Handle -->
<div class="drag-handle">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="9" cy="6" r="2" fill="currentColor" />
<circle cx="15" cy="6" r="2" fill="currentColor" />
<circle cx="9" cy="12" r="2" fill="currentColor" />
<circle cx="15" cy="12" r="2" fill="currentColor" />
<circle cx="9" cy="18" r="2" fill="currentColor" />
<circle cx="15" cy="18" r="2" fill="currentColor" />
</svg>
</div>
<!-- Image Preview -->
<div class="image-preview">
<SmartImage
media={{
id: media.mediaId || media.id,
filename: media.filename,
originalName: media.originalName || media.filename,
mimeType: media.mimeType || 'image/jpeg',
size: media.size || 0,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
altText: media.altText,
description: media.description,
isPhotography: media.isPhotography || false,
createdAt: media.createdAt,
updatedAt: media.updatedAt
}}
alt={media.altText || media.filename || 'Gallery image'}
containerWidth={300}
loading="lazy"
aspectRatio="1:1"
class="gallery-image"
/>
<!-- Remove Button -->
<button
class="remove-button"
onclick={() => handleRemoveImage(index)}
type="button"
aria-label="Remove image"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<line
x1="18"
y1="6"
x2="6"
y2="18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="6"
y1="6"
x2="18"
y2="18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<!-- Alt Text Input -->
{#if allowAltText}
<div class="alt-text-input">
<Input
type="text"
label="Alt Text"
value={media.altText || ''}
placeholder="Describe this image"
buttonSize="small"
onblur={(e) => handleAltTextChange(media, e.target.value)}
/>
</div>
{/if}
<!-- File Info -->
<div class="file-info">
<p class="filename">{media.originalName || media.filename}</p>
<p class="file-meta">
{Math.round((media.size || 0) / 1024)} KB
{#if media.width && media.height}
{media.width}×{media.height}
{/if}
</p>
</div>
</div>
{/each}
</div>
{/if}
<!-- Error Message -->
{#if error || uploadError}
<p class="error-message">{error || uploadError}</p>
{/if}
<!-- Hidden File Input -->
<input
bind:this={fileInputElement}
type="file"
accept="image/*"
multiple
style="display: none;"
onchange={handleFileInputChange}
/>
</div>
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={isMediaLibraryOpen}
mode="multiple"
fileType="image"
title="Select Images"
confirmText="Add Selected"
onSelect={handleMediaSelect}
onClose={handleMediaLibraryClose}
/>
<style lang="scss">
.gallery-uploader {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.uploader-label {
font-size: 0.875rem;
font-weight: 500;
color: $grey-20;
.required {
color: $red-60;
margin-left: $unit-half;
}
}
.help-text {
margin: 0;
font-size: 0.8rem;
color: $grey-40;
line-height: 1.4;
}
// Drop Zone Styles
.drop-zone {
border: 2px dashed $grey-80;
border-radius: $card-corner-radius;
background-color: $grey-97;
cursor: pointer;
transition: all 0.2s ease;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
&:hover {
border-color: $blue-60;
background-color: rgba($blue-60, 0.02);
}
&.drag-over {
border-color: $blue-60;
background-color: rgba($blue-60, 0.05);
border-style: solid;
}
&.uploading {
cursor: default;
border-color: $blue-60;
}
&.has-error {
border-color: $red-60;
background-color: rgba($red-60, 0.02);
}
}
.upload-prompt {
text-align: center;
padding: $unit-3x;
.upload-icon {
color: $grey-50;
margin-bottom: $unit-2x;
}
.upload-main-text {
margin: 0 0 $unit 0;
font-size: 0.875rem;
color: $grey-30;
font-weight: 500;
}
.upload-sub-text {
margin: 0;
font-size: 0.75rem;
color: $grey-50;
}
}
.upload-progress {
text-align: center;
padding: $unit-3x;
.upload-spinner {
color: $blue-60;
margin-bottom: $unit-2x;
}
.upload-text {
margin: 0 0 $unit-2x 0;
font-size: 0.875rem;
color: $grey-30;
font-weight: 500;
}
.file-progress-list {
display: flex;
flex-direction: column;
gap: $unit;
max-width: 300px;
margin: 0 auto;
}
.file-progress-item {
display: flex;
align-items: center;
gap: $unit;
font-size: 0.75rem;
.file-name {
flex: 1;
color: $grey-30;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.progress-bar {
width: 60px;
height: 4px;
background-color: $grey-90;
border-radius: 2px;
overflow: hidden;
.progress-fill {
height: 100%;
background-color: $blue-60;
transition: width 0.3s ease;
}
}
.progress-percent {
width: 30px;
text-align: right;
color: $grey-40;
font-size: 0.7rem;
}
}
}
.action-buttons {
display: flex;
gap: $unit-2x;
align-items: center;
}
// Image Gallery Styles
.image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: $unit-3x;
margin-top: $unit-2x;
}
.gallery-item {
position: relative;
border: 1px solid $grey-90;
border-radius: $card-corner-radius;
background-color: white;
overflow: hidden;
transition: all 0.2s ease;
&:hover {
border-color: $grey-70;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
&.dragging {
opacity: 0.5;
transform: scale(0.95);
}
&.drag-over {
border-color: $blue-60;
background-color: rgba($blue-60, 0.05);
}
.drag-handle {
position: absolute;
top: $unit;
left: $unit;
z-index: 2;
background: rgba(255, 255, 255, 0.9);
border-radius: 4px;
padding: $unit-half;
cursor: grab;
color: $grey-40;
opacity: 0;
transition: opacity 0.2s ease;
&:active {
cursor: grabbing;
}
}
&:hover .drag-handle {
opacity: 1;
}
}
.image-preview {
position: relative;
aspect-ratio: 1;
overflow: hidden;
:global(.gallery-image) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.remove-button {
position: absolute;
top: $unit;
right: $unit;
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: $grey-40;
opacity: 0;
transition: all 0.2s ease;
&:hover {
background: white;
color: $red-60;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
}
&:hover .remove-button {
opacity: 1;
}
}
.alt-text-input {
padding: $unit-2x;
}
.file-info {
padding: $unit-2x;
padding-top: $unit;
border-top: 1px solid $grey-95;
.filename {
margin: 0 0 $unit-half 0;
font-size: 0.75rem;
font-weight: 500;
color: $grey-10;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-meta {
margin: 0;
font-size: 0.7rem;
color: $grey-40;
}
}
.error-message {
margin: 0;
font-size: 0.75rem;
color: $red-60;
padding: $unit;
background-color: rgba($red-60, 0.05);
border-radius: $card-corner-radius;
border: 1px solid rgba($red-60, 0.2);
white-space: pre-line;
}
// Responsive adjustments
@media (max-width: 640px) {
.image-gallery {
grid-template-columns: 1fr;
}
.upload-prompt {
padding: $unit-2x;
.upload-main-text {
font-size: 0.8rem;
}
}
.action-buttons {
flex-direction: column;
align-items: stretch;
}
}
</style>

View file

@ -0,0 +1,465 @@
<script lang="ts">
import { onMount } from 'svelte'
import Input from './Input.svelte'
import FormFieldWrapper from './FormFieldWrapper.svelte'
import Button from './Button.svelte'
export interface MetadataField {
type: 'input' | 'textarea' | 'date' | 'toggle' | 'tags' | 'metadata' | 'custom' | 'section'
key: string
label?: string
placeholder?: string
rows?: number
helpText?: string
component?: any // For custom components
props?: any // Additional props for custom components
}
export interface MetadataConfig {
title: string
fields: MetadataField[]
deleteButton?: {
label: string
action: () => void
}
}
type Props = {
config: MetadataConfig
data: any
triggerElement: HTMLElement
onUpdate?: (key: string, value: any) => void
onAddTag?: () => void
onRemoveTag?: (tag: string) => void
onClose?: () => void
}
let {
config,
data = $bindable(),
triggerElement,
onUpdate = () => {},
onAddTag = () => {},
onRemoveTag = () => {},
onClose = () => {}
}: Props = $props()
let popoverElement: HTMLDivElement
let portalTarget: HTMLElement
function updatePosition() {
if (!popoverElement || !triggerElement) return
const triggerRect = triggerElement.getBoundingClientRect()
const popoverRect = popoverElement.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Find the AdminPage container to align with its right edge
const adminPage =
document.querySelector('.admin-page') || document.querySelector('[data-admin-page]')
const adminPageRect = adminPage?.getBoundingClientRect()
// Position below the trigger button
let top = triggerRect.bottom + 8
// Align closer to the right edge of AdminPage, with some padding
let left: number
if (adminPageRect) {
// Position to align with AdminPage right edge minus padding
left = adminPageRect.right - popoverRect.width - 24
} else {
// Fallback to viewport-based positioning
left = triggerRect.right - popoverRect.width
}
// Ensure we don't go off-screen horizontally
if (left < 16) {
left = 16
} else if (left + popoverRect.width > viewportWidth - 16) {
left = viewportWidth - popoverRect.width - 16
}
// Check if popover would go off-screen vertically (both top and bottom)
if (top + popoverRect.height > viewportHeight - 16) {
// Try positioning above the trigger
const topAbove = triggerRect.top - popoverRect.height - 8
if (topAbove >= 16) {
top = topAbove
} else {
// If neither above nor below works, position with maximum available space
if (triggerRect.top > viewportHeight - triggerRect.bottom) {
// More space above - position at top of viewport with margin
top = 16
} else {
// More space below - position at bottom of viewport with margin
top = viewportHeight - popoverRect.height - 16
}
}
}
// Also check if positioning below would place us off the top (shouldn't happen but be safe)
if (top < 16) {
top = 16
}
popoverElement.style.position = 'fixed'
popoverElement.style.top = `${top}px`
popoverElement.style.left = `${left}px`
popoverElement.style.zIndex = '1000'
}
function handleFieldUpdate(key: string, value: any) {
data[key] = value
onUpdate(key, value)
}
onMount(() => {
// Create portal target
portalTarget = document.createElement('div')
portalTarget.style.position = 'absolute'
portalTarget.style.top = '0'
portalTarget.style.left = '0'
portalTarget.style.pointerEvents = 'none'
document.body.appendChild(portalTarget)
// Initial positioning
updatePosition()
// Update position on scroll/resize
const handleUpdate = () => updatePosition()
window.addEventListener('scroll', handleUpdate, true)
window.addEventListener('resize', handleUpdate)
// Click outside handler
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
// Don't close if clicking inside the trigger button or the popover itself
if (triggerElement?.contains(target) || popoverElement?.contains(target)) {
return
}
onClose()
}
// Add click outside listener
document.addEventListener('click', handleClickOutside)
return () => {
window.removeEventListener('scroll', handleUpdate, true)
window.removeEventListener('resize', handleUpdate)
document.removeEventListener('click', handleClickOutside)
if (portalTarget) {
document.body.removeChild(portalTarget)
}
}
})
$effect(() => {
if (popoverElement && portalTarget && triggerElement) {
portalTarget.appendChild(popoverElement)
portalTarget.style.pointerEvents = 'auto'
updatePosition()
}
})
</script>
<div class="metadata-popover" bind:this={popoverElement}>
<div class="popover-content">
<h3>{config.title}</h3>
{#each config.fields as field}
{#if field.type === 'input'}
<Input
label={field.label}
bind:value={data[field.key]}
placeholder={field.placeholder}
helpText={field.helpText}
onchange={() => handleFieldUpdate(field.key, data[field.key])}
/>
{:else if field.type === 'textarea'}
<Input
type="textarea"
label={field.label}
bind:value={data[field.key]}
rows={field.rows || 3}
placeholder={field.placeholder}
helpText={field.helpText}
onchange={() => handleFieldUpdate(field.key, data[field.key])}
/>
{:else if field.type === 'date'}
<Input
type="date"
label={field.label}
bind:value={data[field.key]}
helpText={field.helpText}
onchange={() => handleFieldUpdate(field.key, data[field.key])}
/>
{:else if field.type === 'toggle'}
<div class="toggle-wrapper">
<label class="toggle-label">
<input
type="checkbox"
bind:checked={data[field.key]}
class="toggle-input"
onchange={() => handleFieldUpdate(field.key, data[field.key])}
/>
<span class="toggle-slider"></span>
<div class="toggle-content">
<span class="toggle-title">{field.label}</span>
{#if field.helpText}
<span class="toggle-description">{field.helpText}</span>
{/if}
</div>
</label>
</div>
{:else if field.type === 'tags'}
<div class="tags-section">
<Input
label={field.label}
bind:value={data.tagInput}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
placeholder={field.placeholder || 'Add tags...'}
/>
<button type="button" onclick={onAddTag} class="add-tag-btn">Add</button>
{#if data[field.key] && data[field.key].length > 0}
<div class="tags">
{#each data[field.key] as tag}
<span class="tag">
{tag}
<button onclick={() => onRemoveTag(tag)}>×</button>
</span>
{/each}
</div>
{/if}
</div>
{:else if field.type === 'metadata'}
<div class="metadata">
<p>Created: {new Date(data.createdAt).toLocaleString()}</p>
<p>Updated: {new Date(data.updatedAt).toLocaleString()}</p>
{#if data.publishedAt}
<p>Published: {new Date(data.publishedAt).toLocaleString()}</p>
{/if}
</div>
{:else if field.type === 'section'}
<div class="section-header">
<h4>{field.label}</h4>
</div>
{:else if field.type === 'custom' && field.component}
<svelte:component this={field.component} {...field.props} bind:data />
{/if}
{/each}
</div>
{#if config.deleteButton}
<div class="popover-footer">
<Button variant="danger-text" pill={false} onclick={config.deleteButton.action}>
<svg slot="icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M4 4L12 12M4 12L12 4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{config.deleteButton.label}
</Button>
</div>
{/if}
</div>
<style lang="scss">
@import '$styles/variables.scss';
.metadata-popover {
background: white;
border: 1px solid $grey-80;
border-radius: $card-corner-radius;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
min-width: 420px;
max-width: 480px;
max-height: calc(100vh - #{$unit-2x * 2});
display: flex;
flex-direction: column;
pointer-events: auto;
overflow-y: auto;
}
.popover-content {
padding: $unit-3x;
display: flex;
flex-direction: column;
gap: $unit-3x;
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: $grey-10;
}
}
.popover-footer {
padding: $unit-3x;
border-top: 1px solid $grey-90;
display: flex;
justify-content: flex-start;
}
.section-header {
margin: $unit-3x 0 $unit 0;
&:first-child {
margin-top: 0;
}
h4 {
display: block;
margin-bottom: $unit;
font-weight: 500;
color: $grey-20;
font-size: 0.925rem;
}
}
.tags-section {
display: flex;
flex-direction: column;
gap: $unit;
}
.add-tag-btn {
align-self: flex-start;
margin-top: $unit-half;
padding: $unit $unit-2x;
background: $grey-10;
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: $grey-20;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: $unit;
margin-top: $unit;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px $unit-2x;
background: $grey-80;
border-radius: 20px;
font-size: 0.75rem;
button {
background: none;
border: none;
color: $grey-40;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
&:hover {
color: $grey-10;
}
}
}
.metadata {
font-size: 0.75rem;
color: $grey-40;
p {
margin: $unit-half 0;
}
}
.toggle-wrapper {
.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;
}
}
}
@include breakpoint('phone') {
.metadata-popover {
min-width: 280px;
max-width: calc(100vw - 2rem);
}
}
</style>

View file

@ -0,0 +1,401 @@
<script lang="ts">
import Button from './Button.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import type { Media } from '@prisma/client'
interface Props {
label: string
value?: Media | null
aspectRatio?: string
placeholder?: string
required?: boolean
error?: string
showDimensions?: boolean
}
let {
label,
value = $bindable(),
aspectRatio,
placeholder = 'No image selected',
required = false,
error,
showDimensions = true
}: Props = $props()
let showModal = $state(false)
let isHovering = $state(false)
function handleImageSelect(media: Media) {
value = media
showModal = false
}
function handleClear() {
value = null
}
function openModal() {
showModal = true
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
// Computed properties
const hasImage = $derived(value !== null && value !== undefined)
const selectedIds = $derived(hasImage ? [value!.id] : [])
// Calculate aspect ratio styles
const aspectRatioStyle = $derived(
!aspectRatio
? 'aspect-ratio: 16/9;'
: (() => {
const [width, height] = aspectRatio.split(':').map(Number)
return width && height ? `aspect-ratio: ${width}/${height};` : 'aspect-ratio: 16/9;'
})()
)
</script>
<div class="image-picker">
<label class="input-label">
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
<!-- Image Preview Area -->
<div
class="image-preview-container"
class:has-image={hasImage}
class:has-error={error}
style={aspectRatioStyle}
role="button"
tabindex="0"
onclick={openModal}
onkeydown={(e) => e.key === 'Enter' && openModal()}
onmouseenter={() => (isHovering = true)}
onmouseleave={() => (isHovering = false)}
>
{#if hasImage && value}
<!-- Image Display -->
<img src={value.url} alt={value.filename} class="preview-image" />
<!-- Hover Overlay -->
{#if isHovering}
<div class="image-overlay">
<div class="overlay-actions">
<Button variant="primary" onclick={openModal}>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Change
</Button>
<Button variant="ghost" onclick={handleClear}>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 6h18m-2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Remove
</Button>
</div>
</div>
{/if}
{:else}
<!-- Empty State -->
<div class="empty-state">
<div class="empty-icon">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="1.5"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="1.5" fill="none" />
</svg>
</div>
<p class="empty-text">{placeholder}</p>
<Button variant="ghost" onclick={openModal}>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5v14m-7-7h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
Select Image
</Button>
</div>
{/if}
</div>
<!-- Image Details -->
{#if hasImage && value}
<div class="image-details">
<div class="detail-row">
<span class="detail-label">Filename:</span>
<span class="detail-value">{value.filename}</span>
</div>
<div class="detail-row">
<span class="detail-label">Size:</span>
<span class="detail-value">{formatFileSize(value.size)}</span>
</div>
{#if showDimensions && value.width && value.height}
<div class="detail-row">
<span class="detail-label">Dimensions:</span>
<span class="detail-value">{value.width} × {value.height} px</span>
</div>
{/if}
</div>
{/if}
<!-- Error Message -->
{#if error}
<p class="error-message">{error}</p>
{/if}
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={showModal}
mode="single"
fileType="image"
{selectedIds}
title="Select Image"
confirmText="Select Image"
onselect={handleImageSelect}
/>
</div>
<style lang="scss">
.image-picker {
display: flex;
flex-direction: column;
gap: $unit;
}
.input-label {
font-size: 0.875rem;
font-weight: 500;
color: $grey-20;
.required {
color: $red-60;
margin-left: $unit-half;
}
}
.image-preview-container {
position: relative;
width: 100%;
border: 2px dashed $grey-80;
border-radius: $card-corner-radius;
overflow: hidden;
cursor: pointer;
transition: all 0.2s ease;
background-color: $grey-95;
&:hover {
border-color: $grey-60;
}
&:focus {
outline: none;
border-color: $blue-60;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&.has-image {
border-style: solid;
border-color: $grey-80;
background-color: transparent;
&:hover {
border-color: $blue-60;
}
}
&.has-error {
border-color: $red-60;
&:focus {
border-color: $red-60;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
}
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
animation: fadeIn 0.2s ease forwards;
}
@keyframes fadeIn {
to {
opacity: 1;
}
}
.overlay-actions {
display: flex;
gap: $unit-2x;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $unit-4x;
text-align: center;
height: 100%;
min-height: 200px;
gap: $unit-2x;
}
.empty-icon {
color: $grey-60;
margin-bottom: $unit;
}
.empty-text {
margin: 0;
font-size: 0.875rem;
color: $grey-40;
margin-bottom: $unit;
}
.image-details {
padding: $unit-2x;
background-color: $grey-95;
border-radius: $card-corner-radius;
display: flex;
flex-direction: column;
gap: $unit-half;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
}
.detail-label {
font-weight: 500;
color: $grey-30;
}
.detail-value {
color: $grey-10;
text-align: right;
word-break: break-all;
}
.error-message {
margin: 0;
font-size: 0.75rem;
color: $red-60;
}
// Responsive adjustments
@media (max-width: 640px) {
.empty-state {
padding: $unit-3x;
min-height: 150px;
}
.empty-icon svg {
width: 32px;
height: 32px;
}
.overlay-actions {
flex-direction: column;
gap: $unit;
}
.detail-row {
flex-direction: column;
align-items: flex-start;
gap: $unit-half;
}
.detail-value {
text-align: left;
}
}
</style>

View file

@ -0,0 +1,296 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core'
import type { Media } from '@prisma/client'
import Image from 'lucide-svelte/icons/image'
import Upload from 'lucide-svelte/icons/upload'
import Link from 'lucide-svelte/icons/link'
import Grid from 'lucide-svelte/icons/grid-3x3'
import { NodeViewWrapper } from 'svelte-tiptap'
import MediaLibraryModal from './MediaLibraryModal.svelte'
const { editor, deleteNode }: NodeViewProps = $props()
let fileInput: HTMLInputElement
let isDragging = $state(false)
let isMediaLibraryOpen = $state(false)
let isUploading = $state(false)
function handleBrowseLibrary(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
isMediaLibraryOpen = true
}
function handleDirectUpload(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
fileInput.click()
}
function handleMediaSelect(media: Media | Media[]) {
const selectedMedia = Array.isArray(media) ? media[0] : media
if (selectedMedia) {
// Set a reasonable default width (max 600px)
const displayWidth =
selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width
editor
.chain()
.focus()
.setImage({
src: selectedMedia.url,
alt: selectedMedia.altText || '',
width: displayWidth,
height: selectedMedia.height,
align: 'center'
})
.run()
}
isMediaLibraryOpen = false
}
function handleMediaLibraryClose() {
isMediaLibraryOpen = false
}
async function handleFileSelect(e: Event) {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
await uploadFile(file)
}
// Reset input
target.value = ''
}
async function uploadFile(file: File) {
// Check file type
if (!file.type.startsWith('image/')) {
alert('Please select an image file')
return
}
// Check file size (2MB max)
const filesize = file.size / 1024 / 1024
if (filesize > 2) {
alert(`Image too large! File size: ${filesize.toFixed(2)} MB (max 2MB)`)
return
}
isUploading = true
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
throw new Error('Not authenticated')
}
const formData = new FormData()
formData.append('file', file)
formData.append('type', 'image')
const response = await fetch('/api/media/upload', {
method: 'POST',
headers: {
Authorization: `Basic ${auth}`
},
body: formData
})
if (!response.ok) {
throw new Error('Upload failed')
}
const media = await response.json()
// Insert the uploaded image with reasonable default width
const displayWidth = media.width && media.width > 600 ? 600 : media.width
editor
.chain()
.focus()
.setImage({
src: media.url,
alt: media.altText || '',
width: displayWidth,
height: media.height,
align: 'center'
})
.run()
} catch (error) {
console.error('Image upload failed:', error)
alert('Failed to upload image. Please try again.')
} finally {
isUploading = false
}
}
// Drag and drop handlers
function handleDragOver(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragging = true
}
function handleDragLeave(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragging = false
}
async function handleDrop(e: DragEvent) {
e.preventDefault()
e.stopPropagation()
isDragging = false
const file = e.dataTransfer?.files[0]
if (file && file.type.startsWith('image/')) {
await uploadFile(file)
}
}
// Handle keyboard navigation
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleBrowseLibrary(e as any)
} else if (e.key === 'Escape') {
deleteNode()
}
}
</script>
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
<input
bind:this={fileInput}
type="file"
accept="image/*"
onchange={handleFileSelect}
style="display: none;"
/>
<div class="edra-image-placeholder-container">
{#if isUploading}
<div class="edra-image-placeholder-uploading">
<div class="spinner"></div>
<span>Uploading image...</span>
</div>
{:else}
<button
class="edra-image-placeholder-option"
onclick={handleDirectUpload}
onkeydown={handleKeyDown}
tabindex="0"
aria-label="Upload Image"
title="Upload from device"
>
<Upload class="edra-image-placeholder-icon" />
<span class="edra-image-placeholder-text">Upload Image</span>
</button>
<button
class="edra-image-placeholder-option"
onclick={handleBrowseLibrary}
onkeydown={handleKeyDown}
tabindex="0"
aria-label="Browse Media Library"
title="Choose from library"
>
<Grid class="edra-image-placeholder-icon" />
<span class="edra-image-placeholder-text">Browse Library</span>
</button>
{/if}
</div>
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={isMediaLibraryOpen}
mode="single"
fileType="image"
onSelect={handleMediaSelect}
onClose={handleMediaLibraryClose}
/>
</NodeViewWrapper>
<style>
.edra-image-placeholder-container {
display: flex;
gap: 12px;
padding: 24px;
border: 2px dashed #e5e7eb;
border-radius: 8px;
background: #f9fafb;
transition: all 0.2s ease;
justify-content: center;
align-items: center;
}
.edra-image-placeholder-container:hover {
border-color: #d1d5db;
background: #f3f4f6;
}
.edra-image-placeholder-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 20px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
}
.edra-image-placeholder-option:hover {
border-color: #d1d5db;
background: #f9fafb;
transform: translateY(-1px);
}
.edra-image-placeholder-option:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.edra-image-placeholder-uploading {
display: flex;
align-items: center;
gap: 8px;
padding: 20px;
color: #6b7280;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #f3f4f6;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
:global(.edra-image-placeholder-icon) {
width: 28px;
height: 28px;
color: #6b7280;
}
.edra-image-placeholder-text {
font-size: 14px;
color: #6b7280;
font-weight: 500;
}
</style>

View file

@ -0,0 +1,856 @@
<script lang="ts">
import type { Media } from '@prisma/client'
import Button from './Button.svelte'
import Input from './Input.svelte'
import SmartImage from '../SmartImage.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import { authenticatedFetch } from '$lib/admin-auth'
import RefreshIcon from '$icons/refresh.svg?component'
interface Props {
label: string
value?: Media | null
onUpload: (media: Media) => void
aspectRatio?: string // e.g., "16:9", "1:1"
required?: boolean
error?: string
allowAltText?: boolean
maxFileSize?: number // MB limit
placeholder?: string
helpText?: string
showBrowseLibrary?: boolean // Show secondary "Browse Library" button
compact?: boolean // Use compact layout with smaller preview and side-by-side alt text
}
let {
label,
value = $bindable(),
onUpload,
aspectRatio,
required = false,
error,
allowAltText = true,
maxFileSize = 10,
placeholder = 'Drag and drop an image here, or click to browse',
helpText,
showBrowseLibrary = false,
compact = false
}: Props = $props()
// State
let isUploading = $state(false)
let uploadProgress = $state(0)
let uploadError = $state<string | null>(null)
let isDragOver = $state(false)
let fileInputElement: HTMLInputElement
let altTextValue = $state(value?.altText || '')
let descriptionValue = $state(value?.description || '')
let isMediaLibraryOpen = $state(false)
// Computed properties
const hasValue = $derived(!!value)
const aspectRatioStyle = $derived(() => {
if (!aspectRatio) return ''
const [w, h] = aspectRatio.split(':').map(Number)
const ratio = (h / w) * 100
return `aspect-ratio: ${w}/${h}; padding-bottom: ${ratio}%;`
})
// File validation
function validateFile(file: File): string | null {
// Check file type
if (!file.type.startsWith('image/')) {
return 'Please select an image file'
}
// Check file size
const sizeMB = file.size / 1024 / 1024
if (sizeMB > maxFileSize) {
return `File size must be less than ${maxFileSize}MB`
}
return null
}
// Upload file to server
async function uploadFile(file: File): Promise<Media> {
const formData = new FormData()
formData.append('file', file)
if (allowAltText && altTextValue.trim()) {
formData.append('altText', altTextValue.trim())
}
if (allowAltText && descriptionValue.trim()) {
formData.append('description', descriptionValue.trim())
}
const response = await authenticatedFetch('/api/media/upload', {
method: 'POST',
body: formData
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Upload failed')
}
return await response.json()
}
// Handle file selection/drop
async function handleFiles(files: FileList) {
if (files.length === 0) return
const file = files[0]
const validationError = validateFile(file)
if (validationError) {
uploadError = validationError
return
}
uploadError = null
isUploading = true
uploadProgress = 0
try {
// Simulate progress for user feedback
const progressInterval = setInterval(() => {
if (uploadProgress < 90) {
uploadProgress += Math.random() * 10
}
}, 100)
const uploadedMedia = await uploadFile(file)
clearInterval(progressInterval)
uploadProgress = 100
// Brief delay to show completion
setTimeout(() => {
value = uploadedMedia
altTextValue = uploadedMedia.altText || ''
descriptionValue = uploadedMedia.description || ''
onUpload(uploadedMedia)
isUploading = false
uploadProgress = 0
}, 500)
} catch (err) {
isUploading = false
uploadProgress = 0
uploadError = err instanceof Error ? err.message : 'Upload failed'
}
}
// Drag and drop handlers
function handleDragOver(event: DragEvent) {
event.preventDefault()
isDragOver = true
}
function handleDragLeave(event: DragEvent) {
event.preventDefault()
isDragOver = false
}
function handleDrop(event: DragEvent) {
event.preventDefault()
isDragOver = false
const files = event.dataTransfer?.files
if (files) {
handleFiles(files)
}
}
// Click to browse handler
function handleBrowseClick() {
fileInputElement?.click()
}
function handleFileInputChange(event: Event) {
const target = event.target as HTMLInputElement
if (target.files) {
handleFiles(target.files)
}
}
// Remove uploaded image
function handleRemove() {
value = null
altTextValue = ''
descriptionValue = ''
uploadError = null
}
// Update alt text on server
async function handleAltTextChange() {
if (!value) return
try {
const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
altText: altTextValue.trim() || null
})
})
if (response.ok) {
const updatedData = await response.json()
value = { ...value, altText: updatedData.altText, updatedAt: updatedData.updatedAt }
}
} catch (error) {
console.error('Failed to update alt text:', error)
}
}
async function handleDescriptionChange() {
if (!value) return
try {
const response = await authenticatedFetch(`/api/media/${value.id}/metadata`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
description: descriptionValue.trim() || null
})
})
if (response.ok) {
const updatedData = await response.json()
value = { ...value, description: updatedData.description, updatedAt: updatedData.updatedAt }
}
} catch (error) {
console.error('Failed to update description:', error)
}
}
// Browse library handler
function handleBrowseLibrary() {
isMediaLibraryOpen = true
}
function handleMediaSelect(selectedMedia: Media | Media[]) {
// Since this is single mode, selectedMedia will be a single Media object
const media = selectedMedia as Media
value = media
altTextValue = media.altText || ''
descriptionValue = media.description || ''
onUpload(media)
}
function handleMediaLibraryClose() {
isMediaLibraryOpen = false
}
</script>
<div class="image-uploader" class:compact>
<!-- Label -->
<label class="uploader-label">
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
{#if helpText}
<p class="help-text">{helpText}</p>
{/if}
<!-- Upload Area or Preview -->
<div class="upload-container">
{#if hasValue && !isUploading}
{#if compact}
<!-- Compact Layout: Image and metadata side-by-side -->
<div class="compact-preview">
<div class="compact-image">
<SmartImage
media={value}
alt={value?.altText || value?.filename || 'Uploaded image'}
containerWidth={100}
loading="eager"
{aspectRatio}
class="preview-image"
/>
<!-- Overlay with actions -->
<div class="preview-overlay">
<div class="preview-actions">
<Button variant="overlay" buttonSize="small" onclick={handleBrowseClick}>
<RefreshIcon slot="icon" width="12" height="12" />
</Button>
<Button variant="overlay" buttonSize="small" onclick={handleRemove}>
<svg
slot="icon"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="3,6 5,6 21,6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
</div>
</div>
</div>
<div class="compact-info">
<!-- Alt Text Input in compact mode -->
{#if allowAltText}
<div class="compact-metadata">
<Input
type="text"
label="Alt Text"
bind:value={altTextValue}
placeholder="Describe this image for screen readers"
buttonSize="small"
onblur={handleAltTextChange}
/>
<Input
type="textarea"
label="Description (Optional)"
bind:value={descriptionValue}
placeholder="Additional description or caption"
rows={2}
buttonSize="small"
onblur={handleDescriptionChange}
/>
</div>
{/if}
</div>
</div>
{:else}
<!-- Standard Layout: Image preview -->
<div class="image-preview" style={aspectRatioStyle}>
<SmartImage
media={value}
alt={value?.altText || value?.filename || 'Uploaded image'}
containerWidth={800}
loading="eager"
{aspectRatio}
class="preview-image"
/>
<!-- Overlay with actions -->
<div class="preview-overlay">
<div class="preview-actions">
<Button variant="overlay" buttonSize="small" onclick={handleBrowseClick}>
<RefreshIcon slot="icon" width="16" height="16" />
Replace
</Button>
<Button variant="overlay" buttonSize="small" onclick={handleRemove}>
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<polyline
points="3,6 5,6 21,6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M19 6V20A2 2 0 0 1 17 22H7A2 2 0 0 1 5 20V6M8 6V4A2 2 0 0 1 10 2H14A2 2 0 0 1 16 4V6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Remove
</Button>
</div>
</div>
</div>
<!-- File Info -->
<div class="file-info">
<p class="filename">{value?.originalName || value?.filename}</p>
<p class="file-meta">
{Math.round((value?.size || 0) / 1024)} KB
{#if value?.width && value?.height}
{value.width}×{value.height}
{/if}
</p>
</div>
{/if}
{:else}
<!-- Upload Drop Zone -->
<div
class="drop-zone"
class:drag-over={isDragOver}
class:uploading={isUploading}
class:has-error={!!uploadError}
style={aspectRatioStyle}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={handleBrowseClick}
>
{#if isUploading}
<!-- Upload Progress -->
<div class="upload-progress">
<svg class="upload-spinner" width="24" height="24" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="2"
fill="none"
stroke-dasharray="60"
stroke-dashoffset="60"
stroke-linecap="round"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 12 12"
to="360 12 12"
dur="1s"
repeatCount="indefinite"
/>
</circle>
</svg>
<p class="upload-text">Uploading... {Math.round(uploadProgress)}%</p>
<div class="progress-bar">
<div class="progress-fill" style="width: {uploadProgress}%"></div>
</div>
</div>
{:else}
<!-- Upload Prompt -->
<div class="upload-prompt">
<svg
class="upload-icon"
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="14,2 14,8 20,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<line
x1="16"
y1="13"
x2="8"
y2="13"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<line
x1="16"
y1="17"
x2="8"
y2="17"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
<polyline
points="10,9 9,9 8,9"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<p class="upload-main-text">{placeholder}</p>
<p class="upload-sub-text">
Supports JPG, PNG, GIF up to {maxFileSize}MB
</p>
</div>
{/if}
</div>
{/if}
</div>
<!-- Action Buttons -->
{#if !hasValue && !isUploading}
<div class="action-buttons">
<Button variant="primary" onclick={handleBrowseClick}>Choose File</Button>
{#if showBrowseLibrary}
<Button variant="ghost" onclick={handleBrowseLibrary}>Browse Library</Button>
{/if}
</div>
{/if}
<!-- Alt Text Input (only in standard mode, compact mode has it inline) -->
{#if allowAltText && hasValue && !compact}
<div class="metadata-section">
<Input
type="text"
label="Alt Text"
bind:value={altTextValue}
placeholder="Describe this image for screen readers"
helpText="Help make your content accessible. Describe what's in the image."
onblur={handleAltTextChange}
/>
<Input
type="textarea"
label="Description (Optional)"
bind:value={descriptionValue}
placeholder="Additional description or caption"
rows={2}
onblur={handleDescriptionChange}
/>
</div>
{/if}
<!-- Error Message -->
{#if error || uploadError}
<p class="error-message">{error || uploadError}</p>
{/if}
<!-- Hidden File Input -->
<input
bind:this={fileInputElement}
type="file"
accept="image/*"
style="display: none;"
onchange={handleFileInputChange}
/>
</div>
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={isMediaLibraryOpen}
mode="single"
fileType="image"
title="Select Image"
confirmText="Select Image"
onSelect={handleMediaSelect}
onClose={handleMediaLibraryClose}
/>
<style lang="scss">
.image-uploader {
display: flex;
flex-direction: column;
gap: $unit-2x;
&.compact {
gap: $unit;
}
}
.uploader-label {
font-size: 0.875rem;
font-weight: 500;
color: $grey-20;
.required {
color: $red-60;
margin-left: $unit-half;
}
}
.help-text {
margin: 0;
font-size: 0.8rem;
color: $grey-40;
line-height: 1.4;
}
.upload-container {
position: relative;
}
// Drop Zone Styles
.drop-zone {
border: 2px dashed $grey-80;
border-radius: $card-corner-radius;
background-color: $grey-97;
cursor: pointer;
transition: all 0.2s ease;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
&:hover {
border-color: $blue-60;
background-color: rgba($blue-60, 0.02);
}
&.drag-over {
border-color: $blue-60;
background-color: rgba($blue-60, 0.05);
border-style: solid;
}
&.uploading {
cursor: default;
border-color: $blue-60;
}
&.has-error {
border-color: $red-60;
background-color: rgba($red-60, 0.02);
}
}
.upload-prompt {
text-align: center;
padding: $unit-4x;
.upload-icon {
color: $grey-50;
margin-bottom: $unit-2x;
}
.upload-main-text {
margin: 0 0 $unit 0;
font-size: 0.875rem;
color: $grey-30;
font-weight: 500;
}
.upload-sub-text {
margin: 0;
font-size: 0.75rem;
color: $grey-50;
}
}
.upload-progress {
text-align: center;
padding: $unit-4x;
.upload-spinner {
color: $blue-60;
margin-bottom: $unit-2x;
}
.upload-text {
margin: 0 0 $unit-2x 0;
font-size: 0.875rem;
color: $grey-30;
font-weight: 500;
}
.progress-bar {
width: 200px;
height: 4px;
background-color: $grey-90;
border-radius: 2px;
overflow: hidden;
margin: 0 auto;
.progress-fill {
height: 100%;
background-color: $blue-60;
transition: width 0.3s ease;
}
}
}
// Image Preview Styles
.image-preview {
position: relative;
border-radius: $card-corner-radius;
overflow: hidden;
background-color: $grey-95;
min-height: 200px;
:global(.preview-image) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover .preview-overlay {
opacity: 1;
}
.preview-actions {
display: flex;
gap: $unit;
}
}
.file-info {
margin-top: $unit-2x;
.filename {
margin: 0 0 $unit-half 0;
font-size: 0.875rem;
font-weight: 500;
color: $grey-10;
}
.file-meta {
margin: 0;
font-size: 0.75rem;
color: $grey-40;
}
}
.action-buttons {
display: flex;
gap: $unit-2x;
align-items: center;
}
.metadata-section {
display: flex;
flex-direction: column;
gap: $unit-2x;
padding: $unit-3x;
background-color: $grey-97;
border-radius: $card-corner-radius;
border: 1px solid $grey-90;
}
.error-message {
margin: 0;
font-size: 0.75rem;
color: $red-60;
padding: $unit;
background-color: rgba($red-60, 0.05);
border-radius: $card-corner-radius;
border: 1px solid rgba($red-60, 0.2);
}
// Compact layout styles
.compact-preview {
display: flex;
gap: $unit-3x;
align-items: flex-start;
}
.compact-image {
position: relative;
width: 100px;
height: 100px;
flex-shrink: 0;
border-radius: $card-corner-radius;
overflow: hidden;
background-color: $grey-95;
border: 1px solid $grey-90;
:global(.preview-image) {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
padding: $unit-3x;
box-sizing: border-box;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
&:hover .preview-overlay {
opacity: 1;
}
.preview-actions {
display: flex;
gap: $unit-half;
}
}
.compact-info {
flex: 1;
display: flex;
flex-direction: column;
.compact-metadata {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
}
// Responsive adjustments
@media (max-width: 640px) {
.upload-prompt {
padding: $unit-3x;
.upload-main-text {
font-size: 0.8rem;
}
}
.action-buttons {
flex-direction: column;
align-items: stretch;
}
.preview-actions {
flex-direction: column;
}
}
</style>

View file

@ -0,0 +1,508 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements'
// Type helpers for different input elements
type InputProps = HTMLInputAttributes & {
type?:
| 'text'
| 'email'
| 'password'
| 'url'
| 'search'
| 'number'
| 'tel'
| 'date'
| 'time'
| 'color'
}
type TextareaProps = HTMLTextareaAttributes & {
type: 'textarea'
rows?: number
autoResize?: boolean
}
type Props = (InputProps | TextareaProps) & {
label?: string
error?: string
helpText?: string
size?: 'small' | 'medium' | 'large'
pill?: boolean
fullWidth?: boolean
required?: boolean
class?: string
wrapperClass?: string
inputClass?: string
prefixIcon?: boolean
suffixIcon?: boolean
showCharCount?: boolean
maxLength?: number
colorSwatch?: boolean // Show color swatch based on input value
}
let {
label,
error,
helpText,
size = 'medium',
pill = false,
fullWidth = true,
required = false,
disabled = false,
readonly = false,
type = 'text',
value = $bindable(''),
class: className = '',
wrapperClass = '',
inputClass = '',
prefixIcon = false,
suffixIcon = false,
showCharCount = false,
maxLength,
colorSwatch = false,
id = `input-${Math.random().toString(36).substr(2, 9)}`,
...restProps
}: Props = $props()
// For textarea auto-resize
let textareaElement: HTMLTextAreaElement | undefined = $state()
let charCount = $derived(String(value).length)
let charsRemaining = $derived(maxLength ? maxLength - charCount : 0)
// Color swatch validation and display
const isValidHexColor = $derived(() => {
if (!colorSwatch || !value) return false
const hexRegex = /^#[0-9A-Fa-f]{6}$/
return hexRegex.test(String(value))
})
// Color picker functionality
let colorPickerInput: HTMLInputElement
function handleColorSwatchClick() {
if (colorPickerInput) {
colorPickerInput.click()
}
}
function handleColorPickerChange(event: Event) {
const target = event.target as HTMLInputElement
if (target.value) {
value = target.value.toUpperCase()
}
}
// Auto-resize textarea
$effect(() => {
if (type === 'textarea' && textareaElement && isTextarea(restProps) && restProps.autoResize) {
// Reset height to auto to get the correct scrollHeight
textareaElement.style.height = 'auto'
// Set the height to match content
textareaElement.style.height = textareaElement.scrollHeight + 'px'
}
})
// Compute classes
const wrapperClasses = $derived(() => {
const classes = ['input-wrapper']
if (size) classes.push(`input-wrapper-${size}`)
if (fullWidth) classes.push('full-width')
if (error) classes.push('has-error')
if (disabled) classes.push('is-disabled')
if (prefixIcon) classes.push('has-prefix-icon')
if (suffixIcon) classes.push('has-suffix-icon')
if (colorSwatch) classes.push('has-color-swatch')
if (type === 'textarea' && isTextarea(restProps) && restProps.autoResize)
classes.push('has-auto-resize')
if (wrapperClass) classes.push(wrapperClass)
if (className) classes.push(className)
return classes.join(' ')
})
const inputClasses = $derived(() => {
const classes = ['input']
classes.push(`input-${size}`)
if (pill) classes.push('input-pill')
if (inputClass) classes.push(inputClass)
return classes.join(' ')
})
// Type guard for textarea props
function isTextarea(props: Props): props is TextareaProps {
return props.type === 'textarea'
}
</script>
<div class={wrapperClasses()}>
{#if label}
<label for={id} class="input-label">
{label}
{#if required}
<span class="required-indicator">*</span>
{/if}
</label>
{/if}
<div class="input-container">
{#if prefixIcon}
<span class="input-icon prefix-icon">
<slot name="prefix" />
</span>
{/if}
{#if colorSwatch && isValidHexColor}
<span
class="color-swatch"
style="background-color: {value}"
onclick={handleColorSwatchClick}
role="button"
tabindex="0"
aria-label="Open color picker"
></span>
{/if}
{#if type === 'textarea' && isTextarea(restProps)}
<textarea
bind:this={textareaElement}
bind:value
{id}
{disabled}
{readonly}
{required}
{maxLength}
class={inputClasses()}
rows={restProps.rows || 3}
{...restProps}
/>
{:else}
<input
bind:value
{id}
{type}
{disabled}
{readonly}
{required}
{maxLength}
class={inputClasses()}
{...restProps}
/>
{/if}
{#if suffixIcon}
<span class="input-icon suffix-icon">
<slot name="suffix" />
</span>
{/if}
{#if colorSwatch}
<input
bind:this={colorPickerInput}
type="color"
value={isValidHexColor ? String(value) : '#000000'}
oninput={handleColorPickerChange}
onchange={handleColorPickerChange}
style="position: absolute; visibility: hidden; pointer-events: none;"
tabindex="-1"
/>
{/if}
</div>
{#if (error || helpText || showCharCount) && !disabled}
<div class="input-footer">
{#if error}
<span class="input-error">{error}</span>
{:else if helpText}
<span class="input-help">{helpText}</span>
{/if}
{#if showCharCount && maxLength}
<span
class="char-count"
class:warning={charsRemaining < maxLength * 0.1}
class:error={charsRemaining < 0}
>
{charsRemaining}
</span>
{/if}
</div>
{/if}
</div>
<style lang="scss">
@import '$styles/variables.scss';
// Wrapper styles
.input-wrapper {
display: inline-block;
position: relative;
&.full-width {
display: block;
width: 100%;
}
&.has-error {
.input {
border-color: $red-50;
&:focus {
border-color: $red-50;
}
}
}
&.is-disabled {
opacity: 0.6;
}
&.has-color-swatch {
.input {
padding-left: 36px; // Make room for color swatch (20px + 8px margin + 8px padding)
}
}
}
// Label styles
.input-label {
display: block;
margin-bottom: $unit;
font-size: 14px;
font-weight: 500;
color: $grey-20;
}
.required-indicator {
color: $red-50;
margin-left: 2px;
}
// Container for input and icons
.input-container {
position: relative;
display: flex;
align-items: stretch;
width: 100%;
}
// Color swatch styles
.color-swatch {
position: absolute;
left: 8px;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.1);
z-index: 1;
cursor: pointer;
transition: border-color 0.15s ease;
&:hover {
border-color: rgba(0, 0, 0, 0.2);
}
}
// Input and textarea styles
.input {
width: 100%;
font-size: 14px;
border: 1px solid $grey-80;
border-radius: 6px;
background-color: white;
transition: all 0.15s ease;
&::placeholder {
color: $grey-50;
}
&:focus {
outline: none;
border-color: $primary-color;
background-color: white;
}
&:disabled {
background-color: $grey-95;
cursor: not-allowed;
color: $grey-40;
}
&:read-only {
background-color: $grey-97;
cursor: default;
}
}
// Size variations
.input-small {
padding: $unit calc($unit * 1.5);
font-size: 13px;
}
.input-medium {
padding: calc($unit * 1.5) $unit-2x;
font-size: 14px;
}
.input-large {
padding: $unit-2x $unit-3x;
font-size: 16px;
box-sizing: border-box;
}
// Shape variants - pill vs rounded
.input-pill {
&.input-small {
border-radius: 20px;
}
&.input-medium {
border-radius: 24px;
}
&.input-large {
border-radius: 28px;
}
}
.input:not(.input-pill) {
&.input-small {
border-radius: 6px;
}
&.input-medium {
border-radius: 8px;
}
&.input-large {
border-radius: 10px;
}
}
// Icon adjustments
.has-prefix-icon .input {
padding-left: calc($unit-2x + 24px);
}
.has-suffix-icon .input {
padding-right: calc($unit-2x + 24px);
}
.input-icon {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
color: $grey-40;
pointer-events: none;
&.prefix-icon {
left: $unit-2x;
}
&.suffix-icon {
right: $unit-2x;
}
:global(svg) {
width: 16px;
height: 16px;
}
}
// Textarea specific
textarea.input {
resize: vertical;
min-height: 80px;
padding-top: calc($unit * 1.5);
padding-bottom: calc($unit * 1.5);
line-height: 1.5;
overflow-y: hidden; // Important for auto-resize
&.input-small {
min-height: 60px;
padding-top: $unit;
padding-bottom: $unit;
}
&.input-large {
min-height: 100px;
}
}
// Auto-resizing textarea
.has-auto-resize textarea.input {
resize: none; // Disable manual resize when auto-resize is enabled
}
// Footer styles
.input-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: $unit-half;
min-height: 20px;
}
.input-error,
.input-help {
font-size: 13px;
line-height: 1.4;
}
.input-error {
color: $red-50;
}
.input-help {
color: $grey-40;
}
.char-count {
font-size: 12px;
color: $grey-50;
font-variant-numeric: tabular-nums;
margin-left: auto;
&.warning {
color: $universe-color;
}
&.error {
color: $red-50;
font-weight: 500;
}
}
// Special input types
input[type='color'].input {
padding: $unit;
cursor: pointer;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
&::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
}
input[type='number'].input {
-moz-appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
// Search input
input[type='search'].input {
&::-webkit-search-decoration,
&::-webkit-search-cancel-button {
-webkit-appearance: none;
}
}
</style>

View file

@ -0,0 +1,51 @@
<script lang="ts">
interface Props {
size?: 'small' | 'medium' | 'large'
text?: string
}
let { size = 'medium', text = '' }: Props = $props()
const sizeMap = {
small: '24px',
medium: '32px',
large: '48px'
}
</script>
<div class="loading-spinner">
<div class="spinner" style="width: {sizeMap[size]}; height: {sizeMap[size]};"></div>
{#if text}
<p class="loading-text">{text}</p>
{/if}
</div>
<style lang="scss">
.loading-spinner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $unit-2x;
padding: $unit-8x;
}
.spinner {
border: 3px solid $grey-80;
border-top-color: $primary-color;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
margin: 0;
color: $grey-40;
font-size: 1rem;
}
</style>

View file

@ -0,0 +1,779 @@
<script lang="ts">
import Modal from './Modal.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte'
import SmartImage from '../SmartImage.svelte'
import { authenticatedFetch } from '$lib/admin-auth'
import type { Media } from '@prisma/client'
interface Props {
isOpen: boolean
media: Media | null
onClose: () => void
onUpdate: (updatedMedia: Media) => void
}
let { isOpen = $bindable(), media, onClose, onUpdate }: Props = $props()
// Form state
let altText = $state('')
let description = $state('')
let isPhotography = $state(false)
let isSaving = $state(false)
let error = $state('')
let successMessage = $state('')
// Usage tracking state
let usage = $state<
Array<{
contentType: string
contentId: number
contentTitle: string
fieldDisplayName: string
contentUrl?: string
createdAt: string
}>
>([])
let loadingUsage = $state(false)
// Initialize form when media changes
$effect(() => {
if (media) {
altText = media.altText || ''
description = media.description || ''
isPhotography = media.isPhotography || false
error = ''
successMessage = ''
loadUsage()
}
})
// Load usage information
async function loadUsage() {
if (!media) return
try {
loadingUsage = true
const response = await authenticatedFetch(`/api/media/${media.id}/usage`)
if (response.ok) {
const data = await response.json()
usage = data.usage || []
} else {
console.warn('Failed to load media usage')
usage = []
}
} catch (error) {
console.error('Error loading media usage:', error)
usage = []
} finally {
loadingUsage = false
}
}
function handleClose() {
altText = ''
description = ''
isPhotography = false
error = ''
successMessage = ''
isOpen = false
onClose()
}
async function handleSave() {
if (!media) return
try {
isSaving = true
error = ''
const response = await authenticatedFetch(`/api/media/${media.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
altText: altText.trim() || null,
description: description.trim() || null,
isPhotography: isPhotography
})
})
if (!response.ok) {
throw new Error('Failed to update media')
}
const updatedMedia = await response.json()
onUpdate(updatedMedia)
successMessage = 'Media updated successfully!'
// Auto-close after success
setTimeout(() => {
handleClose()
}, 1500)
} catch (err) {
error = 'Failed to update media. Please try again.'
console.error('Failed to update media:', err)
} finally {
isSaving = false
}
}
async function handleDelete() {
if (
!media ||
!confirm('Are you sure you want to delete this media file? This action cannot be undone.')
) {
return
}
try {
isSaving = true
error = ''
const response = await authenticatedFetch(`/api/media/${media.id}`, {
method: 'DELETE'
})
if (!response.ok) {
throw new Error('Failed to delete media')
}
// Close modal and let parent handle the deletion
handleClose()
// Note: Parent component should refresh the media list
} catch (err) {
error = 'Failed to delete media. Please try again.'
console.error('Failed to delete media:', err)
} finally {
isSaving = false
}
}
function copyUrl() {
if (media?.url) {
navigator.clipboard
.writeText(media.url)
.then(() => {
successMessage = 'URL copied to clipboard!'
setTimeout(() => {
successMessage = ''
}, 2000)
})
.catch(() => {
error = 'Failed to copy URL'
setTimeout(() => {
error = ''
}, 2000)
})
}
}
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]
}
function getFileType(mimeType: string): string {
if (mimeType.startsWith('image/')) return 'Image'
if (mimeType.startsWith('video/')) return 'Video'
if (mimeType.startsWith('audio/')) return 'Audio'
if (mimeType.includes('pdf')) return 'PDF'
return 'File'
}
</script>
{#if media}
<Modal
bind:isOpen
size="large"
closeOnBackdrop={!isSaving}
closeOnEscape={!isSaving}
on:close={handleClose}
>
<div class="media-details-modal">
<!-- Header -->
<div class="modal-header">
<div class="header-content">
<h2>Media Details</h2>
<p class="filename">{media.filename}</p>
</div>
{#if !isSaving}
<Button variant="ghost" onclick={handleClose} iconOnly aria-label="Close modal">
<svg
slot="icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 6L18 18M6 18L18 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</Button>
{/if}
</div>
<!-- Content -->
<div class="modal-body">
<div class="media-preview-section">
<!-- Media Preview -->
<div class="media-preview">
{#if media.mimeType.startsWith('image/')}
<SmartImage {media} alt={media.altText || media.filename} />
{:else}
<div class="file-placeholder">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14 2H6A2 2 0 0 0 4 4V20A2 2 0 0 0 6 22H18A2 2 0 0 0 20 20V8L14 2Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<polyline
points="14,2 14,8 20,8"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span class="file-type">{getFileType(media.mimeType)}</span>
</div>
{/if}
</div>
<!-- File Info -->
<div class="file-info">
<div class="info-row">
<span class="label">Type:</span>
<span class="value">{getFileType(media.mimeType)}</span>
</div>
<div class="info-row">
<span class="label">Size:</span>
<span class="value">{formatFileSize(media.size)}</span>
</div>
{#if media.width && media.height}
<div class="info-row">
<span class="label">Dimensions:</span>
<span class="value">{media.width} × {media.height}px</span>
</div>
{/if}
<div class="info-row">
<span class="label">Uploaded:</span>
<span class="value">{new Date(media.createdAt).toLocaleDateString()}</span>
</div>
<div class="info-row">
<span class="label">URL:</span>
<div class="url-section">
<span class="url-text">{media.url}</span>
<Button variant="ghost" buttonSize="small" onclick={copyUrl}>Copy</Button>
</div>
</div>
</div>
</div>
<!-- Edit Form -->
<div class="edit-form">
<h3>Accessibility & SEO</h3>
<Input
type="text"
label="Alt Text"
bind:value={altText}
placeholder="Describe this image for screen readers"
helpText="Help make your content accessible. Describe what's in the image."
disabled={isSaving}
fullWidth
/>
<Input
type="textarea"
label="Description (Optional)"
bind:value={description}
placeholder="Additional description or caption"
helpText="Optional longer description for context or captions."
rows={3}
disabled={isSaving}
fullWidth
/>
<!-- Photography Toggle -->
<div class="photography-toggle">
<label class="toggle-label">
<input
type="checkbox"
bind:checked={isPhotography}
disabled={isSaving}
class="toggle-input"
/>
<span class="toggle-slider"></span>
<div class="toggle-content">
<span class="toggle-title">Photography</span>
<span class="toggle-description">Show this media in the photography experience</span
>
</div>
</label>
</div>
<!-- Usage Tracking -->
<div class="usage-section">
<h4>Used In</h4>
{#if loadingUsage}
<div class="usage-loading">
<div class="spinner"></div>
<span>Loading usage information...</span>
</div>
{:else if usage.length > 0}
<ul class="usage-list">
{#each usage as usageItem}
<li class="usage-item">
<div class="usage-content">
<div class="usage-header">
{#if usageItem.contentUrl}
<a
href={usageItem.contentUrl}
class="usage-title"
target="_blank"
rel="noopener"
>
{usageItem.contentTitle}
</a>
{:else}
<span class="usage-title">{usageItem.contentTitle}</span>
{/if}
<span class="usage-type">{usageItem.contentType}</span>
</div>
<div class="usage-details">
<span class="usage-field">{usageItem.fieldDisplayName}</span>
<span class="usage-date"
>Added {new Date(usageItem.createdAt).toLocaleDateString()}</span
>
</div>
</div>
</li>
{/each}
</ul>
{:else}
<p class="no-usage">This media file is not currently used in any content.</p>
{/if}
</div>
</div>
</div>
<!-- Footer -->
<div class="modal-footer">
<div class="footer-left">
<Button variant="ghost" onclick={handleDelete} disabled={isSaving} class="delete-button">
Delete
</Button>
</div>
<div class="footer-right">
{#if error}
<span class="error-text">{error}</span>
{/if}
{#if successMessage}
<span class="success-text">{successMessage}</span>
{/if}
<Button variant="ghost" onclick={handleClose} disabled={isSaving}>Cancel</Button>
<Button variant="primary" onclick={handleSave} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</div>
</Modal>
{/if}
<style lang="scss">
.media-details-modal {
display: flex;
flex-direction: column;
height: 100%;
max-height: 90vh;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $unit-4x;
border-bottom: 1px solid $grey-90;
flex-shrink: 0;
.header-content {
flex: 1;
h2 {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 $unit-half 0;
color: $grey-10;
}
.filename {
font-size: 0.875rem;
color: $grey-40;
margin: 0;
word-break: break-all;
}
}
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: $unit-4x;
display: flex;
flex-direction: column;
gap: $unit-6x;
}
.media-preview-section {
display: grid;
grid-template-columns: 300px 1fr;
gap: $unit-4x;
align-items: start;
@include breakpoint('tablet') {
grid-template-columns: 1fr;
gap: $unit-3x;
}
}
.media-preview {
width: 100%;
max-width: 300px;
aspect-ratio: 4/3;
border-radius: 12px;
overflow: hidden;
background: $grey-95;
display: flex;
align-items: center;
justify-content: center;
:global(img) {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: $unit-2x;
color: $grey-50;
.file-type {
font-size: 0.875rem;
font-weight: 500;
}
}
}
.file-info {
display: flex;
flex-direction: column;
gap: $unit-2x;
}
.info-row {
display: flex;
align-items: center;
gap: $unit-2x;
.label {
font-weight: 500;
color: $grey-30;
min-width: 80px;
}
.value {
color: $grey-10;
flex: 1;
}
.url-section {
display: flex;
align-items: center;
gap: $unit-2x;
flex: 1;
.url-text {
color: $grey-10;
font-size: 0.875rem;
word-break: break-all;
flex: 1;
}
}
}
.edit-form {
display: flex;
flex-direction: column;
gap: $unit-4x;
h3 {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
color: $grey-10;
}
h4 {
font-size: 1rem;
font-weight: 600;
margin: 0;
color: $grey-20;
}
}
.photography-toggle {
.toggle-label {
display: flex;
align-items: center;
gap: $unit-3x;
cursor: pointer;
user-select: none;
}
.toggle-input {
position: absolute;
opacity: 0;
pointer-events: none;
&:checked + .toggle-slider {
background-color: $blue-60;
&::before {
transform: translateX(20px);
}
}
&:disabled + .toggle-slider {
opacity: 0.5;
cursor: not-allowed;
}
}
.toggle-slider {
position: relative;
width: 44px;
height: 24px;
background-color: $grey-80;
border-radius: 12px;
transition: background-color 0.2s ease;
flex-shrink: 0;
&::before {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: white;
border-radius: 50%;
transition: transform 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
}
.toggle-content {
display: flex;
flex-direction: column;
gap: $unit-half;
.toggle-title {
font-weight: 500;
color: $grey-10;
font-size: 0.875rem;
}
.toggle-description {
font-size: 0.75rem;
color: $grey-50;
line-height: 1.4;
}
}
}
.usage-section {
.usage-list {
list-style: none;
padding: 0;
margin: $unit-2x 0 0 0;
display: flex;
flex-direction: column;
gap: $unit;
}
.usage-loading {
display: flex;
align-items: center;
gap: $unit-2x;
padding: $unit-2x;
color: $grey-50;
.spinner {
width: 16px;
height: 16px;
border: 2px solid $grey-90;
border-top: 2px solid $grey-50;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
.usage-item {
padding: $unit-3x;
background: $grey-95;
border-radius: 12px;
border: 1px solid $grey-90;
.usage-content {
display: flex;
flex-direction: column;
gap: $unit;
}
.usage-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: $unit-2x;
.usage-title {
font-weight: 600;
color: $grey-10;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: $blue-60;
}
}
.usage-type {
background: $grey-85;
color: $grey-30;
padding: $unit-half $unit;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
}
.usage-details {
display: flex;
align-items: center;
gap: $unit-3x;
.usage-field {
color: $grey-40;
font-size: 0.875rem;
font-weight: 500;
}
.usage-date {
color: $grey-50;
font-size: 0.75rem;
}
}
}
.no-usage {
color: $grey-50;
font-style: italic;
margin: $unit-2x 0 0 0;
}
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: $unit-4x;
border-top: 1px solid $grey-90;
flex-shrink: 0;
.footer-left {
:global(.delete-button) {
color: $red-60;
&:hover {
background-color: rgba(239, 68, 68, 0.1);
}
}
}
.footer-right {
display: flex;
align-items: center;
gap: $unit-2x;
.error-text {
color: $red-60;
font-size: 0.875rem;
}
.success-text {
color: #16a34a; // green-600 equivalent
font-size: 0.875rem;
}
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// Responsive adjustments
@include breakpoint('phone') {
.modal-header {
padding: $unit-3x;
}
.modal-body {
padding: $unit-3x;
}
.modal-footer {
padding: $unit-3x;
flex-direction: column;
gap: $unit-3x;
align-items: stretch;
.footer-right {
justify-content: space-between;
}
}
}
</style>

View file

@ -0,0 +1,430 @@
<script lang="ts">
import Button from './Button.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import type { Media } from '@prisma/client'
interface Props {
label: string
value?: Media | Media[] | null
mode: 'single' | 'multiple'
fileType?: 'image' | 'video' | 'all'
placeholder?: string
required?: boolean
error?: string
}
let {
label,
value = $bindable(),
mode,
fileType = 'all',
placeholder = mode === 'single' ? 'No file selected' : 'No files selected',
required = false,
error
}: Props = $props()
let showModal = $state(false)
function handleMediaSelect(media: Media | Media[]) {
value = media
showModal = false
}
function handleClear() {
if (mode === 'single') {
value = null
} else {
value = []
}
}
function openModal() {
showModal = true
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
// Computed properties
const hasValue = $derived(
mode === 'single'
? value !== null && value !== undefined
: Array.isArray(value) && value.length > 0
)
const displayText = $derived(
!hasValue
? placeholder
: mode === 'single' && value && !Array.isArray(value)
? value.filename
: mode === 'multiple' && Array.isArray(value)
? value.length === 1
? `${value.length} file selected`
: `${value.length} files selected`
: placeholder
)
const selectedIds = $derived(
!hasValue
? []
: mode === 'single' && value && !Array.isArray(value)
? [value.id]
: mode === 'multiple' && Array.isArray(value)
? value.map((item) => item.id)
: []
)
const modalTitle = $derived(
mode === 'single'
? `Select ${fileType === 'image' ? 'Image' : 'Media'}`
: `Select ${fileType === 'image' ? 'Images' : 'Media'}`
)
const confirmText = $derived(mode === 'single' ? 'Select' : 'Select Files')
</script>
<div class="media-input">
<label class="input-label">
{label}
{#if required}
<span class="required">*</span>
{/if}
</label>
<!-- Selected Media Preview -->
{#if hasValue}
<div class="selected-media">
{#if mode === 'single' && value && !Array.isArray(value)}
<div class="media-preview single">
<div class="media-thumbnail">
{#if value.thumbnailUrl}
<img src={value.thumbnailUrl} alt={value.filename} />
{:else}
<div class="media-placeholder">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path
d="M3 16l5-5 3 3 4-4 4 4"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
</svg>
</div>
{/if}
</div>
<div class="media-info">
<p class="filename">{value.filename}</p>
<p class="file-meta">
{formatFileSize(value.size)}
{#if value.width && value.height}
{value.width}×{value.height}
{/if}
</p>
</div>
</div>
{:else if mode === 'multiple' && Array.isArray(value) && value.length > 0}
<div class="media-preview multiple">
<div class="media-grid">
{#each value.slice(0, 4) as item}
<div class="media-thumbnail">
{#if item.thumbnailUrl}
<img src={item.thumbnailUrl} alt={item.filename} />
{:else}
<div class="media-placeholder">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path
d="M3 16l5-5 3 3 4-4 4 4"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
</svg>
</div>
{/if}
</div>
{/each}
{#if value.length > 4}
<div class="media-thumbnail overflow">
<div class="overflow-indicator">
+{value.length - 4}
</div>
</div>
{/if}
</div>
<p class="selection-summary">
{value.length} file{value.length !== 1 ? 's' : ''} selected
</p>
</div>
{/if}
</div>
{/if}
<!-- Input Field -->
<div class="input-field" class:has-error={error}>
<input
type="text"
readonly
value={displayText}
class="media-input-field"
class:placeholder={!hasValue}
/>
<div class="input-actions">
<Button variant="ghost" onclick={openModal}>Browse</Button>
{#if hasValue}
<Button variant="ghost" onclick={handleClear} aria-label="Clear selection">
<svg
slot="icon"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 6L18 18M6 18L18 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</Button>
{/if}
</div>
</div>
<!-- Error Message -->
{#if error}
<p class="error-message">{error}</p>
{/if}
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={showModal}
{mode}
{fileType}
{selectedIds}
title={modalTitle}
{confirmText}
onselect={handleMediaSelect}
/>
</div>
<style lang="scss">
.media-input {
display: flex;
flex-direction: column;
gap: $unit;
}
.input-label {
font-size: 0.875rem;
font-weight: 500;
color: $grey-20;
.required {
color: $red-60;
margin-left: $unit-half;
}
}
.selected-media {
padding: $unit-2x;
background-color: $grey-95;
border-radius: $card-corner-radius;
border: 1px solid $grey-85;
}
.media-preview {
&.single {
display: flex;
gap: $unit-2x;
align-items: flex-start;
}
&.multiple {
display: flex;
flex-direction: column;
gap: $unit;
}
}
.media-thumbnail {
width: 60px;
height: 60px;
border-radius: calc($card-corner-radius - 2px);
overflow: hidden;
background-color: $grey-90;
flex-shrink: 0;
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
&.overflow {
display: flex;
align-items: center;
justify-content: center;
background-color: $grey-80;
color: $grey-30;
font-size: 0.75rem;
font-weight: 600;
}
}
.media-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: $grey-60;
}
.media-info {
flex: 1;
min-width: 0;
.filename {
margin: 0 0 $unit-half 0;
font-size: 0.875rem;
font-weight: 500;
color: $grey-10;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
margin: 0;
font-size: 0.75rem;
color: $grey-40;
}
}
.media-grid {
display: flex;
gap: $unit;
margin-bottom: $unit;
}
.selection-summary {
margin: 0;
font-size: 0.875rem;
color: $grey-30;
font-weight: 500;
}
.input-field {
position: relative;
display: flex;
align-items: center;
border: 1px solid $grey-80;
border-radius: $card-corner-radius;
background-color: white;
transition: border-color 0.2s ease;
&:focus-within {
border-color: $blue-60;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
&.has-error {
border-color: $red-60;
&:focus-within {
border-color: $red-60;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
}
}
.media-input-field {
flex: 1;
padding: $unit $unit-2x;
border: none;
background: transparent;
font-size: 0.875rem;
color: $grey-10;
&:focus {
outline: none;
}
&.placeholder {
color: $grey-50;
}
&[readonly] {
cursor: pointer;
}
}
.input-actions {
display: flex;
align-items: center;
padding-right: $unit-half;
gap: $unit-half;
}
.error-message {
margin: 0;
font-size: 0.75rem;
color: $red-60;
}
// Responsive adjustments
@media (max-width: 640px) {
.media-preview.single {
flex-direction: column;
}
.media-thumbnail {
width: 80px;
height: 80px;
}
.media-grid {
flex-wrap: wrap;
}
}
</style>

View file

@ -0,0 +1,225 @@
<script lang="ts">
import Modal from './Modal.svelte'
import Button from './Button.svelte'
import MediaSelector from './MediaSelector.svelte'
import type { Media } from '@prisma/client'
interface Props {
isOpen: boolean
mode: 'single' | 'multiple'
fileType?: 'image' | 'video' | 'all'
selectedIds?: number[]
title?: string
confirmText?: string
onSelect: (media: Media | Media[]) => void
onClose: () => void
}
let {
isOpen = $bindable(),
mode,
fileType = 'all',
selectedIds = [],
title = mode === 'single' ? 'Select Media' : 'Select Media Files',
confirmText = mode === 'single' ? 'Select' : 'Select Files',
onSelect,
onClose
}: Props = $props()
let selectedMedia = $state<Media[]>([])
let isLoading = $state(false)
function handleMediaSelect(media: Media[]) {
selectedMedia = media
}
function handleConfirm() {
if (selectedMedia.length === 0) return
if (mode === 'single') {
onSelect(selectedMedia[0])
} else {
onSelect(selectedMedia)
}
handleClose()
}
function handleClose() {
selectedMedia = []
isOpen = false
onClose()
}
function handleCancel() {
handleClose()
}
// Computed properties
const canConfirm = $derived(selectedMedia.length > 0)
const selectionCount = $derived(selectedMedia.length)
const footerText = $derived(
mode === 'single'
? canConfirm
? '1 item selected'
: 'No item selected'
: `${selectionCount} item${selectionCount !== 1 ? 's' : ''} selected`
)
</script>
<Modal {isOpen} size="full" closeOnBackdrop={false} showCloseButton={false} on:close={handleClose}>
<div class="media-library-modal">
<!-- Header -->
<header class="modal-header">
<div class="header-content">
<h2 class="modal-title">{title}</h2>
<p class="modal-subtitle">
{#if fileType === 'image'}
Browse and select image files
{:else if fileType === 'video'}
Browse and select video files
{:else}
Browse and select media files
{/if}
</p>
</div>
<Button variant="ghost" iconOnly onclick={handleClose} aria-label="Close modal">
<svg
slot="icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 6L18 18M6 18L18 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</Button>
</header>
<!-- Media Browser -->
<div class="modal-body">
<MediaSelector
{mode}
{fileType}
{selectedIds}
on:select={(e) => handleMediaSelect(e.detail)}
bind:loading={isLoading}
/>
</div>
<!-- Footer -->
<footer class="modal-footer">
<div class="footer-info">
<span class="selection-count">{footerText}</span>
</div>
<div class="footer-actions">
<Button variant="ghost" onclick={handleCancel} disabled={isLoading}>Cancel</Button>
<Button variant="primary" onclick={handleConfirm} disabled={!canConfirm || isLoading}>
{confirmText}
</Button>
</div>
</footer>
</div>
</Modal>
<style lang="scss">
.media-library-modal {
display: flex;
flex-direction: column;
height: 100%;
min-height: 80vh;
max-height: 90vh;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: $unit-3x $unit-4x;
border-bottom: 1px solid $grey-80;
background-color: white;
flex-shrink: 0;
}
.header-content {
flex: 1;
}
.modal-title {
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 $unit-half 0;
color: $grey-10;
}
.modal-subtitle {
font-size: 0.875rem;
color: $grey-30;
margin: 0;
}
.modal-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: $unit-3x $unit-4x;
border-top: 1px solid $grey-80;
background-color: $grey-95;
flex-shrink: 0;
}
.footer-info {
flex: 1;
}
.selection-count {
font-size: 0.875rem;
color: $grey-30;
font-weight: 500;
}
.footer-actions {
display: flex;
gap: $unit-2x;
align-items: center;
}
// Responsive adjustments
@media (max-width: 768px) {
.modal-header {
padding: $unit-2x $unit-3x;
}
.modal-footer {
padding: $unit-2x $unit-3x;
flex-direction: column;
gap: $unit-2x;
align-items: stretch;
}
.footer-info {
text-align: center;
}
.footer-actions {
justify-content: center;
}
.modal-title {
font-size: 1.25rem;
}
}
</style>

View file

@ -0,0 +1,631 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
import Input from './Input.svelte'
import Button from './Button.svelte'
import LoadingSpinner from './LoadingSpinner.svelte'
import type { Media } from '@prisma/client'
interface Props {
mode: 'single' | 'multiple'
fileType?: 'image' | 'video' | 'all'
selectedIds?: number[]
loading?: boolean
}
let { mode, fileType = 'all', selectedIds = [], loading = $bindable(false) }: Props = $props()
const dispatch = createEventDispatcher<{
select: Media[]
}>()
// State
let media = $state<Media[]>([])
let selectedMedia = $state<Media[]>([])
let currentPage = $state(1)
let totalPages = $state(1)
let total = $state(0)
let searchQuery = $state('')
let filterType = $state<string>(fileType === 'all' ? 'all' : fileType)
let photographyFilter = $state<string>('all')
let searchTimeout: ReturnType<typeof setTimeout>
// Initialize selected media from IDs
$effect(() => {
if (selectedIds.length > 0 && media.length > 0) {
selectedMedia = media.filter((item) => selectedIds.includes(item.id))
dispatch('select', selectedMedia)
}
})
// Watch for search query changes with debounce
$effect(() => {
if (searchQuery !== undefined) {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
currentPage = 1
loadMedia()
}, 300)
}
})
// Watch for filter changes
$effect(() => {
if (filterType !== undefined) {
currentPage = 1
loadMedia()
}
})
// Watch for photography filter changes
$effect(() => {
if (photographyFilter !== undefined) {
currentPage = 1
loadMedia()
}
})
onMount(() => {
loadMedia()
})
async function loadMedia(page = 1) {
try {
loading = true
const auth = localStorage.getItem('admin_auth')
if (!auth) return
let url = `/api/media?page=${page}&limit=24`
if (filterType !== 'all') {
url += `&mimeType=${filterType}`
}
if (photographyFilter !== 'all') {
url += `&isPhotography=${photographyFilter}`
}
if (searchQuery) {
url += `&search=${encodeURIComponent(searchQuery)}`
}
const response = await fetch(url, {
headers: { Authorization: `Basic ${auth}` }
})
if (!response.ok) {
throw new Error('Failed to load media')
}
const data = await response.json()
if (page === 1) {
media = data.media
} else {
media = [...media, ...data.media]
}
currentPage = page
totalPages = data.pagination.totalPages
total = data.pagination.total
} catch (error) {
console.error('Error loading media:', error)
} finally {
loading = false
}
}
function handleMediaClick(item: Media) {
if (mode === 'single') {
selectedMedia = [item]
dispatch('select', selectedMedia)
} else {
const isSelected = selectedMedia.some((m) => m.id === item.id)
if (isSelected) {
selectedMedia = selectedMedia.filter((m) => m.id !== item.id)
} else {
selectedMedia = [...selectedMedia, item]
}
dispatch('select', selectedMedia)
}
}
function handleSelectAll() {
if (selectedMedia.length === media.length) {
selectedMedia = []
} else {
selectedMedia = [...media]
}
dispatch('select', selectedMedia)
}
function loadMore() {
if (currentPage < totalPages && !loading) {
loadMedia(currentPage + 1)
}
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
function isSelected(item: Media): boolean {
return selectedMedia.some((m) => m.id === item.id)
}
// Computed properties
const hasMore = $derived(currentPage < totalPages)
const showSelectAll = $derived(mode === 'multiple' && media.length > 0)
const allSelected = $derived(media.length > 0 && selectedMedia.length === media.length)
</script>
<div class="media-selector">
<!-- Search and Filter Controls -->
<div class="controls">
<div class="search-filters">
<Input type="search" placeholder="Search media files..." bind:value={searchQuery} />
<select bind:value={filterType} class="filter-select">
<option value="all">All Files</option>
<option value="image">Images</option>
<option value="video">Videos</option>
</select>
<select bind:value={photographyFilter} class="filter-select">
<option value="all">All Media</option>
<option value="true">Photography</option>
<option value="false">Non-Photography</option>
</select>
</div>
{#if showSelectAll}
<Button variant="ghost" onclick={handleSelectAll}>
{allSelected ? 'Clear All' : 'Select All'}
</Button>
{/if}
</div>
<!-- Results Info -->
{#if total > 0}
<div class="results-info">
<span class="total-count">{total} file{total !== 1 ? 's' : ''} found</span>
</div>
{/if}
<!-- Media Grid -->
<div class="media-grid-container">
{#if loading && media.length === 0}
<div class="loading-container">
<LoadingSpinner />
<p>Loading media...</p>
</div>
{:else if media.length === 0}
<div class="empty-state">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="3" y="5" width="18" height="14" rx="2" stroke="currentColor" stroke-width="2" />
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path d="M3 16l5-5 3 3 4-4 4 4" stroke="currentColor" stroke-width="2" fill="none" />
</svg>
<h3>No media found</h3>
<p>Try adjusting your search or upload some files</p>
</div>
{:else}
<div class="media-grid">
{#each media as item (item.id)}
<button
type="button"
class="media-item"
class:selected={isSelected(item)}
onclick={() => handleMediaClick(item)}
>
<!-- Thumbnail -->
<div class="media-thumbnail">
{#if item.mimeType?.startsWith('image/')}
<img
src={item.mimeType === 'image/svg+xml' ? item.url : item.thumbnailUrl || item.url}
alt={item.filename}
loading="lazy"
/>
{:else}
<div class="media-placeholder">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="3"
y="5"
width="18"
height="14"
rx="2"
stroke="currentColor"
stroke-width="2"
/>
<circle cx="8.5" cy="8.5" r=".5" fill="currentColor" />
<path
d="M3 16l5-5 3 3 4-4 4 4"
stroke="currentColor"
stroke-width="2"
fill="none"
/>
</svg>
</div>
{/if}
<!-- Selection Indicator -->
{#if mode === 'multiple'}
<div class="selection-checkbox">
<input type="checkbox" checked={isSelected(item)} readonly />
</div>
{/if}
<!-- Selected Overlay -->
{#if isSelected(item)}
<div class="selected-overlay">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9 12l2 2 4-4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
{/if}
</div>
<!-- Media Info -->
<div class="media-info">
<div class="media-filename" title={item.filename}>
{item.filename}
</div>
<div class="media-indicators">
{#if item.isPhotography}
<span class="indicator-pill photography" title="Photography">
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<polygon
points="12,2 15.09,8.26 22,9 17,14.74 18.18,21.02 12,17.77 5.82,21.02 7,14.74 2,9 8.91,8.26"
fill="currentColor"
/>
</svg>
Photo
</span>
{/if}
{#if item.altText}
<span class="indicator-pill alt-text" title="Alt text: {item.altText}">
Alt
</span>
{:else}
<span class="indicator-pill no-alt-text" title="No alt text"> No Alt </span>
{/if}
</div>
<div class="media-meta">
<span class="file-size">{formatFileSize(item.size)}</span>
{#if item.width && item.height}
<span class="dimensions">{item.width}×{item.height}</span>
{/if}
</div>
</div>
</button>
{/each}
</div>
<!-- Load More Button -->
{#if hasMore}
<div class="load-more-container">
<Button variant="ghost" onclick={loadMore} disabled={loading} class="load-more-button">
{#if loading}
<LoadingSpinner buttonSize="small" />
Loading...
{:else}
Load More
{/if}
</Button>
</div>
{/if}
{/if}
</div>
</div>
<style lang="scss">
.media-selector {
display: flex;
flex-direction: column;
height: 100%;
padding: $unit-3x $unit-4x;
}
.controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: $unit-2x;
margin-bottom: $unit-3x;
flex-shrink: 0;
}
.search-filters {
display: flex;
gap: $unit-2x;
flex: 1;
max-width: 600px;
}
.filter-select {
padding: $unit $unit-2x;
border: 1px solid $grey-80;
border-radius: $card-corner-radius;
background-color: white;
font-size: 0.875rem;
min-width: 120px;
&:focus {
outline: none;
border-color: $blue-60;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.results-info {
margin-bottom: $unit-2x;
flex-shrink: 0;
}
.total-count {
font-size: 0.875rem;
color: $grey-30;
}
.media-grid-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.loading-container,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: $unit-6x;
text-align: center;
color: $grey-40;
min-height: 300px;
svg {
margin-bottom: $unit-2x;
color: $grey-60;
}
h3 {
margin: 0 0 $unit 0;
font-size: 1.125rem;
font-weight: 600;
color: $grey-20;
}
p {
margin: 0;
font-size: 0.875rem;
}
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: $unit-2x;
padding-bottom: $unit-2x;
}
.media-item {
display: flex;
flex-direction: column;
background: white;
border: 2px solid $grey-90;
border-radius: $card-corner-radius;
padding: $unit;
cursor: pointer;
transition: all 0.2s ease;
text-align: left;
width: 100%;
&:hover {
border-color: $grey-70;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: $blue-60;
background-color: rgba(59, 130, 246, 0.05);
}
&:focus {
outline: none;
border-color: $blue-60;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
}
.media-thumbnail {
position: relative;
width: 100%;
height: 120px;
border-radius: calc($card-corner-radius - 2px);
overflow: hidden;
background-color: $grey-95;
margin-bottom: $unit;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.media-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: $grey-60;
background-color: $grey-95;
}
.selection-checkbox {
position: absolute;
top: $unit;
left: $unit;
z-index: 2;
input {
width: 18px;
height: 18px;
cursor: pointer;
}
}
.selected-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(59, 130, 246, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: $blue-60;
}
.media-info {
flex: 1;
}
.media-filename {
font-size: 0.875rem;
font-weight: 500;
color: $grey-10;
margin-bottom: $unit-half;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-indicators {
display: flex;
gap: $unit-half;
flex-wrap: wrap;
margin-bottom: $unit-half;
}
.media-meta {
display: flex;
gap: $unit;
font-size: 0.75rem;
color: $grey-40;
}
// Indicator pill styles
.indicator-pill {
display: inline-flex;
align-items: center;
gap: $unit-half;
padding: 2px $unit;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
line-height: 1;
svg {
width: 8px;
height: 8px;
flex-shrink: 0;
}
&.photography {
background-color: rgba(139, 92, 246, 0.1);
color: #7c3aed;
border: 1px solid rgba(139, 92, 246, 0.2);
svg {
fill: #7c3aed;
}
}
&.alt-text {
background-color: rgba(34, 197, 94, 0.1);
color: #16a34a;
border: 1px solid rgba(34, 197, 94, 0.2);
}
&.no-alt-text {
background-color: rgba(239, 68, 68, 0.1);
color: #dc2626;
border: 1px solid rgba(239, 68, 68, 0.2);
}
}
.load-more-container {
display: flex;
justify-content: center;
padding: $unit-3x 0;
}
// Responsive adjustments
@media (max-width: 768px) {
.media-selector {
padding: $unit-2x;
}
.controls {
flex-direction: column;
align-items: stretch;
gap: $unit-2x;
}
.search-filters {
max-width: none;
}
.media-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: $unit;
}
.media-thumbnail {
height: 100px;
}
}
</style>

View file

@ -0,0 +1,618 @@
<script lang="ts">
import Modal from './Modal.svelte'
import Button from './Button.svelte'
import LoadingSpinner from './LoadingSpinner.svelte'
interface Props {
isOpen: boolean
onClose: () => void
onUploadComplete: () => void
}
let { isOpen = $bindable(), onClose, onUploadComplete }: Props = $props()
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
// Reset state when modal opens/closes
$effect(() => {
if (!isOpen) {
files = []
dragActive = false
isUploading = false
uploadProgress = {}
uploadErrors = []
successCount = 0
}
})
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, close modal and refresh media list
if (successCount === files.length && uploadErrors.length === 0) {
setTimeout(() => {
onUploadComplete()
onClose()
}, 1500)
}
}
function clearAll() {
files = []
uploadProgress = {}
uploadErrors = []
successCount = 0
}
function handleClose() {
if (!isUploading) {
onClose()
}
}
</script>
<Modal bind:isOpen on:close={handleClose} size="large">
<div class="upload-modal-content">
<div class="modal-header">
<h2>Upload Media</h2>
</div>
<!-- Drop Zone -->
<div class="modal-inner-content">
<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>
</div>
<!-- File List -->
{#if files.length > 0}
<div class="file-list">
<div class="file-list-header">
<h3>Files to Upload</h3>
<div class="file-actions">
<Button
variant="secondary"
buttonSize="small"
onclick={clearAll}
disabled={isUploading}
>
Clear All
</Button>
<Button
variant="primary"
buttonSize="small"
onclick={uploadFiles}
disabled={isUploading || files.length === 0}
>
{#if isUploading}
<LoadingSpinner buttonSize="small" />
Uploading...
{:else}
Upload {files.length} File{files.length !== 1 ? 's' : ''}
{/if}
</Button>
</div>
</div>
<div class="files">
{#each files as file, index}
<div class="file-item">
<div class="file-preview">
{#if file.type.startsWith('image/')}
<img src={URL.createObjectURL(file)} alt={file.name} />
{:else}
<div class="file-icon">📄</div>
{/if}
</div>
<div class="file-info">
<div class="file-name">{file.name}</div>
<div class="file-size">{formatFileSize(file.size)}</div>
{#if uploadProgress[file.name]}
<div class="progress-bar">
<div class="progress-fill" style="width: {uploadProgress[file.name]}%"></div>
</div>
{/if}
</div>
{#if !isUploading}
<button
type="button"
class="remove-button"
onclick={() => removeFile(index)}
title="Remove file"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Upload Results -->
{#if successCount > 0 || uploadErrors.length > 0}
<div class="upload-results">
{#if successCount > 0}
<div class="success-message">
✅ Successfully uploaded {successCount} file{successCount !== 1 ? 's' : ''}
{#if successCount === files.length && uploadErrors.length === 0}
<br /><small>Closing modal...</small>
{/if}
</div>
{/if}
{#if uploadErrors.length > 0}
<div class="error-messages">
<h4>Upload Errors:</h4>
{#each uploadErrors as error}
<div class="error-item">{error}</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
</Modal>
<style lang="scss">
.upload-modal-content {
display: flex;
flex-direction: column;
max-height: 70vh;
overflow-y: auto;
}
.modal-header {
display: flex;
flex-direction: row;
padding: $unit-2x $unit-3x $unit $unit-3x;
h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: $grey-10;
}
}
.modal-inner-content {
padding: $unit $unit-3x $unit-3x;
}
.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;
&.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;
}
.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;
}
}
}
// Responsive adjustments
@media (max-width: 768px) {
.upload-modal-content {
max-height: 80vh;
}
.drop-zone {
padding: $unit-4x $unit-2x;
}
.file-item {
flex-direction: column;
align-items: flex-start;
gap: $unit-2x;
}
.file-list-header {
flex-direction: column;
align-items: flex-start;
gap: $unit-2x;
}
}
</style>

View file

@ -0,0 +1,329 @@
<script lang="ts">
import { onMount } from 'svelte'
import Input from './Input.svelte'
type Props = {
post: any
postType: 'post' | 'essay'
slug: string
excerpt: string
tags: string[]
tagInput: string
triggerElement: HTMLElement
onAddTag: () => void
onRemoveTag: (tag: string) => void
onDelete: () => void
}
let {
post,
postType,
slug = $bindable(),
excerpt = $bindable(),
tags = $bindable(),
tagInput = $bindable(),
triggerElement,
onAddTag,
onRemoveTag,
onDelete
}: Props = $props()
let popoverElement: HTMLDivElement
let portalTarget: HTMLElement
function updatePosition() {
if (!popoverElement || !triggerElement) return
const triggerRect = triggerElement.getBoundingClientRect()
const popoverRect = popoverElement.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Find the AdminPage container to align with its right edge
const adminPage =
document.querySelector('.admin-page') || document.querySelector('[data-admin-page]')
const adminPageRect = adminPage?.getBoundingClientRect()
// Position below the trigger button
let top = triggerRect.bottom + 8
// Align closer to the right edge of AdminPage, with some padding
let left: number
if (adminPageRect) {
// Position to align with AdminPage right edge minus padding
left = adminPageRect.right - popoverRect.width - 24
} else {
// Fallback to viewport-based positioning
left = triggerRect.right - popoverRect.width
}
// Ensure we don't go off-screen horizontally
if (left < 16) {
left = 16
} else if (left + popoverRect.width > viewportWidth - 16) {
left = viewportWidth - popoverRect.width - 16
}
// Check if popover would go off-screen vertically (both top and bottom)
if (top + popoverRect.height > viewportHeight - 16) {
// Try positioning above the trigger
const topAbove = triggerRect.top - popoverRect.height - 8
if (topAbove >= 16) {
top = topAbove
} else {
// If neither above nor below works, position with maximum available space
if (triggerRect.top > viewportHeight - triggerRect.bottom) {
// More space above - position at top of viewport with margin
top = 16
} else {
// More space below - position at bottom of viewport with margin
top = viewportHeight - popoverRect.height - 16
}
}
}
// Also check if positioning below would place us off the top (shouldn't happen but be safe)
if (top < 16) {
top = 16
}
popoverElement.style.position = 'fixed'
popoverElement.style.top = `${top}px`
popoverElement.style.left = `${left}px`
popoverElement.style.zIndex = '1000'
}
onMount(() => {
// Create portal target
portalTarget = document.createElement('div')
portalTarget.style.position = 'absolute'
portalTarget.style.top = '0'
portalTarget.style.left = '0'
portalTarget.style.pointerEvents = 'none'
document.body.appendChild(portalTarget)
// Initial positioning
updatePosition()
// Update position on scroll/resize
const handleUpdate = () => updatePosition()
window.addEventListener('scroll', handleUpdate, true)
window.addEventListener('resize', handleUpdate)
return () => {
window.removeEventListener('scroll', handleUpdate, true)
window.removeEventListener('resize', handleUpdate)
if (portalTarget) {
document.body.removeChild(portalTarget)
}
}
})
$effect(() => {
if (popoverElement && portalTarget && triggerElement) {
portalTarget.appendChild(popoverElement)
portalTarget.style.pointerEvents = 'auto'
updatePosition()
}
})
</script>
<div class="metadata-popover" bind:this={popoverElement}>
<div class="popover-content">
<h3>Post Settings</h3>
<Input label="Slug" bind:value={slug} placeholder="post-slug" />
{#if postType === 'essay'}
<Input
type="textarea"
label="Excerpt"
bind:value={excerpt}
rows={3}
placeholder="Brief description..."
/>
{/if}
<div class="tags-section">
<Input
label="Tags"
bind:value={tagInput}
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
placeholder="Add tags..."
/>
<button type="button" onclick={onAddTag} class="add-tag-btn">Add</button>
{#if tags.length > 0}
<div class="tags">
{#each tags as tag}
<span class="tag">
{tag}
<button onclick={() => onRemoveTag(tag)}>×</button>
</span>
{/each}
</div>
{/if}
</div>
<div class="metadata">
<p>Created: {new Date(post.createdAt).toLocaleString()}</p>
<p>Updated: {new Date(post.updatedAt).toLocaleString()}</p>
{#if post.publishedAt}
<p>Published: {new Date(post.publishedAt).toLocaleString()}</p>
{/if}
</div>
</div>
<div class="popover-footer">
<button class="btn btn-danger" onclick={onDelete}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M4 4L12 12M4 12L12 4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Delete Post
</button>
</div>
</div>
<style lang="scss">
@import '$styles/variables.scss';
.metadata-popover {
background: white;
border: 1px solid $grey-80;
border-radius: $card-corner-radius;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
min-width: 420px;
max-width: 480px;
display: flex;
flex-direction: column;
pointer-events: auto;
}
.popover-content {
padding: $unit-3x;
display: flex;
flex-direction: column;
gap: $unit-3x;
h3 {
font-size: 1.125rem;
font-weight: 600;
margin: 0;
color: $grey-10;
}
}
.popover-footer {
padding: $unit-3x;
border-top: 1px solid $grey-90;
display: flex;
justify-content: flex-start;
}
.tags-section {
display: flex;
flex-direction: column;
gap: $unit;
}
.add-tag-btn {
align-self: flex-start;
margin-top: $unit-half;
padding: $unit $unit-2x;
background: $grey-10;
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: $grey-20;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: $unit;
margin-top: $unit;
}
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px $unit-2x;
background: $grey-80;
border-radius: 20px;
font-size: 0.75rem;
button {
background: none;
border: none;
color: $grey-40;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
&:hover {
color: $grey-10;
}
}
}
.metadata {
font-size: 0.75rem;
color: $grey-40;
p {
margin: $unit-half 0;
}
}
.btn {
padding: $unit-2x $unit-3x;
border: none;
border-radius: 50px;
font-size: 0.925rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: $unit;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.btn-small {
padding: $unit $unit-2x;
font-size: 0.875rem;
}
&.btn-danger {
background-color: #dc2626;
color: white;
&:hover:not(:disabled) {
background-color: #b91c1c;
}
}
}
@include breakpoint('phone') {
.metadata-popover {
min-width: 280px;
max-width: calc(100vw - 2rem);
}
}
</style>

View file

@ -0,0 +1,139 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
import { fade } from 'svelte/transition'
import Button from './Button.svelte'
export let isOpen = false
export let size: 'small' | 'medium' | 'large' | 'full' = 'medium'
export let closeOnBackdrop = true
export let closeOnEscape = true
export let showCloseButton = true
const dispatch = createEventDispatcher()
function handleClose() {
isOpen = false
dispatch('close')
}
function handleBackdropClick() {
if (closeOnBackdrop) {
handleClose()
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && closeOnEscape) {
handleClose()
}
}
onMount(() => {
document.addEventListener('keydown', handleKeydown)
return () => {
document.removeEventListener('keydown', handleKeydown)
}
})
$: modalClass = `modal-${size}`
</script>
{#if isOpen}
<div class="modal-backdrop" on:click={handleBackdropClick} transition:fade={{ duration: 200 }}>
<div
class="modal {modalClass}"
on:click|stopPropagation
transition:fade={{ duration: 200, delay: 50 }}
>
{#if showCloseButton}
<Button
variant="ghost"
iconOnly
onclick={handleClose}
aria-label="Close modal"
class="close-button"
>
<svg
slot="icon"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 6L18 18M6 18L18 6"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</Button>
{/if}
<div class="modal-content">
<slot />
</div>
</div>
</div>
{/if}
<style lang="scss">
.modal-backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: $unit-2x;
}
.modal {
background-color: white;
border-radius: $card-corner-radius;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
position: relative;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
&.modal-small {
width: 100%;
max-width: 400px;
}
&.modal-medium {
width: 100%;
max-width: 600px;
}
&.modal-large {
width: 100%;
max-width: 800px;
}
&.modal-full {
width: 100%;
max-width: 1200px;
height: 90vh;
}
}
:global(.close-button) {
position: absolute !important;
top: $unit-2x;
right: $unit-2x;
z-index: 1;
}
.modal-content {
overflow-y: auto;
flex: 1;
}
</style>

View file

@ -0,0 +1,348 @@
<script lang="ts">
import { goto } from '$app/navigation'
import AdminPage from './AdminPage.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte'
import ImageUploader from './ImageUploader.svelte'
import Editor from './Editor.svelte'
import type { JSONContent } from '@tiptap/core'
import type { Media } from '@prisma/client'
interface Props {
postId?: number
initialData?: {
title?: string
content?: JSONContent
featuredImage?: string
status: 'draft' | 'published'
tags?: string[]
}
mode: 'create' | 'edit'
}
let { postId, initialData, mode }: Props = $props()
// State
let isSaving = $state(false)
let error = $state('')
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
// Form data
let title = $state(initialData?.title || '')
let content = $state<JSONContent>(initialData?.content || { type: 'doc', content: [] })
let featuredImage = $state<Media | null>(null)
let tags = $state(initialData?.tags?.join(', ') || '')
// Editor ref
let editorRef: any
// Initialize featured image if editing
$effect(() => {
if (initialData?.featuredImage && mode === 'edit') {
// Create a minimal Media object for display
featuredImage = {
id: -1,
filename: 'photo.jpg',
originalName: 'photo.jpg',
mimeType: 'image/jpeg',
size: 0,
url: initialData.featuredImage,
thumbnailUrl: initialData.featuredImage,
width: null,
height: null,
altText: null,
description: null,
usedIn: [],
createdAt: new Date(),
updatedAt: new Date()
}
}
})
function handleFeaturedImageUpload(media: Media) {
featuredImage = media
// If no title is set, use the media filename as a starting point
if (!title.trim() && media.originalName) {
title = media.originalName.replace(/\.[^/.]+$/, '') // Remove file extension
}
}
function createSlug(title: string): string {
return title
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
}
async function handleSave() {
// Validate required fields
if (!featuredImage) {
error = 'Please upload a photo for this post'
return
}
if (!title.trim()) {
error = 'Please enter a title for this post'
return
}
try {
isSaving = true
error = ''
// Get editor content
let editorContent = content
if (editorRef) {
const editorData = await editorRef.save()
if (editorData) {
editorContent = editorData
}
}
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
return
}
// Generate slug from title
const slug = createSlug(title)
const payload = {
title: title.trim(),
slug,
type: 'photo',
status,
content: editorContent,
featuredImage: featuredImage.url,
tags: tags
? tags
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
: [],
}
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
const method = mode === 'edit' ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
Authorization: `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
})
if (!response.ok) {
throw new Error(`Failed to ${mode === 'edit' ? 'update' : 'create'} photo post`)
}
const savedPost = await response.json()
// Redirect to posts list or edit page
if (mode === 'create') {
goto(`/admin/posts/${savedPost.id}/edit`)
} else {
goto('/admin/posts')
}
} catch (err) {
error = `Failed to ${mode === 'edit' ? 'update' : 'create'} photo post`
console.error(err)
} finally {
isSaving = false
}
}
async function handlePublish() {
status = 'published'
await handleSave()
}
async function handleDraft() {
status = 'draft'
await handleSave()
}
</script>
<AdminPage>
<header slot="header">
<div class="header-content">
<h1>{mode === 'edit' ? 'Edit Photo Post' : 'New Photo Post'}</h1>
<p class="subtitle">Share a photo with a caption and description</p>
</div>
<div class="header-actions">
{#if !isSaving}
<Button variant="ghost" onclick={() => goto('/admin/posts')}>Cancel</Button>
<Button
variant="secondary"
onclick={handleDraft}
disabled={!featuredImage || !title.trim()}
>
Save Draft
</Button>
<Button
variant="primary"
onclick={handlePublish}
disabled={!featuredImage || !title.trim()}
>
{isSaving ? 'Publishing...' : 'Publish'}
</Button>
{/if}
</div>
</header>
<div class="form-container">
{#if error}
<div class="error-message">{error}</div>
{/if}
<div class="form-content">
<!-- Featured Photo Upload -->
<div class="form-section">
<ImageUploader
label="Photo"
value={featuredImage}
onUpload={handleFeaturedImageUpload}
placeholder="Upload the main photo for this post"
helpText="This photo will be displayed prominently in the post"
showBrowseLibrary={true}
required={true}
/>
</div>
<!-- Title -->
<div class="form-section">
<Input
type="text"
label="Title"
bind:value={title}
placeholder="Enter a title for this photo post"
required={true}
fullWidth={true}
/>
</div>
<!-- Caption/Content -->
<div class="form-section">
<label class="editor-label">Caption & Description</label>
<p class="editor-help">Add a caption or tell the story behind this photo</p>
<div class="editor-container">
<Editor
bind:this={editorRef}
bind:data={content}
placeholder="Write a caption or description for this photo..."
minHeight={200}
autofocus={false}
class="photo-post-editor"
/>
</div>
</div>
<!-- Tags -->
<div class="form-section">
<Input
type="text"
label="Tags (Optional)"
bind:value={tags}
placeholder="nature, photography, travel (comma-separated)"
helpText="Add tags to help categorize this photo post"
fullWidth={true}
/>
</div>
</div>
</div>
</AdminPage>
<style lang="scss">
.header-content {
h1 {
font-size: 1.75rem;
font-weight: 700;
margin: 0 0 $unit-half 0;
color: $grey-10;
}
.subtitle {
font-size: 0.875rem;
color: $grey-40;
margin: 0;
}
}
.header-actions {
display: flex;
gap: $unit-2x;
align-items: center;
}
.form-container {
max-width: 800px;
margin: 0 auto;
padding: $unit-4x;
@include breakpoint('phone') {
padding: $unit-3x;
}
}
.error-message {
background-color: #fee;
color: #d33;
padding: $unit-3x;
border-radius: $unit;
margin-bottom: $unit-4x;
text-align: center;
}
.form-content {
background: white;
border-radius: $unit-2x;
padding: $unit-6x;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
@include breakpoint('phone') {
padding: $unit-4x;
}
}
.form-section {
margin-bottom: $unit-6x;
&:last-child {
margin-bottom: 0;
}
}
.editor-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: $grey-20;
margin-bottom: $unit-half;
}
.editor-help {
font-size: 0.8rem;
color: $grey-40;
margin: 0 0 $unit-2x 0;
line-height: 1.4;
}
.editor-container {
border: 1px solid $grey-80;
border-radius: $unit;
overflow: hidden;
:global(.photo-post-editor) {
min-height: 200px;
}
}
</style>

View file

@ -0,0 +1,202 @@
<script lang="ts">
import { goto } from '$app/navigation'
import UniverseComposer from './UniverseComposer.svelte'
import Button from './Button.svelte'
import ChevronDownIcon from '$icons/chevron-down.svg?raw'
let isOpen = $state(false)
let buttonRef: HTMLElement
let showComposer = $state(false)
let selectedType = $state<'post' | 'essay'>('post')
const postTypes = [
{ value: 'essay', label: 'Essay' },
{ value: 'post', label: 'Post' }
]
function handleSelection(type: string) {
isOpen = false
if (type === 'essay') {
// Essays go straight to the full page
goto('/admin/posts/new?type=essay')
} else if (type === 'post') {
// Posts open in modal
selectedType = 'post'
showComposer = true
}
}
function handleComposerClose() {
showComposer = false
}
function handleComposerSaved() {
showComposer = false
// Reload posts - in a real app, you'd emit an event to parent
window.location.reload()
}
function handleClickOutside(event: MouseEvent) {
if (!buttonRef?.contains(event.target as Node)) {
isOpen = false
}
}
$effect(() => {
if (isOpen) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
}
})
</script>
<div class="dropdown-container">
<Button
bind:this={buttonRef}
variant="primary"
buttonSize="large"
onclick={(e) => {
e.stopPropagation()
isOpen = !isOpen
}}
iconPosition="right"
>
New Post
{#snippet icon()}
<div class="chevron">
{@html ChevronDownIcon}
</div>
{/snippet}
</Button>
{#if isOpen}
<ul class="dropdown-menu">
{#each postTypes as type}
<li class="dropdown-item" onclick={() => handleSelection(type.value)}>
<div class="dropdown-icon">
{#if type.value === 'essay'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M3 5C3 3.89543 3.89543 3 5 3H11L17 9V15C17 16.1046 16.1046 17 15 17H5C3.89543 17 3 16.1046 3 15V5Z"
stroke="currentColor"
stroke-width="1.5"
/>
<path d="M11 3V9H17" stroke="currentColor" stroke-width="1.5" />
<path
d="M7 13H13"
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 === 'post'}
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M4 3C2.89543 3 2 3.89543 2 5V11C2 12.1046 2.89543 13 4 13H6L8 16V13H13C14.1046 13 15 12.1046 15 11V5C15 3.89543 14.1046 3 13 3H4Z"
stroke="currentColor"
stroke-width="1.5"
/>
<path d="M5 7H12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<path d="M5 9H10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
{/if}
</div>
<span class="dropdown-label">{type.label}</span>
</li>
{/each}
</ul>
{/if}
</div>
<UniverseComposer
bind:isOpen={showComposer}
initialPostType={selectedType}
on:close={handleComposerClose}
on:saved={handleComposerSaved}
on:switch-to-essay
/>
<style lang="scss">
@import '$styles/variables.scss';
.dropdown-container {
position: relative;
}
.chevron {
transition: transform 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.dropdown-menu {
position: absolute;
top: calc(100% + $unit);
right: 0;
background: white;
border: 1px solid $grey-85;
border-radius: $unit-2x;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
min-width: 140px;
z-index: 100;
overflow: hidden;
margin: 0;
padding: 0;
list-style: none;
}
.dropdown-item {
display: flex;
align-items: center;
gap: $unit;
padding: $unit-2x $unit-3x;
cursor: pointer;
transition: background-color 0.2s ease;
border: none;
background: none;
width: 100%;
text-align: left;
&:hover {
background-color: $grey-95;
}
&:first-child {
border-radius: $unit-2x $unit-2x 0 0;
}
&:last-child {
border-radius: 0 0 $unit-2x $unit-2x;
}
&:only-child {
border-radius: $unit-2x;
}
}
.dropdown-icon {
color: $grey-40;
display: flex;
align-items: center;
flex-shrink: 0;
svg {
width: 20px;
height: 20px;
}
}
.dropdown-label {
font-size: 0.925rem;
font-weight: 500;
color: $grey-10;
}
</style>

View file

@ -0,0 +1,183 @@
<script lang="ts">
import { goto } from '$app/navigation'
import AdminByline from './AdminByline.svelte'
interface Post {
id: number
slug: string
postType: string
title: string | null
content: any // JSON content
excerpt: string | null
status: string
tags: string[] | null
linkUrl: string | null
linkDescription: string | null
featuredImage: string | null
publishedAt: string | null
createdAt: string
updatedAt: string
}
interface Props {
post: Post
}
let { post }: Props = $props()
const postTypeLabels: Record<string, string> = {
post: 'Post',
essay: 'Essay',
// Legacy types for backward compatibility
blog: 'Essay',
microblog: 'Post',
link: 'Post',
photo: 'Post',
album: 'Album'
}
function handlePostClick() {
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} ${diffDays === 1 ? 'day' : 'days'} ago`
} else {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined
})
}
}
</script>
<article class="post-item" onclick={handlePostClick}>
{#if post.title}
<h3 class="post-title">{post.title}</h3>
{/if}
<div class="post-content">
{#if post.linkUrl}
<p class="post-link-url">{post.linkUrl}</p>
{/if}
<p class="post-preview">{getPostSnippet(post)}</p>
</div>
<AdminByline
sections={[
postTypeLabels[post.postType] || post.postType,
post.status === 'published' ? 'Published' : 'Draft',
post.status === 'published' && post.publishedAt
? `published ${formatDate(post.publishedAt)}`
: `created ${formatDate(post.createdAt)}`
]}
/>
</article>
<style lang="scss">
.post-item {
background: transparent;
border: none;
border-radius: $unit-2x;
padding: $unit-2x;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
gap: $unit-2x;
&:hover {
background: $grey-95;
}
}
.post-title {
font-size: 1rem;
font-weight: 600;
margin: 0;
color: $grey-10;
line-height: 1.4;
}
.post-content {
display: flex;
flex-direction: column;
gap: $unit;
}
.post-link-url {
margin: 0;
font-size: 0.875rem;
color: $blue-60;
word-break: break-all;
}
.post-preview {
margin: 0;
font-size: 0.925rem;
line-height: 1.5;
color: $grey-30;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
// Responsive adjustments
@media (max-width: 768px) {
.post-item {
padding: $unit-2x;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show more