Merge branch 'universe/posts' of github.com:jedmund/jedmund-svelte into universe/posts
# Conflicts: # package.json
This commit is contained in:
commit
8e746097b3
298 changed files with 54191 additions and 6521 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
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
||||||
|
/../generated/prisma
|
||||||
|
|
||||||
|
# Local uploads (for development)
|
||||||
|
/static/local-uploads
|
||||||
|
|
||||||
|
*storybook.log
|
||||||
|
storybook-static
|
||||||
|
|
|
||||||
1
.node-version
Normal file
1
.node-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
20
|
||||||
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.
|
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
|
### Key Architecture Components
|
||||||
|
|
||||||
**API Integration Layer** (`src/routes/api/`)
|
**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 js from '@eslint/js'
|
||||||
import ts from 'typescript-eslint'
|
import ts from 'typescript-eslint'
|
||||||
import svelte from 'eslint-plugin-svelte'
|
import svelte from 'eslint-plugin-svelte'
|
||||||
|
|
@ -29,5 +32,6 @@ export default [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignores: ['build/', '.svelte-kit/', 'dist/']
|
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
47
package.json
47
package.json
|
|
@ -23,40 +23,83 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@musicorum/lastfm": "github:jedmund/lastfm",
|
"@musicorum/lastfm": "github:jedmund/lastfm",
|
||||||
"@poppanator/sveltekit-svg": "^5.0.0-svelte5.4",
|
"@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/adapter-auto": "^3.0.0",
|
||||||
"@sveltejs/kit": "^2.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/eslint": "^8.56.7",
|
||||||
"@types/node": "^22.0.2",
|
"@types/node": "^22.0.2",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-storybook": "^9.0.1",
|
||||||
"eslint-plugin-svelte": "^2.36.0",
|
"eslint-plugin-svelte": "^2.36.0",
|
||||||
"globals": "^15.0.0",
|
"globals": "^15.0.0",
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.39",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
"prettier-plugin-svelte": "^3.1.2",
|
"prettier-plugin-svelte": "^3.1.2",
|
||||||
"sass": "^1.77.8",
|
"sass": "^1.77.8",
|
||||||
|
"storybook": "^9.0.1",
|
||||||
"svelte": "^5.0.0-next.1",
|
"svelte": "^5.0.0-next.1",
|
||||||
"svelte-check": "^3.6.0",
|
"svelte-check": "^3.6.0",
|
||||||
"tslib": "^2.4.1",
|
"tslib": "^2.4.1",
|
||||||
|
"tsx": "^4.19.4",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.0.0-alpha.20",
|
"typescript-eslint": "^8.0.0-alpha.20",
|
||||||
"vite": "^5.0.3"
|
"vite": "^5.0.3"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aarkue/tiptap-math-extension": "^1.3.6",
|
||||||
|
"@prisma/client": "^6.8.2",
|
||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@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/redis": "^4.0.10",
|
||||||
"@types/steamapi": "^2.2.5",
|
"@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",
|
"giantbombing-api": "^1.0.4",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
|
"katex": "^0.16.22",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
|
"lucide-svelte": "^0.511.0",
|
||||||
"marked": "^15.0.12",
|
"marked": "^15.0.12",
|
||||||
|
"multer": "^2.0.0",
|
||||||
"node-itunes-search": "^1.2.3",
|
"node-itunes-search": "^1.2.3",
|
||||||
|
"prisma": "^6.8.2",
|
||||||
"psn-api": "github:jedmund/psn-api",
|
"psn-api": "github:jedmund/psn-api",
|
||||||
"redis": "^4.7.0",
|
"redis": "^4.7.0",
|
||||||
|
"sharp": "^0.34.2",
|
||||||
"steamapi": "^3.0.11",
|
"steamapi": "^3.0.11",
|
||||||
"svelte-tiptap": "^2.1.0",
|
"svelte-tiptap": "^2.1.0",
|
||||||
"svgo": "^3.3.2",
|
"svgo": "^3.3.2",
|
||||||
|
|
|
||||||
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";
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Create a table to track database initialization
|
||||||
|
CREATE TABLE IF NOT EXISTS "_db_initialization" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY DEFAULT 1,
|
||||||
|
"initialized_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"version" TEXT NOT NULL DEFAULT '1.0.0',
|
||||||
|
CONSTRAINT "_db_initialization_id_check" CHECK (id = 1)
|
||||||
|
);
|
||||||
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()
|
||||||
|
})
|
||||||
29
scripts/init-db.sh
Executable file
29
scripts/init-db.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Database initialization script
|
||||||
|
# This script checks if the database has been initialized and runs migrations/seeds only if needed
|
||||||
|
|
||||||
|
echo "🔍 Checking database initialization status..."
|
||||||
|
|
||||||
|
# Run a simple query to check if the _prisma_migrations table exists and has entries
|
||||||
|
DB_INITIALIZED=$(npx prisma db execute --stdin <<EOF 2>/dev/null | grep -c "1"
|
||||||
|
SELECT COUNT(*) FROM _prisma_migrations WHERE finished_at IS NOT NULL LIMIT 1;
|
||||||
|
EOF
|
||||||
|
)
|
||||||
|
|
||||||
|
if [ "$DB_INITIALIZED" = "0" ]; then
|
||||||
|
echo "📦 First time setup detected. Initializing database..."
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
echo "🔄 Running database migrations..."
|
||||||
|
npx prisma migrate deploy
|
||||||
|
|
||||||
|
# Run seeds
|
||||||
|
echo "🌱 Seeding database..."
|
||||||
|
npx prisma db seed
|
||||||
|
|
||||||
|
echo "✅ Database initialization complete!"
|
||||||
|
else
|
||||||
|
echo "✅ Database already initialized. Running migrations only..."
|
||||||
|
npx prisma migrate deploy
|
||||||
|
fi
|
||||||
53
scripts/init-db.ts
Normal file
53
scripts/init-db.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
|
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function isDatabaseInitialized(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Check if we have any completed migrations
|
||||||
|
const migrationCount = await prisma.$queryRaw<[{ count: bigint }]>`
|
||||||
|
SELECT COUNT(*) as count
|
||||||
|
FROM _prisma_migrations
|
||||||
|
WHERE finished_at IS NOT NULL
|
||||||
|
`
|
||||||
|
|
||||||
|
return migrationCount[0].count > 0n
|
||||||
|
} catch (error) {
|
||||||
|
// If the table doesn't exist, database is not initialized
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeDatabase() {
|
||||||
|
console.log('🔍 Checking database initialization status...')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isInitialized = await isDatabaseInitialized()
|
||||||
|
|
||||||
|
if (!isInitialized) {
|
||||||
|
console.log('📦 First time setup detected. Initializing database...')
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
console.log('🔄 Running database migrations...')
|
||||||
|
execSync('npx prisma migrate deploy', { stdio: 'inherit' })
|
||||||
|
|
||||||
|
// Run seeds
|
||||||
|
console.log('🌱 Seeding database...')
|
||||||
|
execSync('npx prisma db seed', { stdio: 'inherit' })
|
||||||
|
|
||||||
|
console.log('✅ Database initialization complete!')
|
||||||
|
} else {
|
||||||
|
console.log('✅ Database already initialized. Running migrations only...')
|
||||||
|
execSync('npx prisma migrate deploy', { stdio: 'inherit' })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Database initialization failed:', error)
|
||||||
|
process.exit(1)
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the initialization
|
||||||
|
initializeDatabase()
|
||||||
14
scripts/railway-build.sh
Executable file
14
scripts/railway-build.sh
Executable file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Railway deployment script
|
||||||
|
echo "🚂 Starting Railway deployment..."
|
||||||
|
|
||||||
|
# Initialize database (runs migrations and seeds on first deploy only)
|
||||||
|
echo "🗄️ Initializing database..."
|
||||||
|
npm run db:init
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
echo "🏗️ Building application..."
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "✅ Deployment preparation complete!"
|
||||||
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,14 +3,22 @@
|
||||||
|
|
||||||
// Work icon - cursor wiggle
|
// Work icon - cursor wiggle
|
||||||
@keyframes cursorWiggle {
|
@keyframes cursorWiggle {
|
||||||
0%, 100% { transform: rotate(0deg) scale(1); }
|
0%,
|
||||||
25% { transform: rotate(-8deg) scale(1.05); }
|
100% {
|
||||||
75% { transform: rotate(8deg) scale(1.05); }
|
transform: rotate(0deg) scale(1);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(-8deg) scale(1.05);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(8deg) scale(1.05);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Photos icon - masonry height changes
|
// Photos icon - masonry height changes
|
||||||
@keyframes masonryRect1 {
|
@keyframes masonryRect1 {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
height: 10px;
|
height: 10px;
|
||||||
y: 2px;
|
y: 2px;
|
||||||
}
|
}
|
||||||
|
|
@ -21,7 +29,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes masonryRect2 {
|
@keyframes masonryRect2 {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
y: 2px;
|
y: 2px;
|
||||||
}
|
}
|
||||||
|
|
@ -32,7 +41,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes masonryRect3 {
|
@keyframes masonryRect3 {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
y: 14px;
|
y: 14px;
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +53,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes masonryRect4 {
|
@keyframes masonryRect4 {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
height: 8px;
|
height: 8px;
|
||||||
y: 10px;
|
y: 10px;
|
||||||
}
|
}
|
||||||
|
|
@ -55,21 +66,41 @@
|
||||||
|
|
||||||
// Labs icon - test tube rotation
|
// Labs icon - test tube rotation
|
||||||
@keyframes tubeRotate {
|
@keyframes tubeRotate {
|
||||||
0%, 100% { transform: rotate(0deg); }
|
0%,
|
||||||
25% { transform: rotate(-10deg); }
|
100% {
|
||||||
75% { transform: rotate(10deg); }
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: rotate(-10deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: rotate(10deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Universe icon - star spin
|
// Universe icon - star spin
|
||||||
@keyframes starSpin {
|
@keyframes starSpin {
|
||||||
0% { transform: rotate(0deg); }
|
0% {
|
||||||
100% { transform: rotate(720deg); }
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(720deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Placeholder animations (not currently used)
|
// Placeholder animations (not currently used)
|
||||||
@keyframes photoMasonry {
|
@keyframes photoMasonry {
|
||||||
0%, 100% { transform: scale(1); }
|
0%,
|
||||||
25% { transform: scale(1); }
|
100% {
|
||||||
50% { transform: scale(1); }
|
transform: scale(1);
|
||||||
75% { 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-10x: $unit * 10;
|
||||||
$unit-12x: $unit * 12;
|
$unit-12x: $unit * 12;
|
||||||
$unit-14x: $unit * 14;
|
$unit-14x: $unit * 14;
|
||||||
|
$unit-16x: $unit * 16;
|
||||||
|
$unit-18x: $unit * 18;
|
||||||
$unit-20x: $unit * 20;
|
$unit-20x: $unit * 20;
|
||||||
|
|
||||||
/* Page properties
|
/* Page properties
|
||||||
|
|
@ -64,18 +66,46 @@ $letter-spacing: -0.02em;
|
||||||
/* Colors
|
/* Colors
|
||||||
* -------------------------------------------------------------------------- */
|
* -------------------------------------------------------------------------- */
|
||||||
$grey-100: #ffffff;
|
$grey-100: #ffffff;
|
||||||
|
$grey-97: #fafafa;
|
||||||
|
$grey-95: #f5f5f5;
|
||||||
$grey-90: #f7f7f7;
|
$grey-90: #f7f7f7;
|
||||||
|
$grey-85: #ebebeb;
|
||||||
$grey-80: #e8e8e8;
|
$grey-80: #e8e8e8;
|
||||||
|
$grey-70: #dfdfdf;
|
||||||
|
$grey-60: #cccccc;
|
||||||
|
$grey-5: #f9f9f9;
|
||||||
$grey-50: #b2b2b2;
|
$grey-50: #b2b2b2;
|
||||||
$grey-40: #999999;
|
$grey-40: #999999;
|
||||||
|
$grey-30: #808080;
|
||||||
$grey-20: #666666;
|
$grey-20: #666666;
|
||||||
|
$grey-10: #4d4d4d;
|
||||||
$grey-00: #333333;
|
$grey-00: #333333;
|
||||||
|
|
||||||
|
$red-90: #ff9d8f;
|
||||||
$red-80: #ff6a54;
|
$red-80: #ff6a54;
|
||||||
$red-60: #e33d3d;
|
$red-60: #e33d3d;
|
||||||
|
$red-50: #d33;
|
||||||
$red-40: #d31919;
|
$red-40: #d31919;
|
||||||
$red-00: #3d0c0c;
|
$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;
|
$bg-color: #e8e8e8;
|
||||||
$page-color: #ffffff;
|
$page-color: #ffffff;
|
||||||
$card-color: #f7f7f7;
|
$card-color: #f7f7f7;
|
||||||
|
|
@ -85,6 +115,7 @@ $text-color-body: #666666;
|
||||||
|
|
||||||
$accent-color: #e33d3d;
|
$accent-color: #e33d3d;
|
||||||
$grey-color: #f0f0f0;
|
$grey-color: #f0f0f0;
|
||||||
|
$primary-color: #1482c1; // Using labs color as primary
|
||||||
|
|
||||||
$image-border-color: rgba(0, 0, 0, 0.03);
|
$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>
|
<script>
|
||||||
// What if we have a headphones avatar that is head bopping if the last scrobble was < 5 mins ago
|
// 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
|
// 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'
|
import { spring } from 'svelte/motion'
|
||||||
|
|
||||||
let isHovering = $state(false)
|
let isHovering = false
|
||||||
let isBlinking = $state(false)
|
let isBlinking = false
|
||||||
|
|
||||||
const scale = spring(1, {
|
const scale = spring(1, {
|
||||||
stiffness: 0.1,
|
stiffness: 0.1,
|
||||||
damping: 0.125
|
damping: 0.125
|
||||||
})
|
})
|
||||||
|
|
||||||
$effect(() => {
|
function handleMouseEnter() {
|
||||||
if (isHovering) {
|
isHovering = true
|
||||||
scale.set(1.25)
|
scale.set(1.25)
|
||||||
} else {
|
}
|
||||||
scale.set(1)
|
|
||||||
}
|
function handleMouseLeave() {
|
||||||
})
|
isHovering = false
|
||||||
|
scale.set(1)
|
||||||
|
}
|
||||||
|
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBlinkState(state) {
|
|
||||||
isBlinking = state
|
|
||||||
}
|
|
||||||
|
|
||||||
async function singleBlink(duration) {
|
async function singleBlink(duration) {
|
||||||
setBlinkState(true)
|
isBlinking = true
|
||||||
await sleep(duration)
|
await sleep(duration)
|
||||||
setBlinkState(false)
|
isBlinking = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doubleBlink() {
|
async function doubleBlink() {
|
||||||
|
|
@ -48,25 +46,27 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startBlinking() {
|
let blinkInterval
|
||||||
const blinkInterval = setInterval(() => {
|
|
||||||
|
onMount(() => {
|
||||||
|
blinkInterval = setInterval(() => {
|
||||||
if (!isHovering) {
|
if (!isHovering) {
|
||||||
blink()
|
blink()
|
||||||
}
|
}
|
||||||
}, 4000)
|
}, 4000)
|
||||||
|
|
||||||
return () => clearInterval(blinkInterval)
|
return () => {
|
||||||
}
|
if (blinkInterval) {
|
||||||
|
clearInterval(blinkInterval)
|
||||||
onMount(() => {
|
}
|
||||||
return startBlinking()
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="face-container"
|
class="face-container"
|
||||||
on:mouseenter={() => (isHovering = true)}
|
onmouseenter={handleMouseEnter}
|
||||||
on:mouseleave={() => (isHovering = false)}
|
onmouseleave={handleMouseLeave}
|
||||||
style="transform: scale({$scale})"
|
style="transform: scale({$scale})"
|
||||||
>
|
>
|
||||||
<svg
|
<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>
|
||||||
|
|
@ -3,27 +3,38 @@
|
||||||
import SegmentedController from './SegmentedController.svelte'
|
import SegmentedController from './SegmentedController.svelte'
|
||||||
|
|
||||||
let scrollY = $state(0)
|
let scrollY = $state(0)
|
||||||
let hasScrolled = $state(false)
|
// Smooth gradient opacity from 0 to 1 over the first 100px of scroll
|
||||||
let gradientOpacity = $derived(Math.min(scrollY / 40, 1))
|
let gradientOpacity = $derived(Math.min(scrollY / 100, 1))
|
||||||
|
// Padding transition happens more quickly
|
||||||
|
let paddingProgress = $derived(Math.min(scrollY / 50, 1))
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const handleScroll = () => {
|
let ticking = false
|
||||||
scrollY = window.scrollY
|
|
||||||
|
|
||||||
// Add hysteresis to prevent flickering
|
const updateScroll = () => {
|
||||||
if (!hasScrolled && scrollY > 30) {
|
scrollY = window.scrollY
|
||||||
hasScrolled = true
|
ticking = false
|
||||||
} else if (hasScrolled && scrollY < 20) {
|
}
|
||||||
hasScrolled = 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)
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
})
|
})
|
||||||
</script>
|
</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">
|
<div class="header-content">
|
||||||
<a href="/about" class="header-link" aria-label="@jedmund">
|
<a href="/about" class="header-link" aria-label="@jedmund">
|
||||||
<Avatar />
|
<Avatar />
|
||||||
|
|
@ -39,32 +50,33 @@
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: $unit-5x 0;
|
// Smooth padding transition based on scroll
|
||||||
transition:
|
padding: calc($unit-5x - ($unit-5x - $unit-2x) * var(--padding-progress)) 0;
|
||||||
padding 0.3s ease,
|
|
||||||
background 0.3s ease;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
// Add a very subtle transition to smooth out any remaining jitter
|
||||||
|
transition: padding 0.1s ease-out;
|
||||||
|
|
||||||
&.scrolled {
|
&::before {
|
||||||
padding: $unit-2x 0;
|
content: '';
|
||||||
|
position: absolute;
|
||||||
&::before {
|
top: 0;
|
||||||
content: '';
|
left: 0;
|
||||||
position: absolute;
|
right: 0;
|
||||||
top: 0;
|
height: 120px;
|
||||||
left: 0;
|
background: linear-gradient(
|
||||||
right: 0;
|
to bottom,
|
||||||
height: 120px;
|
rgba(0, 0, 0, 0.15),
|
||||||
background: linear-gradient(to bottom, rgba(0, 0, 0, calc(0.15 * var(--gradient-opacity))), transparent);
|
transparent
|
||||||
backdrop-filter: blur(calc(6px * var(--gradient-opacity)));
|
);
|
||||||
-webkit-backdrop-filter: blur(calc(6px * var(--gradient-opacity)));
|
backdrop-filter: blur(6px);
|
||||||
mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
|
-webkit-backdrop-filter: blur(6px);
|
||||||
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
|
mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
|
||||||
pointer-events: none;
|
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
|
||||||
z-index: -1;
|
pointer-events: none;
|
||||||
opacity: var(--gradient-opacity);
|
z-index: -1;
|
||||||
transition: opacity 0.2s ease;
|
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">
|
<script lang="ts">
|
||||||
import Lightbox from './Lightbox.svelte'
|
import Slideshow from './Slideshow.svelte'
|
||||||
import TiltCard from './TiltCard.svelte'
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
images = [],
|
images = [],
|
||||||
|
|
@ -10,191 +9,9 @@
|
||||||
alt?: string
|
alt?: string
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
let selectedIndex = $state(0)
|
// Convert string array to slideshow items
|
||||||
let lightboxOpen = $state(false)
|
const slideshowItems = $derived(images.map(url => ({ url, alt })))
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if images.length === 1}
|
<Slideshow items={slideshowItems} {alt} aspectRatio="4/3" />
|
||||||
<!-- 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}
|
|
||||||
|
|
||||||
<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,68 +1,242 @@
|
||||||
<script lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<article class="lab-card">
|
{#if isClickable}
|
||||||
<div class="card-header">
|
<a
|
||||||
<h3 class="project-title">{project.title}</h3>
|
href={projectUrl}
|
||||||
<span class="project-year">{project.year}</span>
|
class="lab-card clickable"
|
||||||
</div>
|
bind:this={cardElement}
|
||||||
|
onmousemove={handleMouseMove}
|
||||||
<p class="project-description">{project.description}</p>
|
onmouseenter={handleMouseEnter}
|
||||||
|
onmouseleave={handleMouseLeave}
|
||||||
{#if project.url || project.github}
|
style:transform
|
||||||
<div class="project-links">
|
>
|
||||||
{#if project.url}
|
<div class="card-header">
|
||||||
<a href={project.url} target="_blank" rel="noopener noreferrer" class="project-link primary">
|
<div class="project-title-container">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
<h3 class="project-title">{project.title}</h3>
|
||||||
<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"/>
|
<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>
|
</svg>
|
||||||
Visit Project
|
</Button>
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{#if project.github}
|
|
||||||
<a href={project.github} target="_blank" rel="noopener noreferrer" class="project-link secondary">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
|
||||||
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<style lang="scss">
|
||||||
.lab-card {
|
.lab-card {
|
||||||
background: $grey-100;
|
background: $grey-100;
|
||||||
border-radius: $card-corner-radius;
|
border-radius: $card-corner-radius;
|
||||||
padding: $unit-3x;
|
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 {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
box-shadow:
|
||||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
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') {
|
@include breakpoint('phone') {
|
||||||
padding: $unit-2x;
|
padding: $unit-2x;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: flex-start;
|
||||||
margin-bottom: $unit-2x;
|
|
||||||
gap: $unit-2x;
|
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 {
|
.project-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.25rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 400;
|
||||||
color: $grey-00;
|
color: $grey-00;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
|
||||||
|
|
@ -74,13 +248,13 @@
|
||||||
.project-year {
|
.project-year {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
font-weight: 500;
|
font-weight: 400;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-description {
|
.project-description {
|
||||||
margin: 0 0 $unit-3x 0;
|
margin: 0 0 $unit-3x 0;
|
||||||
font-size: 1rem;
|
font-size: 1.125rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
|
|
||||||
|
|
@ -93,6 +267,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: $unit-2x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-link {
|
.project-link {
|
||||||
|
|
@ -115,6 +290,10 @@
|
||||||
background: darken($labs-color, 10%);
|
background: darken($labs-color, 10%);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.external {
|
||||||
|
pointer-events: none; // Prevent clicking when it's inside a clickable card
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.secondary {
|
&.secondary {
|
||||||
|
|
@ -132,4 +311,34 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
padding: $unit $unit-2x;
|
||||||
|
border-radius: $unit-2x;
|
||||||
|
margin-top: $unit-2x;
|
||||||
|
|
||||||
|
&.list-only {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.password-protected {
|
||||||
|
background: rgba(251, 191, 36, 0.1);
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -108,8 +108,19 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="lightbox-close" onclick={close} aria-label="Close lightbox">
|
<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">
|
<svg
|
||||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,6 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
transition: border-color 0.2s ease;
|
transition: border-color 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|
@ -163,7 +162,6 @@
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.favicon {
|
.favicon {
|
||||||
|
|
@ -184,7 +182,6 @@
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: $grey-00;
|
color: $grey-00;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
|
@ -196,7 +193,6 @@
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
|
|
@ -225,7 +221,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
|
|
|
||||||
|
|
@ -1,84 +1,37 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PhotoItem from '$components/PhotoItem.svelte'
|
import PhotoItem from '$components/PhotoItem.svelte'
|
||||||
import PhotoLightbox from '$components/PhotoLightbox.svelte'
|
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
|
||||||
import type { PhotoItem as PhotoItemType, Photo } from '$lib/types/photos'
|
|
||||||
|
|
||||||
const { photoItems }: { photoItems: PhotoItemType[] } = $props()
|
const {
|
||||||
|
photoItems,
|
||||||
let lightboxPhoto: Photo | null = $state(null)
|
albumSlug
|
||||||
let lightboxAlbumPhotos: Photo[] = $state([])
|
}: {
|
||||||
let lightboxIndex = $state(0)
|
photoItems: PhotoItemType[]
|
||||||
|
albumSlug?: string
|
||||||
function openLightbox(photo: Photo, albumPhotos?: Photo[]) {
|
} = $props()
|
||||||
if (albumPhotos && albumPhotos.length > 0) {
|
|
||||||
// For albums, start with the first photo, not the cover photo
|
|
||||||
lightboxAlbumPhotos = albumPhotos
|
|
||||||
lightboxIndex = 0
|
|
||||||
lightboxPhoto = albumPhotos[0]
|
|
||||||
} else {
|
|
||||||
// For individual photos
|
|
||||||
lightboxPhoto = photo
|
|
||||||
lightboxAlbumPhotos = []
|
|
||||||
lightboxIndex = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeLightbox() {
|
|
||||||
lightboxPhoto = null
|
|
||||||
lightboxAlbumPhotos = []
|
|
||||||
lightboxIndex = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateLightbox(direction: 'prev' | 'next') {
|
|
||||||
if (lightboxAlbumPhotos.length === 0) return
|
|
||||||
|
|
||||||
if (direction === 'prev') {
|
|
||||||
lightboxIndex = lightboxIndex > 0 ? lightboxIndex - 1 : lightboxAlbumPhotos.length - 1
|
|
||||||
} else {
|
|
||||||
lightboxIndex = lightboxIndex < lightboxAlbumPhotos.length - 1 ? lightboxIndex + 1 : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
lightboxPhoto = lightboxAlbumPhotos[lightboxIndex]
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="photo-grid-container">
|
<div class="photo-grid-container">
|
||||||
<div class="photo-grid">
|
<div class="photo-grid">
|
||||||
{#each photoItems as item}
|
{#each photoItems as item}
|
||||||
<PhotoItem {item} onPhotoClick={openLightbox} />
|
<PhotoItem {item} {albumSlug} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if lightboxPhoto}
|
|
||||||
<PhotoLightbox
|
|
||||||
photo={lightboxPhoto}
|
|
||||||
albumPhotos={lightboxAlbumPhotos}
|
|
||||||
currentIndex={lightboxIndex}
|
|
||||||
onClose={closeLightbox}
|
|
||||||
onNavigate={navigateLightbox}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.photo-grid-container {
|
.photo-grid-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 $unit-2x;
|
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
|
||||||
padding: $unit-3x $unit;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.photo-grid {
|
.photo-grid {
|
||||||
columns: 3;
|
columns: 3;
|
||||||
column-gap: $unit-2x;
|
column-gap: $unit-3x;
|
||||||
max-width: 700px;
|
margin: 0;
|
||||||
margin: 0 auto;
|
|
||||||
|
|
||||||
@include breakpoint('tablet') {
|
@include breakpoint('tablet') {
|
||||||
columns: 2;
|
columns: 2;
|
||||||
column-gap: $unit;
|
column-gap: $unit-2x;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,32 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PhotoItem, Photo, PhotoAlbum } from '$lib/types/photos'
|
import type { PhotoItem, Photo, PhotoAlbum } from '$lib/types/photos'
|
||||||
import { isAlbum } from '$lib/types/photos'
|
import { isAlbum } from '$lib/types/photos'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
item,
|
item,
|
||||||
onPhotoClick
|
albumSlug // For when this is used within an album context
|
||||||
}: {
|
}: {
|
||||||
item: PhotoItem
|
item: PhotoItem
|
||||||
onPhotoClick: (photo: Photo, albumPhotos?: Photo[]) => void
|
albumSlug?: string
|
||||||
} = $props()
|
} = $props()
|
||||||
|
|
||||||
let imageLoaded = $state(false)
|
let imageLoaded = $state(false)
|
||||||
|
|
||||||
function handleClick() {
|
function handleClick() {
|
||||||
if (isAlbum(item)) {
|
if (isAlbum(item)) {
|
||||||
// For albums, open the cover photo with album navigation
|
// Navigate to album page using the slug
|
||||||
onPhotoClick(item.coverPhoto, item.photos)
|
goto(`/photos/${item.slug}`)
|
||||||
} else {
|
} else {
|
||||||
// For individual photos, open just that photo
|
// For individual photos, check if we have album context
|
||||||
onPhotoClick(item)
|
if (albumSlug) {
|
||||||
|
// Navigate to photo within album
|
||||||
|
const photoId = item.id.replace('photo-', '') // Remove 'photo-' prefix
|
||||||
|
goto(`/photos/${albumSlug}/${photoId}`)
|
||||||
|
} else {
|
||||||
|
// For standalone photos, navigate to a generic photo page (to be implemented)
|
||||||
|
console.log('Individual photo navigation not yet implemented')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,10 +87,10 @@
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.photo-item {
|
.photo-item {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
margin-bottom: $unit-2x;
|
margin-bottom: $unit-3x;
|
||||||
|
|
||||||
@include breakpoint('tablet') {
|
@include breakpoint('tablet') {
|
||||||
margin-bottom: $unit;
|
margin-bottom: $unit-2x;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,7 +104,9 @@
|
||||||
border-radius: $corner-radius;
|
border-radius: $corner-radius;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,12 @@
|
||||||
<!-- Close button -->
|
<!-- Close button -->
|
||||||
<button class="close-button" onclick={onClose} type="button">
|
<button class="close-button" onclick={onClose} type="button">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
@ -96,24 +101,31 @@
|
||||||
{#if hasNavigation}
|
{#if hasNavigation}
|
||||||
<button class="nav-button nav-prev" onclick={() => onNavigate('prev')} type="button">
|
<button class="nav-button nav-prev" onclick={() => onNavigate('prev')} type="button">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-button nav-next" onclick={() => onNavigate('next')} type="button">
|
<button class="nav-button nav-next" onclick={() => onNavigate('next')} type="button">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Photo -->
|
<!-- Photo -->
|
||||||
<div class="photo-container">
|
<div class="photo-container">
|
||||||
<img
|
<img src={photo.src} alt={photo.alt} onload={handleImageLoad} class:loaded={imageLoaded} />
|
||||||
src={photo.src}
|
|
||||||
alt={photo.alt}
|
|
||||||
onload={handleImageLoad}
|
|
||||||
class:loaded={imageLoaded}
|
|
||||||
/>
|
|
||||||
{#if !imageLoaded}
|
{#if !imageLoaded}
|
||||||
<div class="loading-indicator">
|
<div class="loading-indicator">
|
||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@
|
||||||
color: $grey-20; // #666
|
color: $grey-20; // #666
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
:global(svg) {
|
:global(svg) {
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,13 @@
|
||||||
</time>
|
</time>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if post.type === 'image' && post.images}
|
{#if post.images && post.images.length > 0}
|
||||||
<div class="post-images">
|
<div class="post-images">
|
||||||
<ImagePost images={post.images} alt={post.title || 'Post image'} />
|
<ImagePost images={post.images} alt={post.title || 'Post image'} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if post.type === 'link' && post.link}
|
{#if post.link}
|
||||||
<div class="post-link-preview">
|
<div class="post-link-preview">
|
||||||
<LinkCard link={post.link} />
|
<LinkCard link={post.link} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -51,24 +51,44 @@
|
||||||
max-width: 784px;
|
max-width: 784px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
||||||
&.note {
|
// Post type styles for simplified post types
|
||||||
|
&.post {
|
||||||
.post-body {
|
.post-body {
|
||||||
font-size: 1.1rem;
|
font-size: 1.125rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.image {
|
&.essay {
|
||||||
.post-images {
|
.post-body {
|
||||||
margin-bottom: $unit-4x;
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.link {
|
// Legacy type support
|
||||||
.post-link-preview {
|
&.note,
|
||||||
margin-bottom: $unit-4x;
|
&.microblog {
|
||||||
max-width: 600px;
|
.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 {
|
.post-header {
|
||||||
|
|
@ -91,8 +111,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-body {
|
.post-body {
|
||||||
color: $grey-20;
|
color: $grey-10;
|
||||||
line-height: 1.6;
|
line-height: 1.5;
|
||||||
|
|
||||||
:global(h2) {
|
:global(h2) {
|
||||||
margin: $unit-4x 0 $unit-2x;
|
margin: $unit-4x 0 $unit-2x;
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@
|
||||||
|
|
||||||
&.note {
|
&.note {
|
||||||
.post-excerpt {
|
.post-excerpt {
|
||||||
font-size: 1rem;
|
font-size: 1.125rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,8 +107,8 @@
|
||||||
.post-excerpt {
|
.post-excerpt {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: $grey-00;
|
color: $grey-00;
|
||||||
font-size: 1rem;
|
font-size: 1.125rem;
|
||||||
line-height: 1.3;
|
line-height: 1.5;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
-webkit-line-clamp: 3;
|
-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,26 +1,77 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import SVGHoverEffect from '$components/SVGHoverEffect.svelte'
|
import { onMount } from 'svelte'
|
||||||
import type { SvelteComponent } from 'svelte'
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
export let SVGComponent: typeof SvelteComponent
|
interface Props {
|
||||||
export let backgroundColor: string
|
logoUrl: string | null
|
||||||
export let name: string
|
backgroundColor: string
|
||||||
export let description: string
|
name: string
|
||||||
export let highlightColor: string
|
slug: string
|
||||||
export let index: number = 0
|
description: string
|
||||||
|
highlightColor: string
|
||||||
|
status?: 'draft' | 'published' | 'list-only' | 'password-protected'
|
||||||
|
index?: number
|
||||||
|
}
|
||||||
|
|
||||||
$: isEven = index % 2 === 0
|
let {
|
||||||
|
logoUrl,
|
||||||
|
backgroundColor,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
highlightColor,
|
||||||
|
status = 'published',
|
||||||
|
index = 0
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
const isEven = $derived(index % 2 === 0)
|
||||||
|
const isClickable = $derived(status === 'published' || status === 'password-protected')
|
||||||
|
const isListOnly = $derived(status === 'list-only')
|
||||||
|
const isPasswordProtected = $derived(status === 'password-protected')
|
||||||
|
|
||||||
// Create highlighted description
|
// Create highlighted description
|
||||||
$: highlightedDescription = description.replace(
|
const highlightedDescription = $derived(
|
||||||
new RegExp(`(${name})`, 'gi'),
|
description.replace(
|
||||||
`<span style="color: ${highlightColor};">$1</span>`
|
new RegExp(`(${name})`, 'gi'),
|
||||||
|
`<span style="color: ${highlightColor};">$1</span>`
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 3D tilt effect
|
// 3D tilt effect
|
||||||
let cardElement: HTMLDivElement
|
let cardElement: HTMLDivElement
|
||||||
let isHovering = false
|
let logoElement: HTMLElement
|
||||||
let transform = ''
|
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) {
|
function handleMouseMove(e: MouseEvent) {
|
||||||
if (!cardElement || !isHovering) return
|
if (!cardElement || !isHovering) return
|
||||||
|
|
@ -32,10 +83,19 @@
|
||||||
const centerX = rect.width / 2
|
const centerX = rect.width / 2
|
||||||
const centerY = rect.height / 2
|
const centerY = rect.height / 2
|
||||||
|
|
||||||
const rotateX = ((y - centerY) / centerY) * -4 // -4 to 4 degrees
|
// 3D tilt for card
|
||||||
const rotateY = ((x - centerX) / centerX) * 4 // -4 to 4 degrees
|
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)`
|
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() {
|
function handleMouseEnter() {
|
||||||
|
|
@ -45,28 +105,101 @@
|
||||||
function handleMouseLeave() {
|
function handleMouseLeave() {
|
||||||
isHovering = false
|
isHovering = false
|
||||||
transform = 'perspective(1000px) rotateX(0) rotateY(0) scale3d(1, 1, 1)'
|
transform = 'perspective(1000px) rotateX(0) rotateY(0) scale3d(1, 1, 1)'
|
||||||
|
logoTransform = 'translate(0, 0)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick() {
|
||||||
|
if (isClickable) {
|
||||||
|
goto(`/work/${slug}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="project-item {isEven ? 'even' : 'odd'}"
|
class="project-item {isEven ? 'even' : 'odd'}"
|
||||||
|
class:clickable={isClickable}
|
||||||
|
class:list-only={isListOnly}
|
||||||
|
class:password-protected={isPasswordProtected}
|
||||||
bind:this={cardElement}
|
bind:this={cardElement}
|
||||||
on:mousemove={handleMouseMove}
|
onclick={handleClick}
|
||||||
on:mouseenter={handleMouseEnter}
|
onkeydown={(e) => e.key === 'Enter' && handleClick()}
|
||||||
on:mouseleave={handleMouseLeave}
|
onmousemove={isClickable ? handleMouseMove : undefined}
|
||||||
|
onmouseenter={isClickable ? handleMouseEnter : undefined}
|
||||||
|
onmouseleave={isClickable ? handleMouseLeave : undefined}
|
||||||
style="transform: {transform};"
|
style="transform: {transform};"
|
||||||
|
role={isClickable ? 'button' : 'article'}
|
||||||
|
tabindex={isClickable ? 0 : -1}
|
||||||
>
|
>
|
||||||
<div class="project-logo">
|
<div class="project-logo" style="background-color: {backgroundColor}">
|
||||||
<SVGHoverEffect
|
{#if svgContent}
|
||||||
{SVGComponent}
|
<div bind:this={logoElement} class="logo-svg" style="transform: {logoTransform}">
|
||||||
{backgroundColor}
|
{@html svgContent}
|
||||||
maxMovement={10}
|
</div>
|
||||||
containerHeight="80px"
|
{:else if logoUrl}
|
||||||
bounceDamping={0.2}
|
<img
|
||||||
/>
|
src={logoUrl}
|
||||||
|
alt="{name} logo"
|
||||||
|
class="logo-image"
|
||||||
|
bind:this={logoElement}
|
||||||
|
style="transform: {logoTransform}"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="project-content">
|
<div class="project-content">
|
||||||
<p class="project-description">{@html highlightedDescription}</p>
|
<p class="project-description">{@html highlightedDescription}</p>
|
||||||
|
|
||||||
|
{#if isListOnly}
|
||||||
|
<div class="status-indicator list-only">
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="2" />
|
||||||
|
<path
|
||||||
|
d="M20.188 10.934c.388.472.612 1.057.612 1.686 0 .63-.224 1.214-.612 1.686a11.79 11.79 0 01-1.897 1.853c-1.481 1.163-3.346 2.24-5.291 2.24-1.945 0-3.81-1.077-5.291-2.24A11.79 11.79 0 016.812 14.32C6.224 13.648 6 13.264 6 12.62c0-.63.224-1.214.612-1.686A11.79 11.79 0 018.709 9.08c1.481-1.163 3.346-2.24 5.291-2.24 1.945 0 3.81 1.077 5.291 2.24a11.79 11.79 0 011.897 1.853z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M2 2l20 20" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>Coming Soon</span>
|
||||||
|
</div>
|
||||||
|
{:else if isPasswordProtected}
|
||||||
|
<div class="status-indicator password-protected">
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="3"
|
||||||
|
y="11"
|
||||||
|
width="18"
|
||||||
|
height="11"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="16" r="1" fill="currentColor" />
|
||||||
|
<path
|
||||||
|
d="M7 11V7a5 5 0 0 1 10 0v4"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Password Required</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -79,19 +212,35 @@
|
||||||
padding: $unit-3x;
|
padding: $unit-3x;
|
||||||
background: $grey-100;
|
background: $grey-100;
|
||||||
border-radius: $card-corner-radius;
|
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;
|
transform-style: preserve-3d;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
cursor: pointer;
|
cursor: default;
|
||||||
|
|
||||||
&:hover {
|
&.clickable {
|
||||||
box-shadow:
|
cursor: pointer;
|
||||||
0 10px 30px rgba(0, 0, 0, 0.1),
|
|
||||||
0 1px 8px rgba(0, 0, 0, 0.06);
|
&:hover {
|
||||||
|
box-shadow:
|
||||||
|
0 10px 30px rgba(0, 0, 0, 0.1),
|
||||||
|
0 1px 8px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.list-only {
|
||||||
|
opacity: 0.7;
|
||||||
|
background: $grey-97;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.password-protected {
|
||||||
|
// Keep full interactivity for password-protected items
|
||||||
}
|
}
|
||||||
|
|
||||||
&.odd {
|
&.odd {
|
||||||
flex-direction: row-reverse;
|
// flex-direction: row-reverse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,19 +248,33 @@
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 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) {
|
.logo-image {
|
||||||
width: 80px !important;
|
max-width: 100%;
|
||||||
height: 80px !important;
|
max-height: 100%;
|
||||||
border-radius: $unit-2x;
|
object-fit: contain;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.2, 2.1, 0.3, 0.95);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-svg {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.2, 2.1, 0.3, 0.95);
|
||||||
|
|
||||||
:global(svg) {
|
:global(svg) {
|
||||||
width: 48px !important;
|
width: 48px;
|
||||||
height: 48px !important;
|
height: 48px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,6 +290,27 @@
|
||||||
color: $grey-00;
|
color: $grey-00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-indicator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-half;
|
||||||
|
margin-top: $unit;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&.list-only {
|
||||||
|
color: $grey-60;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.password-protected {
|
||||||
|
color: $blue-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
.project-item {
|
.project-item {
|
||||||
flex-direction: column !important;
|
flex-direction: column !important;
|
||||||
|
|
@ -137,16 +321,6 @@
|
||||||
.project-logo {
|
.project-logo {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
|
||||||
:global(.svg-container) {
|
|
||||||
width: 60px !important;
|
|
||||||
height: 60px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(svg) {
|
|
||||||
width: 36px !important;
|
|
||||||
height: 36px !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,12 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ProjectItem from '$components/ProjectItem.svelte'
|
import ProjectItem from '$components/ProjectItem.svelte'
|
||||||
|
import type { Project } from '$lib/types/project'
|
||||||
|
|
||||||
import MaitsuLogo from '$illos/logo-maitsu.svg?component'
|
interface Props {
|
||||||
import SlackLogo from '$illos/logo-slack.svg?component'
|
projects: Project[]
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects: Project[] = [
|
let { projects = [] }: Props = $props()
|
||||||
{
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="projects">
|
<section class="projects">
|
||||||
|
|
@ -54,16 +14,32 @@
|
||||||
<li>
|
<li>
|
||||||
<div class="intro-card">
|
<div class="intro-card">
|
||||||
<p class="intro-text">
|
<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>
|
||||||
<p class="intro-text">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
{#if projects.length === 0}
|
||||||
|
<li>
|
||||||
|
<div class="no-projects">No projects found</div>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
{#each projects as project, index}
|
{#each projects as project, index}
|
||||||
<li>
|
<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>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -108,7 +84,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlighted {
|
.highlighted {
|
||||||
color: #D0290D;
|
color: #d0290d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.no-projects {
|
||||||
|
padding: $unit-3x;
|
||||||
|
text-align: center;
|
||||||
|
color: $grey-40;
|
||||||
|
}
|
||||||
</style>
|
</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>
|
||||||
|
|
@ -16,9 +16,9 @@
|
||||||
|
|
||||||
const navItems: NavItem[] = [
|
const navItems: NavItem[] = [
|
||||||
{ icon: WorkIcon, text: 'Work', href: '/', variant: 'work' },
|
{ icon: WorkIcon, text: 'Work', href: '/', variant: 'work' },
|
||||||
|
{ icon: UniverseIcon, text: 'Universe', href: '/universe', variant: 'universe' },
|
||||||
{ icon: PhotosIcon, text: 'Photos', href: '/photos', variant: 'photos' },
|
{ icon: PhotosIcon, text: 'Photos', href: '/photos', variant: 'photos' },
|
||||||
{ icon: LabsIcon, text: 'Labs', href: '/labs', variant: 'labs' },
|
{ icon: LabsIcon, text: 'Labs', href: '/labs', variant: 'labs' }
|
||||||
{ icon: UniverseIcon, text: 'Universe', href: '/universe', variant: 'universe' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// Track hover state for each item
|
// Track hover state for each item
|
||||||
|
|
@ -26,11 +26,15 @@
|
||||||
|
|
||||||
// Calculate active index based on current path
|
// Calculate active index based on current path
|
||||||
const activeIndex = $derived(
|
const activeIndex = $derived(
|
||||||
currentPath === '/' ? 0 :
|
currentPath === '/'
|
||||||
currentPath.startsWith('/photos') ? 1 :
|
? 0
|
||||||
currentPath.startsWith('/labs') ? 2 :
|
: currentPath.startsWith('/universe')
|
||||||
currentPath.startsWith('/universe') ? 3 :
|
? 1
|
||||||
-1
|
: currentPath.startsWith('/photos')
|
||||||
|
? 2
|
||||||
|
: currentPath.startsWith('/labs')
|
||||||
|
? 3
|
||||||
|
: -1
|
||||||
)
|
)
|
||||||
|
|
||||||
// Calculate pill position and width
|
// Calculate pill position and width
|
||||||
|
|
@ -67,22 +71,32 @@
|
||||||
// Get background color based on variant
|
// Get background color based on variant
|
||||||
function getBgColor(variant: string): string {
|
function getBgColor(variant: string): string {
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'work': return '#ffcdc5' // $work-bg
|
case 'work':
|
||||||
case 'photos': return '#e8c5ff' // $photos-bg (purple)
|
return '#ffcdc5' // $work-bg
|
||||||
case 'universe': return '#ffebc5' // $universe-bg
|
case 'photos':
|
||||||
case 'labs': return '#c5eaff' // $labs-bg
|
return '#e8c5ff' // $photos-bg (purple)
|
||||||
default: return '#c5eaff'
|
case 'universe':
|
||||||
|
return '#ffebc5' // $universe-bg
|
||||||
|
case 'labs':
|
||||||
|
return '#c5eaff' // $labs-bg
|
||||||
|
default:
|
||||||
|
return '#c5eaff'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get text color based on variant
|
// Get text color based on variant
|
||||||
function getTextColor(variant: string): string {
|
function getTextColor(variant: string): string {
|
||||||
switch (variant) {
|
switch (variant) {
|
||||||
case 'work': return '#d0290d' // $work-color
|
case 'work':
|
||||||
case 'photos': return '#7c3aed' // $photos-color (purple)
|
return '#d0290d' // $work-color
|
||||||
case 'universe': return '#b97d14' // $universe-color
|
case 'photos':
|
||||||
case 'labs': return '#1482c1' // $labs-color
|
return '#7c3aed' // $photos-color (purple)
|
||||||
default: return '#1482c1'
|
case 'universe':
|
||||||
|
return '#b97d14' // $universe-color
|
||||||
|
case 'labs':
|
||||||
|
return '#1482c1' // $labs-color
|
||||||
|
default:
|
||||||
|
return '#1482c1'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -102,10 +116,13 @@
|
||||||
class:active={index === activeIndex}
|
class:active={index === activeIndex}
|
||||||
bind:this={itemElements[index]}
|
bind:this={itemElements[index]}
|
||||||
style="color: {index === activeIndex ? getTextColor(item.variant) : '#666'};"
|
style="color: {index === activeIndex ? getTextColor(item.variant) : '#666'};"
|
||||||
onmouseenter={() => hoveredIndex = index}
|
onmouseenter={() => (hoveredIndex = index)}
|
||||||
onmouseleave={() => hoveredIndex = null}
|
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>
|
<span>{item.text}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -145,10 +162,11 @@
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
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) {
|
&:hover:not(.active) {
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
|
@ -165,43 +183,39 @@
|
||||||
animation: iconPulse 0.6s ease;
|
animation: iconPulse 0.6s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Different animations for each nav item
|
// 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) {
|
.nav-item:nth-of-type(1) :global(svg.animate) {
|
||||||
animation: cursorWiggle 0.6s ease;
|
animation: cursorWiggle 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second item is Photos (index 2) - animation handled by individual rect animations
|
// Second item is Universe
|
||||||
|
.nav-item:nth-of-type(2) :global(svg.animate) {
|
||||||
// Third item is Labs (index 3)
|
|
||||||
.nav-item:nth-of-type(3) :global(svg.animate) {
|
|
||||||
animation: tubeRotate 0.6s ease;
|
|
||||||
transform-origin: center bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fourth item is Universe (index 4)
|
|
||||||
.nav-item:nth-of-type(4) :global(svg.animate) {
|
|
||||||
animation: starSpin 0.6s ease;
|
animation: starSpin 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Specific animation for photo masonry rectangles
|
// Third item is Photos - animation handled by individual rect animations
|
||||||
.nav-item:nth-of-type(2) :global(svg.animate rect:nth-child(1)) {
|
.nav-item:nth-of-type(3) :global(svg.animate rect:nth-child(1)) {
|
||||||
animation: masonryRect1 0.6s ease;
|
animation: masonryRect1 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:nth-of-type(2) :global(svg.animate rect:nth-child(2)) {
|
.nav-item:nth-of-type(3) :global(svg.animate rect:nth-child(2)) {
|
||||||
animation: masonryRect2 0.6s ease;
|
animation: masonryRect2 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:nth-of-type(2) :global(svg.animate rect:nth-child(3)) {
|
.nav-item:nth-of-type(3) :global(svg.animate rect:nth-child(3)) {
|
||||||
animation: masonryRect3 0.6s ease;
|
animation: masonryRect3 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:nth-of-type(2) :global(svg.animate rect:nth-child(4)) {
|
.nav-item:nth-of-type(3) :global(svg.animate rect:nth-child(4)) {
|
||||||
animation: masonryRect4 0.6s ease;
|
animation: masonryRect4 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fourth item is Labs
|
||||||
|
.nav-item:nth-of-type(4) :global(svg.animate) {
|
||||||
|
animation: tubeRotate 0.6s ease;
|
||||||
|
transform-origin: center bottom;
|
||||||
|
}
|
||||||
</style>
|
</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>
|
||||||
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>
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue