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:
commit
a31dcf8a0e
293 changed files with 54108 additions and 6523 deletions
16
.env.example
Normal file
16
.env.example
Normal 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
8
.gitignore
vendored
|
|
@ -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
39
.storybook/main.ts
Normal 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
42
.storybook/preview.ts
Normal 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
|
||||
|
|
@ -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
179
LOCAL_SETUP.md
Normal 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
841
PRD-cms-functionality.md
Normal 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
610
PRD-enhanced-tag-system.md
Normal 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
576
PRD-media-library.md
Normal 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.
|
||||
397
PRD-storybook-integration.md
Normal file
397
PRD-storybook-integration.md
Normal 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.
|
||||
|
|
@ -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
14343
package-lock.json
generated
File diff suppressed because it is too large
Load diff
70
package.json
70
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
149
prisma/migrations/20250527040429_initial_setup/migration.sql
Normal file
149
prisma/migrations/20250527040429_initial_setup/migration.sql
Normal 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;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Project" ADD COLUMN "backgroundColor" VARCHAR(50),
|
||||
ADD COLUMN "highlightColor" VARCHAR(50);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Project" ADD COLUMN "logoUrl" VARCHAR(500);
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `technologies` on the `Project` table. All the data in this column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" DROP COLUMN "technologies";
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Media" ALTER COLUMN "updatedAt" DROP DEFAULT;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Post" ADD COLUMN "attachments" JSONB;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Album" ADD COLUMN "isPhotography" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Media" ADD COLUMN "isPhotography" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Media" ADD COLUMN "exifData" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Project" ADD COLUMN "password" VARCHAR(255),
|
||||
ADD COLUMN "projectType" VARCHAR(50) NOT NULL DEFAULT 'work';
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Post" DROP COLUMN "excerpt";
|
||||
24
prisma/migrations/add_media_usage_tracking/migration.sql
Normal file
24
prisma/migrations/add_media_usage_tracking/migration.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "MediaUsage" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"mediaId" INTEGER NOT NULL,
|
||||
"contentType" VARCHAR(50) NOT NULL,
|
||||
"contentId" INTEGER NOT NULL,
|
||||
"fieldName" VARCHAR(100) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MediaUsage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MediaUsage_mediaId_idx" ON "MediaUsage"("mediaId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MediaUsage_contentType_contentId_idx" ON "MediaUsage"("contentType", "contentId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MediaUsage_mediaId_contentType_contentId_fieldName_key" ON "MediaUsage"("mediaId", "contentType", "contentId", "fieldName");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MediaUsage" ADD CONSTRAINT "MediaUsage_mediaId_fkey" FOREIGN KEY ("mediaId") REFERENCES "Media"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
166
prisma/schema.prisma
Normal 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
376
prisma/seed.ts
Normal 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
39
scripts/setup-local.sh
Executable 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"
|
||||
3
src/assets/icons/chevron-down.svg
Normal file
3
src/assets/icons/chevron-down.svg
Normal 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 |
6
src/assets/icons/dashboard.svg
Normal file
6
src/assets/icons/dashboard.svg
Normal 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 |
3
src/assets/icons/metadata.svg
Normal file
3
src/assets/icons/metadata.svg
Normal 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 |
1
src/assets/icons/refresh.svg
Normal file
1
src/assets/icons/refresh.svg
Normal 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 |
|
|
@ -3,51 +3,62 @@
|
|||
|
||||
// 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;
|
||||
}
|
||||
50% {
|
||||
50% {
|
||||
height: 6px;
|
||||
y: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes masonryRect2 {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
height: 6px;
|
||||
y: 2px;
|
||||
}
|
||||
50% {
|
||||
50% {
|
||||
height: 10px;
|
||||
y: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes masonryRect3 {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
height: 4px;
|
||||
y: 14px;
|
||||
}
|
||||
50% {
|
||||
50% {
|
||||
height: 8px;
|
||||
y: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes masonryRect4 {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
height: 8px;
|
||||
y: 10px;
|
||||
}
|
||||
50% {
|
||||
50% {
|
||||
height: 4px;
|
||||
y: 14px;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
48
src/lib/admin-auth.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
21
src/lib/components/AvatarSimple.svelte
Normal file
21
src/lib/components/AvatarSimple.svelte
Normal 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>
|
||||
364
src/lib/components/DynamicPostContent.svelte
Normal file
364
src/lib/components/DynamicPostContent.svelte
Normal 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>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
|
||||
const socialLinks = [
|
||||
{ name: 'Bluesky', url: 'https://bsky.app/profile/jedmund.com' },
|
||||
{ name: 'Mastodon', url: 'https://fireplace.cafe/@jedmund' },
|
||||
|
|
@ -71,4 +71,4 @@
|
|||
color: $grey-40;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
let ticking = false
|
||||
|
||||
const updateScroll = () => {
|
||||
scrollY = window.scrollY
|
||||
|
||||
// Add hysteresis to prevent flickering
|
||||
if (!hasScrolled && scrollY > 30) {
|
||||
hasScrolled = true
|
||||
} else if (hasScrolled && scrollY < 20) {
|
||||
hasScrolled = false
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,71 +1,245 @@
|
|||
<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;
|
||||
|
||||
|
||||
@include breakpoint('phone') {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit;
|
||||
font-size: 0.875rem;
|
||||
padding: $unit $unit-2x;
|
||||
border-radius: $unit-2x;
|
||||
margin-top: $unit-2x;
|
||||
|
||||
&.list-only {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
&.password-protected {
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { fade, scale } from 'svelte/transition'
|
||||
|
||||
|
||||
let {
|
||||
images = [],
|
||||
selectedIndex = $bindable(0),
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
// Restore scroll when lightbox closes
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
|
|
@ -65,7 +65,7 @@
|
|||
|
||||
onMount(() => {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
|
|
@ -73,8 +73,8 @@
|
|||
</script>
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="lightbox-backdrop"
|
||||
<div
|
||||
class="lightbox-backdrop"
|
||||
onclick={handleBackgroundClick}
|
||||
transition:fade={{ duration: 200 }}
|
||||
role="button"
|
||||
|
|
@ -82,19 +82,19 @@
|
|||
>
|
||||
<div class="lightbox-content" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="lightbox-image-container">
|
||||
<img
|
||||
src={images[selectedIndex]}
|
||||
<img
|
||||
src={images[selectedIndex]}
|
||||
alt="{alt} {selectedIndex + 1}"
|
||||
transition:scale={{ duration: 200, start: 0.9 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{#if images.length > 1}
|
||||
<div class="lightbox-thumbnails">
|
||||
<div class="thumbnails-inner">
|
||||
{#each images as image, index}
|
||||
<button
|
||||
class="lightbox-thumbnail"
|
||||
<button
|
||||
class="lightbox-thumbnail"
|
||||
class:active={index === selectedIndex}
|
||||
onclick={() => selectImage(index)}
|
||||
aria-label="View image {index + 1}"
|
||||
|
|
@ -106,10 +106,21 @@
|
|||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
|
|
@ -146,7 +157,7 @@
|
|||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding-bottom: 120px; // Space for thumbnails
|
||||
|
||||
|
||||
img {
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
|
|
@ -171,7 +182,7 @@
|
|||
overflow-x: auto;
|
||||
max-width: 90vw;
|
||||
padding: $unit $unit-2x; // Add vertical padding to prevent clipping
|
||||
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -190,7 +201,7 @@
|
|||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
|
@ -201,7 +212,7 @@
|
|||
pointer-events: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
|
@ -212,24 +223,24 @@
|
|||
pointer-events: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
|
||||
|
||||
&::before {
|
||||
border-color: $red-60;
|
||||
}
|
||||
|
||||
|
||||
&::after {
|
||||
border-color: $grey-00; // Black inner border
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
|
@ -257,10 +268,10 @@
|
|||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
favicon?: string
|
||||
siteName?: string
|
||||
} | null>(null)
|
||||
|
||||
|
||||
let loading = $state(true)
|
||||
let error = $state(false)
|
||||
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMount(async () => {
|
||||
// If link is just a string URL, fetch metadata
|
||||
if (typeof link === 'string') {
|
||||
|
|
@ -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 {
|
||||
|
|
@ -124,7 +123,7 @@
|
|||
// Loading state
|
||||
&.loading {
|
||||
cursor: default;
|
||||
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-80;
|
||||
}
|
||||
|
|
@ -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% {
|
||||
|
|
|
|||
|
|
@ -1,84 +1,37 @@
|
|||
<script lang="ts">
|
||||
import PhotoItem from '$components/PhotoItem.svelte'
|
||||
import PhotoLightbox from '$components/PhotoLightbox.svelte'
|
||||
import type { PhotoItem as PhotoItemType, Photo } from '$lib/types/photos'
|
||||
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
|
||||
|
||||
const { photoItems }: { photoItems: PhotoItemType[] } = $props()
|
||||
|
||||
let lightboxPhoto: Photo | null = $state(null)
|
||||
let lightboxAlbumPhotos: Photo[] = $state([])
|
||||
let lightboxIndex = $state(0)
|
||||
|
||||
function openLightbox(photo: Photo, albumPhotos?: Photo[]) {
|
||||
if (albumPhotos && albumPhotos.length > 0) {
|
||||
// For albums, start with the first photo, not the cover photo
|
||||
lightboxAlbumPhotos = albumPhotos
|
||||
lightboxIndex = 0
|
||||
lightboxPhoto = albumPhotos[0]
|
||||
} else {
|
||||
// For individual photos
|
||||
lightboxPhoto = photo
|
||||
lightboxAlbumPhotos = []
|
||||
lightboxIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
function closeLightbox() {
|
||||
lightboxPhoto = null
|
||||
lightboxAlbumPhotos = []
|
||||
lightboxIndex = 0
|
||||
}
|
||||
|
||||
function navigateLightbox(direction: 'prev' | 'next') {
|
||||
if (lightboxAlbumPhotos.length === 0) return
|
||||
|
||||
if (direction === 'prev') {
|
||||
lightboxIndex = lightboxIndex > 0 ? lightboxIndex - 1 : lightboxAlbumPhotos.length - 1
|
||||
} else {
|
||||
lightboxIndex = lightboxIndex < lightboxAlbumPhotos.length - 1 ? lightboxIndex + 1 : 0
|
||||
}
|
||||
|
||||
lightboxPhoto = lightboxAlbumPhotos[lightboxIndex]
|
||||
}
|
||||
const {
|
||||
photoItems,
|
||||
albumSlug
|
||||
}: {
|
||||
photoItems: PhotoItemType[]
|
||||
albumSlug?: string
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<div class="photo-grid-container">
|
||||
<div class="photo-grid">
|
||||
{#each photoItems as item}
|
||||
<PhotoItem {item} onPhotoClick={openLightbox} />
|
||||
<PhotoItem {item} {albumSlug} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if lightboxPhoto}
|
||||
<PhotoLightbox
|
||||
photo={lightboxPhoto}
|
||||
albumPhotos={lightboxAlbumPhotos}
|
||||
currentIndex={lightboxIndex}
|
||||
onClose={closeLightbox}
|
||||
onNavigate={navigateLightbox}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.photo-grid-container {
|
||||
width: 100%;
|
||||
padding: 0 $unit-2x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x $unit;
|
||||
}
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
columns: 3;
|
||||
column-gap: $unit-2x;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
column-gap: $unit-3x;
|
||||
margin: 0;
|
||||
|
||||
@include breakpoint('tablet') {
|
||||
columns: 2;
|
||||
column-gap: $unit;
|
||||
column-gap: $unit-2x;
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,32 @@
|
|||
<script lang="ts">
|
||||
import type { PhotoItem, Photo, PhotoAlbum } from '$lib/types/photos'
|
||||
import { isAlbum } from '$lib/types/photos'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
const {
|
||||
item,
|
||||
onPhotoClick
|
||||
}: {
|
||||
const {
|
||||
item,
|
||||
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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -38,8 +46,8 @@
|
|||
<div class="stack-photo stack-back"></div>
|
||||
<div class="stack-photo stack-middle"></div>
|
||||
<div class="stack-photo stack-front">
|
||||
<img
|
||||
src={photo.src}
|
||||
<img
|
||||
src={photo.src}
|
||||
alt={photo.alt}
|
||||
loading="lazy"
|
||||
draggable="false"
|
||||
|
|
@ -60,8 +68,8 @@
|
|||
{:else}
|
||||
<!-- Single photo -->
|
||||
<div class="single-photo">
|
||||
<img
|
||||
src={photo.src}
|
||||
<img
|
||||
src={photo.src}
|
||||
alt={photo.alt}
|
||||
loading="lazy"
|
||||
draggable="false"
|
||||
|
|
@ -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);
|
||||
|
|
@ -133,7 +143,7 @@
|
|||
|
||||
.stack-photo {
|
||||
border-radius: $corner-radius;
|
||||
|
||||
|
||||
&.stack-back {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
|
|
@ -144,7 +154,7 @@
|
|||
z-index: 1;
|
||||
transform: rotate(2deg);
|
||||
}
|
||||
|
||||
|
||||
&.stack-middle {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
|
|
@ -155,11 +165,11 @@
|
|||
z-index: 2;
|
||||
transform: rotate(-1deg);
|
||||
}
|
||||
|
||||
|
||||
&.stack-front {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
|
@ -209,7 +219,7 @@
|
|||
.stack-back {
|
||||
transform: rotate(3deg) translateY(-1px);
|
||||
}
|
||||
|
||||
|
||||
.stack-middle {
|
||||
transform: rotate(-1.5deg) translateY(-0.5px);
|
||||
}
|
||||
|
|
@ -226,7 +236,7 @@
|
|||
background-size: 200% 200%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: $corner-radius;
|
||||
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
|
@ -252,4 +262,4 @@
|
|||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
<script lang="ts">
|
||||
import type { Photo } from '$lib/types/photos'
|
||||
|
||||
const {
|
||||
photo,
|
||||
albumPhotos = [],
|
||||
currentIndex = 0,
|
||||
onClose,
|
||||
onNavigate
|
||||
}: {
|
||||
const {
|
||||
photo,
|
||||
albumPhotos = [],
|
||||
currentIndex = 0,
|
||||
onClose,
|
||||
onNavigate
|
||||
}: {
|
||||
photo: Photo
|
||||
albumPhotos?: Photo[]
|
||||
currentIndex?: number
|
||||
onClose: () => void
|
||||
onNavigate: (direction: 'prev' | 'next') => void
|
||||
onNavigate: (direction: 'prev' | 'next') => void
|
||||
} = $props()
|
||||
|
||||
let imageLoaded = $state(false)
|
||||
|
|
@ -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>
|
||||
|
|
@ -163,7 +175,7 @@
|
|||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: $unit-2x;
|
||||
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit;
|
||||
}
|
||||
|
|
@ -175,7 +187,7 @@
|
|||
max-height: 90vh;
|
||||
display: flex;
|
||||
gap: $unit-3x;
|
||||
|
||||
|
||||
@include breakpoint('phone') {
|
||||
flex-direction: column;
|
||||
max-width: 95vw;
|
||||
|
|
@ -200,7 +212,7 @@
|
|||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
|
||||
@include breakpoint('phone') {
|
||||
top: -$unit-4x;
|
||||
right: -$unit;
|
||||
|
|
@ -231,7 +243,7 @@
|
|||
&.nav-next {
|
||||
right: -$unit-6x;
|
||||
}
|
||||
|
||||
|
||||
@include breakpoint('phone') {
|
||||
&.nav-prev {
|
||||
left: $unit;
|
||||
|
|
@ -249,7 +261,7 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
|
||||
img {
|
||||
max-width: 70vw;
|
||||
max-height: 80vh;
|
||||
|
|
@ -257,11 +269,11 @@
|
|||
border-radius: $corner-radius;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
|
||||
&.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@include breakpoint('phone') {
|
||||
max-width: 95vw;
|
||||
max-height: 60vh;
|
||||
|
|
@ -274,7 +286,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
|
@ -301,11 +313,11 @@
|
|||
backdrop-filter: blur(10px);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease 0.1s; // Slight delay to sync with image
|
||||
|
||||
|
||||
&.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@include breakpoint('phone') {
|
||||
width: 100%;
|
||||
max-height: 30vh;
|
||||
|
|
@ -341,13 +353,13 @@
|
|||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
|
||||
|
||||
dt {
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
margin-right: $unit-2x;
|
||||
}
|
||||
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
|
|
@ -361,4 +373,4 @@
|
|||
opacity: 0.7;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
281
src/lib/components/ProjectContent.svelte
Normal file
281
src/lib/components/ProjectContent.svelte
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
<script lang="ts">
|
||||
import type { Project } from '$lib/types/project'
|
||||
|
||||
interface Props {
|
||||
project: Project
|
||||
}
|
||||
|
||||
let { project }: Props = $props()
|
||||
|
||||
// Function to render BlockNote content as HTML
|
||||
function renderBlockNoteContent(content: any): string {
|
||||
if (!content || !content.content) return ''
|
||||
|
||||
return content.content
|
||||
.map((block: any) => {
|
||||
switch (block.type) {
|
||||
case 'heading':
|
||||
const level = block.attrs?.level || 1
|
||||
const text = block.content?.[0]?.text || ''
|
||||
return `<h${level}>${text}</h${level}>`
|
||||
|
||||
case 'paragraph':
|
||||
if (!block.content || block.content.length === 0) return '<p><br></p>'
|
||||
const paragraphText = block.content.map((c: any) => c.text || '').join('')
|
||||
return `<p>${paragraphText}</p>`
|
||||
|
||||
case 'image':
|
||||
return `<figure><img src="${block.attrs?.src}" alt="${block.attrs?.alt || ''}" style="width: ${block.attrs?.width || '100%'}; height: ${block.attrs?.height || 'auto'};" /></figure>`
|
||||
|
||||
case 'bulletedList':
|
||||
case 'numberedList':
|
||||
const tag = block.type === 'bulletedList' ? 'ul' : 'ol'
|
||||
const items =
|
||||
block.content
|
||||
?.map((item: any) => {
|
||||
const itemText = item.content?.[0]?.content?.[0]?.text || ''
|
||||
return `<li>${itemText}</li>`
|
||||
})
|
||||
.join('') || ''
|
||||
return `<${tag}>${items}</${tag}>`
|
||||
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
</script>
|
||||
|
||||
<article class="project-content">
|
||||
<!-- Project Details -->
|
||||
<div class="project-details">
|
||||
<div class="meta-grid">
|
||||
{#if project.client}
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Client</span>
|
||||
<span class="meta-value">{project.client}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if project.year}
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Year</span>
|
||||
<span class="meta-value">{project.year}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if project.role}
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Role</span>
|
||||
<span class="meta-value">{project.role}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if project.externalUrl}
|
||||
<div class="external-link-wrapper">
|
||||
<a
|
||||
href={project.externalUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="external-link"
|
||||
>
|
||||
Visit Project →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Case Study Content -->
|
||||
{#if project.caseStudyContent && project.caseStudyContent.content && project.caseStudyContent.content.length > 0}
|
||||
<div class="case-study-section">
|
||||
<div class="case-study-content">
|
||||
{@html renderBlockNoteContent(project.caseStudyContent)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Gallery (if available) -->
|
||||
{#if project.gallery && project.gallery.length > 0}
|
||||
<div class="gallery-section">
|
||||
<h2>Gallery</h2>
|
||||
<div class="gallery-grid">
|
||||
{#each project.gallery as image}
|
||||
<img src={image} alt="Project gallery image" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="project-nav">
|
||||
{#if project.projectType === 'labs'}
|
||||
<a href="/labs" class="back-link">← Back to labs</a>
|
||||
{:else}
|
||||
<a href="/" class="back-link">← Back to projects</a>
|
||||
{/if}
|
||||
</nav>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
/* Project Content */
|
||||
.project-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
}
|
||||
|
||||
.project-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
padding-bottom: $unit-3x;
|
||||
border-bottom: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: $unit-2x;
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-half;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-60;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 1rem;
|
||||
color: $grey-20;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.external-link-wrapper {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.external-link {
|
||||
display: inline-block;
|
||||
padding: $unit-2x $unit-3x;
|
||||
background: $grey-10;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 50px;
|
||||
font-weight: 500;
|
||||
font-size: 0.925rem;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-20;
|
||||
}
|
||||
}
|
||||
|
||||
/* Case Study Section */
|
||||
.case-study-content {
|
||||
:global(h1),
|
||||
:global(h2),
|
||||
:global(h3) {
|
||||
margin: $unit-3x 0 $unit-2x;
|
||||
color: $grey-10;
|
||||
font-weight: 600;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(h1) {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
:global(h2) {
|
||||
font-size: 1.375rem;
|
||||
}
|
||||
|
||||
:global(h3) {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
:global(p) {
|
||||
margin: $unit-2x 0;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.65;
|
||||
color: $grey-20;
|
||||
}
|
||||
|
||||
:global(figure) {
|
||||
margin: $unit-3x 0;
|
||||
|
||||
:global(img) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
:global(ul),
|
||||
:global(ol) {
|
||||
margin: $unit-2x 0;
|
||||
padding-left: $unit-3x;
|
||||
|
||||
:global(li) {
|
||||
margin: $unit 0;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.65;
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Gallery Section */
|
||||
.gallery-section {
|
||||
padding-top: $unit-3x;
|
||||
border-top: 1px solid $grey-90;
|
||||
|
||||
h2 {
|
||||
font-size: 1.75rem;
|
||||
margin: 0 0 $unit-3x;
|
||||
color: $grey-10;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: $unit-2x;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.project-nav {
|
||||
text-align: center;
|
||||
padding-top: $unit-3x;
|
||||
border-top: 1px solid $grey-90;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: $grey-40;
|
||||
text-decoration: none;
|
||||
font-size: 0.925rem;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $grey-20;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,72 +1,205 @@
|
|||
<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
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
$: isEven = index % 2 === 0
|
||||
|
||||
// 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
|
||||
|
||||
|
||||
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) * -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() {
|
||||
isHovering = true
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -74,7 +50,7 @@
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -108,7 +84,13 @@
|
|||
}
|
||||
|
||||
.highlighted {
|
||||
color: #D0290D;
|
||||
color: #d0290d;
|
||||
}
|
||||
}
|
||||
|
||||
.no-projects {
|
||||
padding: $unit-3x;
|
||||
text-align: center;
|
||||
color: $grey-40;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
251
src/lib/components/ProjectPasswordProtection.svelte
Normal file
251
src/lib/components/ProjectPasswordProtection.svelte
Normal 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>
|
||||
|
|
@ -4,108 +4,125 @@
|
|||
import UniverseIcon from '$icons/universe.svg'
|
||||
import PhotosIcon from '$icons/photos.svg'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
|
||||
const currentPath = $derived($page.url.pathname)
|
||||
|
||||
|
||||
interface NavItem {
|
||||
icon: typeof WorkIcon
|
||||
text: string
|
||||
href: string
|
||||
variant: 'work' | 'universe' | 'labs' | 'photos'
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
let hoveredIndex = $state<number | null>(null)
|
||||
|
||||
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 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>
|
||||
|
||||
<nav class="segmented-controller" bind:this={containerElement}>
|
||||
{#if activeIndex >= 0}
|
||||
<div
|
||||
<div
|
||||
class="active-pill"
|
||||
style="{pillStyle} background-color: {getBgColor(navItems[activeIndex].variant)};"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#each navItems as item, index}
|
||||
<a
|
||||
<a
|
||||
href={item.href}
|
||||
class="nav-item"
|
||||
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}
|
||||
|
|
@ -113,7 +130,7 @@
|
|||
|
||||
<style lang="scss">
|
||||
@import '../../assets/styles/animations';
|
||||
|
||||
|
||||
.segmented-controller {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -125,7 +142,7 @@
|
|||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.active-pill {
|
||||
position: absolute;
|
||||
top: $unit;
|
||||
|
|
@ -135,7 +152,7 @@
|
|||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -145,63 +162,60 @@
|
|||
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);
|
||||
}
|
||||
|
||||
|
||||
:global(svg.nav-icon) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
fill: currentColor;
|
||||
transition: fill 0.2s ease;
|
||||
|
||||
|
||||
&.animate {
|
||||
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) {
|
||||
|
||||
// Second item is Universe
|
||||
.nav-item:nth-of-type(2) :global(svg.animate) {
|
||||
animation: starSpin 0.6s ease;
|
||||
}
|
||||
|
||||
// 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(3) :global(svg.animate rect:nth-child(2)) {
|
||||
animation: masonryRect2 0.6s ease;
|
||||
}
|
||||
|
||||
.nav-item:nth-of-type(3) :global(svg.animate rect:nth-child(3)) {
|
||||
animation: masonryRect3 0.6s ease;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
// Fourth item is Universe (index 4)
|
||||
.nav-item:nth-of-type(4) :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)) {
|
||||
animation: masonryRect1 0.6s ease;
|
||||
}
|
||||
|
||||
.nav-item:nth-of-type(2) :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)) {
|
||||
animation: masonryRect3 0.6s ease;
|
||||
}
|
||||
|
||||
.nav-item:nth-of-type(2) :global(svg.animate rect:nth-child(4)) {
|
||||
animation: masonryRect4 0.6s ease;
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
353
src/lib/components/Slideshow.svelte
Normal file
353
src/lib/components/Slideshow.svelte
Normal 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>
|
||||
133
src/lib/components/SmartImage.svelte
Normal file
133
src/lib/components/SmartImage.svelte
Normal 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>
|
||||
|
|
@ -50,7 +50,7 @@
|
|||
cursor: pointer;
|
||||
border-radius: $image-corner-radius;
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
// Use mask as a fallback for better clipping
|
||||
-webkit-mask-image: -webkit-radial-gradient(white, black);
|
||||
mask-image: radial-gradient(white, black);
|
||||
|
|
|
|||
94
src/lib/components/UniverseAlbumCard.svelte
Normal file
94
src/lib/components/UniverseAlbumCard.svelte
Normal 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>
|
||||
197
src/lib/components/UniverseCard.svelte
Normal file
197
src/lib/components/UniverseCard.svelte
Normal 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>
|
||||
42
src/lib/components/UniverseFeed.svelte
Normal file
42
src/lib/components/UniverseFeed.svelte
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<script lang="ts">
|
||||
import UniversePostCard from './UniversePostCard.svelte'
|
||||
import UniverseAlbumCard from './UniverseAlbumCard.svelte'
|
||||
import type { UniverseItem } from '../../routes/api/universe/+server'
|
||||
|
||||
let { items }: { items: UniverseItem[] } = $props()
|
||||
</script>
|
||||
|
||||
<div class="universe-feed">
|
||||
{#if items && items.length > 0}
|
||||
{#each items as item}
|
||||
{#if item.type === 'post'}
|
||||
<UniversePostCard post={item} />
|
||||
{:else if item.type === 'album'}
|
||||
<UniverseAlbumCard album={item} />
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<p>No content found in the universe yet.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.universe-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: $unit-6x $unit-3x;
|
||||
color: $grey-40;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
134
src/lib/components/UniversePostCard.svelte
Normal file
134
src/lib/components/UniversePostCard.svelte
Normal 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>
|
||||
43
src/lib/components/admin/AdminByline.svelte
Normal file
43
src/lib/components/admin/AdminByline.svelte
Normal 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">·</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>
|
||||
44
src/lib/components/admin/AdminFilters.svelte
Normal file
44
src/lib/components/admin/AdminFilters.svelte
Normal 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>
|
||||
39
src/lib/components/admin/AdminHeader.svelte
Normal file
39
src/lib/components/admin/AdminHeader.svelte
Normal 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>
|
||||
241
src/lib/components/admin/AdminNavBar.svelte
Normal file
241
src/lib/components/admin/AdminNavBar.svelte
Normal 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>
|
||||
95
src/lib/components/admin/AdminPage.svelte
Normal file
95
src/lib/components/admin/AdminPage.svelte
Normal 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>
|
||||
52
src/lib/components/admin/AdminSegmentedControl.svelte
Normal file
52
src/lib/components/admin/AdminSegmentedControl.svelte
Normal 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>
|
||||
272
src/lib/components/admin/AdminSegmentedController.svelte
Normal file
272
src/lib/components/admin/AdminSegmentedController.svelte
Normal 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>
|
||||
375
src/lib/components/admin/AlbumForm.svelte
Normal file
375
src/lib/components/admin/AlbumForm.svelte
Normal 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>
|
||||
303
src/lib/components/admin/AlbumListItem.svelte
Normal file
303
src/lib/components/admin/AlbumListItem.svelte
Normal 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>
|
||||
104
src/lib/components/admin/AlbumMetadataPopover.svelte
Normal file
104
src/lib/components/admin/AlbumMetadataPopover.svelte
Normal 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}
|
||||
/>
|
||||
416
src/lib/components/admin/Button.svelte
Normal file
416
src/lib/components/admin/Button.svelte
Normal 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>
|
||||
170
src/lib/components/admin/DataTable.svelte
Normal file
170
src/lib/components/admin/DataTable.svelte
Normal 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>
|
||||
97
src/lib/components/admin/DeleteConfirmationModal.svelte
Normal file
97
src/lib/components/admin/DeleteConfirmationModal.svelte
Normal 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>
|
||||
54
src/lib/components/admin/DropdownItem.svelte
Normal file
54
src/lib/components/admin/DropdownItem.svelte
Normal 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>
|
||||
129
src/lib/components/admin/DropdownMenu.svelte
Normal file
129
src/lib/components/admin/DropdownMenu.svelte
Normal 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>
|
||||
28
src/lib/components/admin/DropdownMenuContainer.svelte
Normal file
28
src/lib/components/admin/DropdownMenuContainer.svelte
Normal 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>
|
||||
385
src/lib/components/admin/Editor.svelte
Normal file
385
src/lib/components/admin/Editor.svelte
Normal 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>
|
||||
866
src/lib/components/admin/EditorWithUpload.svelte
Normal file
866
src/lib/components/admin/EditorWithUpload.svelte
Normal 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>
|
||||
545
src/lib/components/admin/EssayForm.svelte
Normal file
545
src/lib/components/admin/EssayForm.svelte
Normal 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>
|
||||
136
src/lib/components/admin/FormField.svelte
Normal file
136
src/lib/components/admin/FormField.svelte
Normal 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>
|
||||
72
src/lib/components/admin/FormFieldWrapper.svelte
Normal file
72
src/lib/components/admin/FormFieldWrapper.svelte
Normal 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>
|
||||
646
src/lib/components/admin/GalleryManager.svelte
Normal file
646
src/lib/components/admin/GalleryManager.svelte
Normal 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>
|
||||
930
src/lib/components/admin/GalleryUploader.svelte
Normal file
930
src/lib/components/admin/GalleryUploader.svelte
Normal 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>
|
||||
465
src/lib/components/admin/GenericMetadataPopover.svelte
Normal file
465
src/lib/components/admin/GenericMetadataPopover.svelte
Normal 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>
|
||||
401
src/lib/components/admin/ImagePicker.svelte
Normal file
401
src/lib/components/admin/ImagePicker.svelte
Normal 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>
|
||||
296
src/lib/components/admin/ImageUploadPlaceholder.svelte
Normal file
296
src/lib/components/admin/ImageUploadPlaceholder.svelte
Normal 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>
|
||||
856
src/lib/components/admin/ImageUploader.svelte
Normal file
856
src/lib/components/admin/ImageUploader.svelte
Normal 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>
|
||||
508
src/lib/components/admin/Input.svelte
Normal file
508
src/lib/components/admin/Input.svelte
Normal 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>
|
||||
51
src/lib/components/admin/LoadingSpinner.svelte
Normal file
51
src/lib/components/admin/LoadingSpinner.svelte
Normal 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>
|
||||
779
src/lib/components/admin/MediaDetailsModal.svelte
Normal file
779
src/lib/components/admin/MediaDetailsModal.svelte
Normal 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>
|
||||
430
src/lib/components/admin/MediaInput.svelte
Normal file
430
src/lib/components/admin/MediaInput.svelte
Normal 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>
|
||||
225
src/lib/components/admin/MediaLibraryModal.svelte
Normal file
225
src/lib/components/admin/MediaLibraryModal.svelte
Normal 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>
|
||||
631
src/lib/components/admin/MediaSelector.svelte
Normal file
631
src/lib/components/admin/MediaSelector.svelte
Normal 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>
|
||||
618
src/lib/components/admin/MediaUploadModal.svelte
Normal file
618
src/lib/components/admin/MediaUploadModal.svelte
Normal 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>
|
||||
329
src/lib/components/admin/MetadataPopover.svelte
Normal file
329
src/lib/components/admin/MetadataPopover.svelte
Normal 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>
|
||||
139
src/lib/components/admin/Modal.svelte
Normal file
139
src/lib/components/admin/Modal.svelte
Normal 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>
|
||||
348
src/lib/components/admin/PhotoPostForm.svelte
Normal file
348
src/lib/components/admin/PhotoPostForm.svelte
Normal 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>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue