Admin WIP
Projects and Posts sorta work, need design help
This commit is contained in:
parent
322427c118
commit
80d54aaaf0
130 changed files with 17177 additions and 466 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"
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -19,3 +19,8 @@ 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
|
||||||
|
|
|
||||||
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.
|
||||||
|
|
@ -1,25 +1,29 @@
|
||||||
# Product Requirements Document: Multi-Content CMS
|
# Product Requirements Document: Multi-Content CMS
|
||||||
|
|
||||||
## Overview
|
## 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).
|
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
|
## Goals
|
||||||
|
|
||||||
- Enable dynamic content creation across all site sections
|
- Enable dynamic content creation across all site sections
|
||||||
- Provide rich text editing for long-form content (BlockNote)
|
- Provide rich text editing for long-form content (Edra)
|
||||||
- Support different content types with appropriate editing interfaces
|
- Support different content types with appropriate editing interfaces
|
||||||
- Store all content in PostgreSQL database (Railway-compatible)
|
- Store all content in PostgreSQL database (Railway-compatible)
|
||||||
- Display content instantly after publishing
|
- Display content instantly after publishing
|
||||||
- Maintain the existing design aesthetic
|
- Maintain the existing design aesthetic
|
||||||
|
|
||||||
## Technical Constraints
|
## Technical Constraints
|
||||||
|
|
||||||
- **Hosting**: Railway (no direct file system access)
|
- **Hosting**: Railway (no direct file system access)
|
||||||
- **Database**: PostgreSQL add-on available
|
- **Database**: PostgreSQL add-on available
|
||||||
- **Framework**: SvelteKit
|
- **Framework**: SvelteKit
|
||||||
- **Editor**: BlockNote for rich text, custom forms for structured data
|
- **Editor**: Edra for rich text (https://edra.tsuzat.com/docs), custom forms for structured data
|
||||||
|
|
||||||
## Core Features
|
## Core Features
|
||||||
|
|
||||||
### 1. Database Schema
|
### 1. Database Schema
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Projects table (for /work)
|
-- Projects table (for /work)
|
||||||
CREATE TABLE projects (
|
CREATE TABLE projects (
|
||||||
|
|
@ -35,7 +39,7 @@ CREATE TABLE projects (
|
||||||
featured_image VARCHAR(500),
|
featured_image VARCHAR(500),
|
||||||
gallery JSONB, -- Array of image URLs
|
gallery JSONB, -- Array of image URLs
|
||||||
external_url VARCHAR(500),
|
external_url VARCHAR(500),
|
||||||
case_study_content JSONB, -- BlockNote JSON format
|
case_study_content JSONB, -- Edra JSON format
|
||||||
display_order INTEGER DEFAULT 0,
|
display_order INTEGER DEFAULT 0,
|
||||||
status VARCHAR(50) DEFAULT 'draft',
|
status VARCHAR(50) DEFAULT 'draft',
|
||||||
published_at TIMESTAMP,
|
published_at TIMESTAMP,
|
||||||
|
|
@ -49,7 +53,7 @@ CREATE TABLE posts (
|
||||||
slug VARCHAR(255) UNIQUE NOT NULL,
|
slug VARCHAR(255) UNIQUE NOT NULL,
|
||||||
post_type VARCHAR(50) NOT NULL, -- blog, microblog, link, photo, album
|
post_type VARCHAR(50) NOT NULL, -- blog, microblog, link, photo, album
|
||||||
title VARCHAR(255), -- Optional for microblog posts
|
title VARCHAR(255), -- Optional for microblog posts
|
||||||
content JSONB, -- BlockNote JSON for blog/microblog, optional for others
|
content JSONB, -- Edra JSON for blog/microblog, optional for others
|
||||||
excerpt TEXT,
|
excerpt TEXT,
|
||||||
|
|
||||||
-- Type-specific fields
|
-- Type-specific fields
|
||||||
|
|
@ -121,9 +125,10 @@ CREATE TABLE media (
|
||||||
|
|
||||||
### 2. Image Handling Strategy
|
### 2. Image Handling Strategy
|
||||||
|
|
||||||
#### For Posts (BlockNote Integration)
|
#### For Posts (Edra Integration)
|
||||||
|
|
||||||
- **Storage**: Images embedded in posts are stored in the `media` table
|
- **Storage**: Images embedded in posts are stored in the `media` table
|
||||||
- **BlockNote Custom Block**: Create custom image block that:
|
- **Edra Custom Block**: Create custom image block that:
|
||||||
- Uploads to `/api/media/upload` on drop/paste
|
- Uploads to `/api/media/upload` on drop/paste
|
||||||
- Returns media ID and URL
|
- Returns media ID and URL
|
||||||
- Stores reference as `{ type: "image", mediaId: 123, url: "...", alt: "..." }`
|
- Stores reference as `{ type: "image", mediaId: 123, url: "...", alt: "..." }`
|
||||||
|
|
@ -134,21 +139,23 @@ CREATE TABLE media (
|
||||||
- No orphaned images (tracked by mediaId)
|
- No orphaned images (tracked by mediaId)
|
||||||
|
|
||||||
#### For Projects
|
#### For Projects
|
||||||
|
|
||||||
- **Featured Image**: Single image reference stored in `featured_image` field
|
- **Featured Image**: Single image reference stored in `featured_image` field
|
||||||
- **Gallery Images**: Array of media IDs stored in `gallery` JSONB field
|
- **Gallery Images**: Array of media IDs stored in `gallery` JSONB field
|
||||||
- **Case Study Content**: Uses same BlockNote approach as Posts
|
- **Case Study Content**: Uses same Edra approach as Posts
|
||||||
- **Storage Pattern**:
|
- **Storage Pattern**:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"featured_image": "https://cdn.../image1.jpg",
|
"featured_image": "https://cdn.../image1.jpg",
|
||||||
"gallery": [
|
"gallery": [
|
||||||
{ "mediaId": 123, "url": "...", "caption": "..." },
|
{ "mediaId": 123, "url": "...", "caption": "..." },
|
||||||
{ "mediaId": 124, "url": "...", "caption": "..." }
|
{ "mediaId": 124, "url": "...", "caption": "..." }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Media Table Enhancement
|
#### Media Table Enhancement
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- Add content associations to media table
|
-- Add content associations to media table
|
||||||
ALTER TABLE media ADD COLUMN used_in JSONB DEFAULT '[]';
|
ALTER TABLE media ADD COLUMN used_in JSONB DEFAULT '[]';
|
||||||
|
|
@ -156,13 +163,14 @@ ALTER TABLE media ADD COLUMN used_in JSONB DEFAULT '[]';
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Content Type Editors
|
### 3. Content Type Editors
|
||||||
|
|
||||||
- **Projects**: Form-based editor with:
|
- **Projects**: Form-based editor with:
|
||||||
- Metadata fields (title, year, client, role)
|
- Metadata fields (title, year, client, role)
|
||||||
- Technology tag selector
|
- Technology tag selector
|
||||||
- Featured image picker (opens media library)
|
- Featured image picker (opens media library)
|
||||||
- Gallery manager (grid view with reordering)
|
- Gallery manager (grid view with reordering)
|
||||||
- Optional BlockNote editor for case studies
|
- Optional Edra editor for case studies
|
||||||
- **Posts**: Full BlockNote editor with:
|
- **Posts**: Full Edra editor with:
|
||||||
- Custom image block implementation
|
- Custom image block implementation
|
||||||
- Drag-and-drop image upload
|
- Drag-and-drop image upload
|
||||||
- Media library integration
|
- Media library integration
|
||||||
|
|
@ -174,10 +182,10 @@ ALTER TABLE media ADD COLUMN used_in JSONB DEFAULT '[]';
|
||||||
- EXIF data extraction
|
- EXIF data extraction
|
||||||
- Album metadata editing
|
- Album metadata editing
|
||||||
|
|
||||||
### 4. BlockNote Custom Image Block
|
### 4. Edra Custom Image Block
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Custom image block schema for BlockNote
|
// Custom image block schema for Edra
|
||||||
const ImageBlock = {
|
const ImageBlock = {
|
||||||
type: "image",
|
type: "image",
|
||||||
content: {
|
content: {
|
||||||
|
|
@ -192,7 +200,7 @@ const ImageBlock = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example BlockNote content with images
|
// Example Edra content with images
|
||||||
{
|
{
|
||||||
"blocks": [
|
"blocks": [
|
||||||
{ "type": "heading", "content": "Project Overview" },
|
{ "type": "heading", "content": "Project Overview" },
|
||||||
|
|
@ -215,7 +223,7 @@ const ImageBlock = {
|
||||||
|
|
||||||
### 5. Media Library Component
|
### 5. Media Library Component
|
||||||
|
|
||||||
- **Modal Interface**: Opens from BlockNote toolbar or form fields
|
- **Modal Interface**: Opens from Edra toolbar or form fields
|
||||||
- **Features**:
|
- **Features**:
|
||||||
- Grid view of all uploaded media
|
- Grid view of all uploaded media
|
||||||
- Search by filename
|
- Search by filename
|
||||||
|
|
@ -241,74 +249,78 @@ const ImageBlock = {
|
||||||
5. **Association**: Update `used_in` when embedded
|
5. **Association**: Update `used_in` when embedded
|
||||||
|
|
||||||
### 7. API Endpoints
|
### 7. API Endpoints
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Projects
|
// Projects
|
||||||
GET /api/projects
|
GET / api / projects
|
||||||
POST /api/projects
|
POST / api / projects
|
||||||
GET /api/projects/[slug]
|
GET / api / projects / [slug]
|
||||||
PUT /api/projects/[id]
|
PUT / api / projects / [id]
|
||||||
DELETE /api/projects/[id]
|
DELETE / api / projects / [id]
|
||||||
|
|
||||||
// Posts
|
// Posts
|
||||||
GET /api/posts
|
GET / api / posts
|
||||||
POST /api/posts
|
POST / api / posts
|
||||||
GET /api/posts/[slug]
|
GET / api / posts / [slug]
|
||||||
PUT /api/posts/[id]
|
PUT / api / posts / [id]
|
||||||
DELETE /api/posts/[id]
|
DELETE / api / posts / [id]
|
||||||
|
|
||||||
// Albums & Photos
|
// Albums & Photos
|
||||||
GET /api/albums
|
GET / api / albums
|
||||||
POST /api/albums
|
POST / api / albums
|
||||||
GET /api/albums/[slug]
|
GET / api / albums / [slug]
|
||||||
PUT /api/albums/[id]
|
PUT / api / albums / [id]
|
||||||
DELETE /api/albums/[id]
|
DELETE / api / albums / [id]
|
||||||
POST /api/albums/[id]/photos
|
POST / api / albums / [id] / photos
|
||||||
DELETE /api/photos/[id]
|
DELETE / api / photos / [id]
|
||||||
PUT /api/photos/[id]/order
|
PUT / api / photos / [id] / order
|
||||||
|
|
||||||
// Media upload
|
// Media upload
|
||||||
POST /api/media/upload
|
POST / api / media / upload
|
||||||
POST /api/media/bulk-upload
|
POST / api / media / bulk - upload
|
||||||
GET /api/media // Browse with filters
|
GET / api / media // Browse with filters
|
||||||
DELETE /api/media/[id] // Delete if unused
|
DELETE / api / media / [id] // Delete if unused
|
||||||
GET /api/media/[id]/usage // Check where media is used
|
GET / api / media / [id] / usage // Check where media is used
|
||||||
```
|
```
|
||||||
|
|
||||||
### 8. Media Management & Cleanup
|
### 8. Media Management & Cleanup
|
||||||
|
|
||||||
#### Orphaned Media Prevention
|
#### Orphaned Media Prevention
|
||||||
|
|
||||||
- **Reference Tracking**: `used_in` field tracks all content using each media item
|
- **Reference Tracking**: `used_in` field tracks all content using each media item
|
||||||
- **On Save**: Update media associations when content is saved
|
- **On Save**: Update media associations when content is saved
|
||||||
- **On Delete**: Remove associations when content is deleted
|
- **On Delete**: Remove associations when content is deleted
|
||||||
- **Cleanup Task**: Periodic job to identify truly orphaned media
|
- **Cleanup Task**: Periodic job to identify truly orphaned media
|
||||||
|
|
||||||
#### BlockNote Integration Details
|
#### Edra Integration Details
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Custom upload handler for BlockNote
|
// Custom upload handler for BlockNote
|
||||||
const handleImageUpload = async (file) => {
|
const handleImageUpload = async (file) => {
|
||||||
const formData = new FormData();
|
const formData = new FormData()
|
||||||
formData.append('file', file);
|
formData.append('file', file)
|
||||||
formData.append('context', 'post'); // or 'project'
|
formData.append('context', 'post') // or 'project'
|
||||||
|
|
||||||
const response = await fetch('/api/media/upload', {
|
const response = await fetch('/api/media/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
})
|
||||||
|
|
||||||
const media = await response.json();
|
const media = await response.json()
|
||||||
|
|
||||||
// Return format expected by BlockNote
|
// Return format expected by Edra
|
||||||
return {
|
return {
|
||||||
mediaId: media.id,
|
mediaId: media.id,
|
||||||
url: media.url,
|
url: media.url,
|
||||||
thumbnailUrl: media.thumbnail_url,
|
thumbnailUrl: media.thumbnail_url,
|
||||||
width: media.width,
|
width: media.width,
|
||||||
height: media.height
|
height: media.height
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9. Admin Interface
|
### 9. Admin Interface
|
||||||
|
|
||||||
- **Route**: `/admin` (completely separate from public routes)
|
- **Route**: `/admin` (completely separate from public routes)
|
||||||
- **Dashboard**: Overview of all content types
|
- **Dashboard**: Overview of all content types
|
||||||
- **Content Lists**:
|
- **Content Lists**:
|
||||||
|
|
@ -319,6 +331,7 @@ const handleImageUpload = async (file) => {
|
||||||
- **Media Library**: Browse all uploaded files
|
- **Media Library**: Browse all uploaded files
|
||||||
|
|
||||||
### 10. Public Display Integration
|
### 10. Public Display Integration
|
||||||
|
|
||||||
- **Work page**: Dynamic project grid from database
|
- **Work page**: Dynamic project grid from database
|
||||||
- **Universe page**:
|
- **Universe page**:
|
||||||
- Mixed feed of posts and albums (marked with `show_in_universe`)
|
- Mixed feed of posts and albums (marked with `show_in_universe`)
|
||||||
|
|
@ -330,24 +343,28 @@ const handleImageUpload = async (file) => {
|
||||||
## Implementation Phases
|
## Implementation Phases
|
||||||
|
|
||||||
### Phase 1: Foundation (Week 1)
|
### Phase 1: Foundation (Week 1)
|
||||||
|
|
||||||
- Set up PostgreSQL database with full schema
|
- Set up PostgreSQL database with full schema
|
||||||
- Create database connection utilities
|
- Create database connection utilities
|
||||||
- Implement media upload infrastructure
|
- Implement media upload infrastructure
|
||||||
- Build admin route structure and navigation
|
- Build admin route structure and navigation
|
||||||
|
|
||||||
### Phase 2: Content Types (Week 2-3)
|
### Phase 2: Content Types (Week 2-3)
|
||||||
- **Posts**: BlockNote integration, CRUD APIs
|
|
||||||
|
- **Posts**: Edra integration, CRUD APIs
|
||||||
- **Projects**: Form builder, gallery management
|
- **Projects**: Form builder, gallery management
|
||||||
- **Albums/Photos**: Bulk upload, EXIF extraction
|
- **Albums/Photos**: Bulk upload, EXIF extraction
|
||||||
- Create content type list views in admin
|
- Create content type list views in admin
|
||||||
|
|
||||||
### Phase 3: Public Display (Week 4)
|
### Phase 3: Public Display (Week 4)
|
||||||
|
|
||||||
- Replace static project data with dynamic
|
- Replace static project data with dynamic
|
||||||
- Build Universe mixed feed (posts + albums)
|
- Build Universe mixed feed (posts + albums)
|
||||||
- Update Photos page with dynamic albums
|
- Update Photos page with dynamic albums
|
||||||
- Implement individual content pages
|
- Implement individual content pages
|
||||||
|
|
||||||
### Phase 4: Polish & Optimization (Week 5)
|
### Phase 4: Polish & Optimization (Week 5)
|
||||||
|
|
||||||
- Image optimization and CDN caching
|
- Image optimization and CDN caching
|
||||||
- Admin UI improvements
|
- Admin UI improvements
|
||||||
- Search and filtering
|
- Search and filtering
|
||||||
|
|
@ -356,12 +373,15 @@ const handleImageUpload = async (file) => {
|
||||||
## Technical Decisions
|
## Technical Decisions
|
||||||
|
|
||||||
### Database Choice: PostgreSQL
|
### Database Choice: PostgreSQL
|
||||||
- Native JSON support for BlockNote content
|
|
||||||
|
- Native JSON support for Edra content
|
||||||
- Railway provides managed PostgreSQL
|
- Railway provides managed PostgreSQL
|
||||||
- Familiar, battle-tested solution
|
- Familiar, battle-tested solution
|
||||||
|
|
||||||
### Media Storage Options
|
### Media Storage Options
|
||||||
|
|
||||||
1. **Cloudinary** (Recommended)
|
1. **Cloudinary** (Recommended)
|
||||||
|
|
||||||
- Free tier sufficient for personal use
|
- Free tier sufficient for personal use
|
||||||
- Automatic image optimization
|
- Automatic image optimization
|
||||||
- Easy API integration
|
- Easy API integration
|
||||||
|
|
@ -371,50 +391,57 @@ const handleImageUpload = async (file) => {
|
||||||
- Additional complexity for signed URLs
|
- Additional complexity for signed URLs
|
||||||
|
|
||||||
### Image Integration Summary
|
### Image Integration Summary
|
||||||
- **Posts**: Use BlockNote's custom image blocks with inline placement
|
|
||||||
|
- **Posts**: Use Edra's custom image blocks with inline placement
|
||||||
- **Projects**:
|
- **Projects**:
|
||||||
- Featured image: Single media reference
|
- Featured image: Single media reference
|
||||||
- Gallery: Array of media IDs with ordering
|
- Gallery: Array of media IDs with ordering
|
||||||
- Case studies: BlockNote blocks (same as posts)
|
- Case studies: Edra blocks (same as posts)
|
||||||
- **Albums**: Direct photos table relationship
|
- **Albums**: Direct photos table relationship
|
||||||
- **Storage**: All images go through media table for consistent handling
|
- **Storage**: All images go through media table for consistent handling
|
||||||
- **Association**: Track usage with `used_in` JSONB field to prevent orphans
|
- **Association**: Track usage with `used_in` JSONB field to prevent orphans
|
||||||
|
|
||||||
### Authentication (Future)
|
### Authentication (Future)
|
||||||
|
|
||||||
- Initially: No auth (rely on obscure admin URL)
|
- Initially: No auth (rely on obscure admin URL)
|
||||||
- Future: Add simple password protection or OAuth
|
- Future: Add simple password protection or OAuth
|
||||||
|
|
||||||
## Development Checklist
|
## Development Checklist
|
||||||
|
|
||||||
### Infrastructure
|
### Infrastructure
|
||||||
|
|
||||||
- [ ] Set up PostgreSQL on Railway
|
- [ ] Set up PostgreSQL on Railway
|
||||||
- [ ] Create database schema and migrations
|
- [ ] Create database schema and migrations
|
||||||
- [ ] Set up Cloudinary/S3 for media storage
|
- [ ] Set up Cloudinary/S3 for media storage
|
||||||
- [ ] Configure environment variables
|
- [ ] Configure environment variables
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
- [ ] `@blocknote/core` & `@blocknote/react`
|
|
||||||
|
- [ ] `edra` (Edra editor)
|
||||||
- [ ] `@prisma/client` or `postgres` driver
|
- [ ] `@prisma/client` or `postgres` driver
|
||||||
- [ ] `exifr` for EXIF data extraction
|
- [ ] `exifr` for EXIF data extraction
|
||||||
- [ ] `sharp` or Cloudinary SDK for image processing
|
- [ ] `sharp` or Cloudinary SDK for image processing
|
||||||
- [ ] Form validation library (Zod/Valibot)
|
- [ ] Form validation library (Zod/Valibot)
|
||||||
|
|
||||||
### Admin Interface
|
### Admin Interface
|
||||||
|
|
||||||
- [ ] Admin layout and navigation
|
- [ ] Admin layout and navigation
|
||||||
- [ ] Content type switcher
|
- [ ] Content type switcher
|
||||||
- [ ] List views for each content type
|
- [ ] List views for each content type
|
||||||
- [ ] Form builders for Projects
|
- [ ] Form builders for Projects
|
||||||
- [ ] BlockNote wrapper for Posts
|
- [ ] Edra wrapper for Posts
|
||||||
- [ ] Photo uploader with drag-and-drop
|
- [ ] Photo uploader with drag-and-drop
|
||||||
- [ ] Media library browser
|
- [ ] Media library browser
|
||||||
|
|
||||||
### APIs
|
### APIs
|
||||||
|
|
||||||
- [ ] CRUD endpoints for all content types
|
- [ ] CRUD endpoints for all content types
|
||||||
- [ ] Media upload with progress
|
- [ ] Media upload with progress
|
||||||
- [ ] Bulk operations (delete, publish)
|
- [ ] Bulk operations (delete, publish)
|
||||||
- [ ] Search and filtering endpoints
|
- [ ] Search and filtering endpoints
|
||||||
|
|
||||||
### Public Display
|
### Public Display
|
||||||
|
|
||||||
- [ ] Dynamic Work page
|
- [ ] Dynamic Work page
|
||||||
- [ ] Mixed Universe feed
|
- [ ] Mixed Universe feed
|
||||||
- [ ] Photos masonry grid
|
- [ ] Photos masonry grid
|
||||||
|
|
@ -432,66 +459,116 @@ Based on requirements discussion:
|
||||||
5. **Scheduled Publishing**: Not needed initially
|
5. **Scheduled Publishing**: Not needed initially
|
||||||
6. **RSS Feeds**: Required for all content types (projects, posts, photos)
|
6. **RSS Feeds**: Required for all content types (projects, posts, photos)
|
||||||
7. **Post Types**: Universe will support multiple post types:
|
7. **Post Types**: Universe will support multiple post types:
|
||||||
- **Blog Post**: Title + long-form BlockNote content
|
- **Blog Post**: Title + long-form Edra content
|
||||||
- **Microblog**: No title, short-form BlockNote content
|
- **Microblog**: No title, short-form Edra content
|
||||||
- **Link Post**: URL + optional commentary
|
- **Link Post**: URL + optional commentary
|
||||||
- **Photo Post**: Single photo + caption
|
- **Photo Post**: Single photo + caption
|
||||||
- **Album Post**: Reference to photo album
|
- **Album Post**: Reference to photo album
|
||||||
|
|
||||||
|
## Current Status (December 2024)
|
||||||
|
|
||||||
|
### Completed
|
||||||
|
- ✅ Database setup with Prisma and PostgreSQL
|
||||||
|
- ✅ Media management system with Cloudinary integration
|
||||||
|
- ✅ Admin foundation (layout, navigation, auth, forms, data tables)
|
||||||
|
- ✅ Edra rich text editor integration for case studies
|
||||||
|
- ✅ Edra image uploads configured to use media API
|
||||||
|
- ✅ Local development mode for media uploads (no Cloudinary usage)
|
||||||
|
- ✅ Project CRUD system with metadata fields
|
||||||
|
- ✅ Project list view in admin
|
||||||
|
- ✅ Test page for verifying upload functionality
|
||||||
|
|
||||||
|
### In Progress
|
||||||
|
- 🔄 Posts System - Core functionality implemented
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
1. **Posts System Enhancements**
|
||||||
|
- Media library modal for photo/album post types
|
||||||
|
- Auto-save functionality
|
||||||
|
- Preview mode for posts
|
||||||
|
- Tags/categories management UI
|
||||||
|
|
||||||
|
2. **Projects System Enhancements**
|
||||||
|
- Technology tag selector
|
||||||
|
- Featured image picker with media library
|
||||||
|
- Gallery manager for project images
|
||||||
|
- Project ordering/display order
|
||||||
|
|
||||||
|
4. **Photos & Albums System**
|
||||||
|
- Album creation and management
|
||||||
|
- Bulk photo upload interface
|
||||||
|
- Photo ordering within albums
|
||||||
|
|
||||||
## Phased Implementation Plan
|
## 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
|
### Phase 1: Database & Infrastructure Setup
|
||||||
- [ ] Set up PostgreSQL on Railway
|
|
||||||
- [ ] Create all database tables with updated schema
|
- [x] Create all database tables with updated schema
|
||||||
- [ ] Set up Prisma ORM with models
|
- [x] Set up Prisma ORM with models
|
||||||
- [ ] Configure Cloudinary account and API keys
|
- [x] Create base API route structure
|
||||||
- [ ] Create base API route structure
|
- [x] Implement database connection utilities
|
||||||
- [ ] Implement database connection utilities
|
- [x] Set up error handling and logging
|
||||||
- [ ] 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
|
### Phase 2: Media Management System
|
||||||
- [ ] Create media upload endpoint with Cloudinary integration
|
|
||||||
- [ ] Implement image processing pipeline (multiple sizes)
|
- [x] Create media upload endpoint with Cloudinary integration
|
||||||
- [ ] Build media library API endpoints
|
- [x] Implement image processing pipeline (multiple sizes)
|
||||||
- [ ] Create media association tracking system
|
- [x] Build media library API endpoints
|
||||||
- [ ] Implement EXIF data extraction for photos
|
- [x] Create media association tracking system
|
||||||
- [ ] Add bulk upload endpoint for photos
|
- [x] Add bulk upload endpoint for photos
|
||||||
- [ ] Create media usage tracking queries
|
- [x] Create media usage tracking queries
|
||||||
|
|
||||||
### Phase 3: Admin Foundation
|
### Phase 3: Admin Foundation
|
||||||
- [ ] Create admin layout component
|
|
||||||
- [ ] Build admin navigation with content type switcher
|
- [x] Create admin layout component
|
||||||
- [ ] Implement admin authentication (basic for now)
|
- [x] Build admin navigation with content type switcher
|
||||||
- [ ] Create reusable form components
|
- [x] Implement admin authentication (basic for now)
|
||||||
- [ ] Build data table component for list views
|
- [x] Create reusable form components
|
||||||
- [ ] Add loading and error states
|
- [x] Build data table component for list views
|
||||||
|
- [x] Add loading and error states
|
||||||
- [ ] Create media library modal component
|
- [ ] Create media library modal component
|
||||||
|
|
||||||
### Phase 4: Posts System (All Types)
|
### Phase 4: Posts System (All Types)
|
||||||
- [ ] Create BlockNote Svelte wrapper component
|
|
||||||
- [ ] Implement custom image block for BlockNote
|
- [x] Create Edra Svelte wrapper component
|
||||||
- [ ] Build post type selector UI
|
- [x] Implement custom image block for Edra
|
||||||
- [ ] Create blog/microblog post editor
|
- [x] Build post type selector UI
|
||||||
- [ ] Build link post form
|
- [x] Create blog/microblog post editor
|
||||||
|
- [x] Build link post form
|
||||||
|
- [x] Create posts list view in admin
|
||||||
|
- [x] Implement post CRUD APIs
|
||||||
|
- [x] Post editor page with type-specific fields
|
||||||
- [ ] Create photo post selector
|
- [ ] Create photo post selector
|
||||||
- [ ] Build album post selector
|
- [ ] Build album post selector
|
||||||
- [ ] Implement post CRUD APIs
|
|
||||||
- [ ] Add auto-save functionality
|
- [ ] Add auto-save functionality
|
||||||
- [ ] Create post list view in admin
|
|
||||||
|
|
||||||
### Phase 5: Projects System
|
### Phase 5: Projects System
|
||||||
- [ ] Build project form with all metadata fields
|
|
||||||
|
- [x] Build project form with all metadata fields
|
||||||
- [ ] Create technology tag selector
|
- [ ] Create technology tag selector
|
||||||
- [ ] Implement featured image picker
|
- [ ] Implement featured image picker
|
||||||
- [ ] Build gallery manager with drag-and-drop ordering
|
- [ ] Build gallery manager with drag-and-drop ordering
|
||||||
- [ ] Add optional BlockNote editor for case studies
|
- [x] Add optional Edra editor for case studies
|
||||||
- [ ] Create project CRUD APIs
|
- [x] Create project CRUD APIs
|
||||||
- [ ] Build project list view with thumbnails
|
- [x] Build project list view with thumbnails
|
||||||
- [ ] Add project ordering functionality
|
- [ ] Add project ordering functionality
|
||||||
|
|
||||||
### Phase 6: Photos & Albums System
|
### Phase 6: Photos & Albums System
|
||||||
|
|
||||||
- [ ] Create album management interface
|
- [ ] Create album management interface
|
||||||
- [ ] Build bulk photo uploader with progress
|
- [ ] Build bulk photo uploader with progress
|
||||||
|
- [ ] Implement EXIF data extraction for photos
|
||||||
- [ ] Implement drag-and-drop photo ordering
|
- [ ] Implement drag-and-drop photo ordering
|
||||||
- [ ] Add individual photo publishing UI
|
- [ ] Add individual photo publishing UI
|
||||||
- [ ] Create photo/album CRUD APIs
|
- [ ] Create photo/album CRUD APIs
|
||||||
|
|
@ -500,6 +577,7 @@ Based on requirements discussion:
|
||||||
- [ ] Add "show in universe" toggle for albums
|
- [ ] Add "show in universe" toggle for albums
|
||||||
|
|
||||||
### Phase 7: Public Display Updates
|
### Phase 7: Public Display Updates
|
||||||
|
|
||||||
- [ ] Replace static Work page with dynamic data
|
- [ ] Replace static Work page with dynamic data
|
||||||
- [ ] Update project detail pages
|
- [ ] Update project detail pages
|
||||||
- [ ] Build Universe mixed feed component
|
- [ ] Build Universe mixed feed component
|
||||||
|
|
@ -510,6 +588,7 @@ Based on requirements discussion:
|
||||||
- [ ] Ensure responsive design throughout
|
- [ ] Ensure responsive design throughout
|
||||||
|
|
||||||
### Phase 8: RSS Feeds & Final Polish
|
### Phase 8: RSS Feeds & Final Polish
|
||||||
|
|
||||||
- [ ] Implement RSS feed for projects
|
- [ ] Implement RSS feed for projects
|
||||||
- [ ] Create RSS feed for Universe posts
|
- [ ] Create RSS feed for Universe posts
|
||||||
- [ ] Add RSS feed for photos/albums
|
- [ ] Add RSS feed for photos/albums
|
||||||
|
|
@ -518,9 +597,20 @@ Based on requirements discussion:
|
||||||
- [ ] Optimize image loading and caching
|
- [ ] Optimize image loading and caching
|
||||||
- [ ] Add search functionality to admin
|
- [ ] Add search functionality to admin
|
||||||
- [ ] Performance optimization pass
|
- [ ] Performance optimization pass
|
||||||
- [ ] Final testing on Railway
|
|
||||||
|
### Phase 9: 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)
|
### Future Enhancements (Post-Launch)
|
||||||
|
|
||||||
- [ ] Version history system
|
- [ ] Version history system
|
||||||
- [ ] More robust authentication
|
- [ ] More robust authentication
|
||||||
- [ ] Project case study templates
|
- [ ] Project case study templates
|
||||||
|
|
@ -529,6 +619,7 @@ Based on requirements discussion:
|
||||||
- [ ] Backup system
|
- [ ] Backup system
|
||||||
|
|
||||||
## Success Metrics
|
## Success Metrics
|
||||||
|
|
||||||
- Can create and publish any content type within 2-3 minutes
|
- Can create and publish any content type within 2-3 minutes
|
||||||
- Content appears on site immediately after publishing
|
- Content appears on site immediately after publishing
|
||||||
- Bulk photo upload handles 50+ images smoothly
|
- Bulk photo upload handles 50+ images smoothly
|
||||||
|
|
|
||||||
2768
package-lock.json
generated
2768
package-lock.json
generated
File diff suppressed because it is too large
Load diff
56
package.json
56
package.json
|
|
@ -10,7 +10,11 @@
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"format": "prettier --write ."
|
"format": "prettier --write .",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:seed": "prisma db seed",
|
||||||
|
"db:studio": "prisma studio",
|
||||||
|
"setup:local": "./scripts/setup-local.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@musicorum/lastfm": "github:jedmund/lastfm",
|
"@musicorum/lastfm": "github:jedmund/lastfm",
|
||||||
|
|
@ -32,24 +36,70 @@
|
||||||
"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",
|
||||||
"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",
|
||||||
"tinyduration": "^3.3.1"
|
"svelte-tiptap": "^2.1.0",
|
||||||
|
"svgo": "^3.3.2",
|
||||||
|
"tinyduration": "^3.3.1",
|
||||||
|
"tippy.js": "^6.3.7",
|
||||||
|
"tiptap-extension-auto-joiner": "^0.1.3",
|
||||||
|
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||||
|
"tiptap-markdown": "^0.8.10",
|
||||||
|
"zod": "^3.25.30"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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);
|
||||||
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"
|
||||||
136
prisma/schema.prisma
Normal file
136
prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
// 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)
|
||||||
|
technologies Json? // Array of tech stack
|
||||||
|
featuredImage 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
|
||||||
|
displayOrder Int @default(0)
|
||||||
|
status String @default("draft") @db.VarChar(50)
|
||||||
|
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
|
||||||
|
excerpt String? @db.Text
|
||||||
|
|
||||||
|
// Type-specific fields
|
||||||
|
linkUrl String? @db.VarChar(500)
|
||||||
|
linkDescription String? @db.Text
|
||||||
|
photoId Int?
|
||||||
|
albumId Int?
|
||||||
|
|
||||||
|
featuredImage String? @db.VarChar(500)
|
||||||
|
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?
|
||||||
|
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)
|
||||||
|
mimeType String @db.VarChar(100)
|
||||||
|
size Int
|
||||||
|
url String @db.Text
|
||||||
|
thumbnailUrl String? @db.Text
|
||||||
|
width Int?
|
||||||
|
height Int?
|
||||||
|
usedIn Json @default("[]") // Track where media is used
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
233
prisma/seed.ts
Normal file
233
prisma/seed.ts
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
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',
|
||||||
|
technologies: ['React Native', 'TypeScript', 'Node.js', 'PostgreSQL'],
|
||||||
|
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',
|
||||||
|
technologies: ['Design Systems', 'User Research', 'Prototyping', 'Strategy'],
|
||||||
|
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',
|
||||||
|
technologies: ['Product Design', 'Prototyping', 'User Research', 'Design Systems'],
|
||||||
|
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',
|
||||||
|
technologies: ['Product Design', 'Mobile Design', 'Design Leadership', 'Visual Design'],
|
||||||
|
featuredImage: '/images/projects/pinterest-cover.png',
|
||||||
|
backgroundColor: '#f7f7f7',
|
||||||
|
highlightColor: '#CB1F27',
|
||||||
|
displayOrder: 4,
|
||||||
|
status: 'published',
|
||||||
|
publishedAt: new Date()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log(`✅ Created ${projects.length} projects`)
|
||||||
|
|
||||||
|
// Create test posts
|
||||||
|
const posts = await Promise.all([
|
||||||
|
prisma.post.create({
|
||||||
|
data: {
|
||||||
|
slug: 'hello-world',
|
||||||
|
postType: 'blog',
|
||||||
|
title: 'Hello World',
|
||||||
|
content: {
|
||||||
|
blocks: [
|
||||||
|
{ type: 'paragraph', content: 'This is my first blog post on the new CMS!' },
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'The system supports multiple post types and rich content editing.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
excerpt: 'Welcome to my new blog powered by a custom CMS.',
|
||||||
|
tags: ['announcement', 'meta'],
|
||||||
|
status: 'published',
|
||||||
|
publishedAt: new Date()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.post.create({
|
||||||
|
data: {
|
||||||
|
slug: 'quick-thought',
|
||||||
|
postType: 'microblog',
|
||||||
|
content: {
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Just pushed a major update to the site. Feeling good about the progress!'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
status: 'published',
|
||||||
|
publishedAt: new Date(Date.now() - 86400000) // Yesterday
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.post.create({
|
||||||
|
data: {
|
||||||
|
slug: 'great-article',
|
||||||
|
postType: 'link',
|
||||||
|
title: 'Great Article on Web Performance',
|
||||||
|
linkUrl: 'https://web.dev/performance',
|
||||||
|
linkDescription:
|
||||||
|
'This article perfectly explains the core web vitals and how to optimize for them.',
|
||||||
|
status: 'published',
|
||||||
|
publishedAt: new Date(Date.now() - 172800000) // 2 days ago
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
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',
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
await prisma.album.update({
|
||||||
|
where: { id: album.id },
|
||||||
|
data: { coverPhotoId: photos[0].id }
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`✅ Created album with ${photos.length} photos`)
|
||||||
|
|
||||||
|
// Create test media entries
|
||||||
|
const media = await Promise.all([
|
||||||
|
prisma.media.create({
|
||||||
|
data: {
|
||||||
|
filename: 'blog-header.jpg',
|
||||||
|
mimeType: 'image/jpeg',
|
||||||
|
size: 245000,
|
||||||
|
url: '/local-uploads/blog-header.jpg',
|
||||||
|
thumbnailUrl: '/local-uploads/thumb-blog-header.jpg',
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
usedIn: [{ type: 'post', id: posts[0].id }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
])
|
||||||
|
|
||||||
|
console.log(`✅ Created ${media.length} media items`)
|
||||||
|
|
||||||
|
console.log('🎉 Seed completed!')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('❌ Seed failed:', e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect()
|
||||||
|
})
|
||||||
39
scripts/setup-local.sh
Executable file
39
scripts/setup-local.sh
Executable file
|
|
@ -0,0 +1,39 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "🚀 Setting up local development environment..."
|
||||||
|
|
||||||
|
# Check if PostgreSQL is installed
|
||||||
|
if ! command -v psql &> /dev/null; then
|
||||||
|
echo "❌ PostgreSQL is not installed. Please install it first:"
|
||||||
|
echo " brew install postgresql@15"
|
||||||
|
echo " brew services start postgresql@15"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if .env exists
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
echo "📝 Creating .env file from .env.local.example..."
|
||||||
|
cp .env.local.example .env
|
||||||
|
echo "✅ .env file created. Please update it with your local settings."
|
||||||
|
else
|
||||||
|
echo "✅ .env file already exists"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create database
|
||||||
|
echo "🗄️ Creating local database..."
|
||||||
|
createdb universe 2>/dev/null || echo "Database already exists"
|
||||||
|
|
||||||
|
# Run Prisma commands
|
||||||
|
echo "🔧 Generating Prisma client..."
|
||||||
|
npx prisma generate
|
||||||
|
|
||||||
|
echo "📊 Running database migrations..."
|
||||||
|
npx prisma migrate dev --name initial_setup
|
||||||
|
|
||||||
|
echo "✨ Local setup complete!"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo "1. Make sure PostgreSQL is running: brew services start postgresql@15"
|
||||||
|
echo "2. Update .env with your local PostgreSQL connection string"
|
||||||
|
echo "3. Run: npm run dev"
|
||||||
|
echo "4. Visit: http://localhost:5173/api/health"
|
||||||
|
|
@ -3,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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -64,11 +64,17 @@ $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-60: #cccccc;
|
||||||
$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-80: #ff6a54;
|
$red-80: #ff6a54;
|
||||||
|
|
@ -85,6 +91,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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="site-header {hasScrolled ? 'scrolled' : ''}" style="--gradient-opacity: {gradientOpacity}">
|
<header
|
||||||
|
class="site-header {hasScrolled ? 'scrolled' : ''}"
|
||||||
|
style="--gradient-opacity: {gradientOpacity}"
|
||||||
|
>
|
||||||
<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 />
|
||||||
|
|
@ -55,7 +58,11 @@
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
background: linear-gradient(to bottom, rgba(0, 0, 0, calc(0.15 * var(--gradient-opacity))), transparent);
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 0, 0, calc(0.15 * var(--gradient-opacity))),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
backdrop-filter: blur(calc(6px * var(--gradient-opacity)));
|
backdrop-filter: blur(calc(6px * var(--gradient-opacity)));
|
||||||
-webkit-backdrop-filter: blur(calc(6px * var(--gradient-opacity)));
|
-webkit-backdrop-filter: blur(calc(6px * var(--gradient-opacity)));
|
||||||
mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
|
mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);
|
||||||
|
|
|
||||||
|
|
@ -15,17 +15,39 @@
|
||||||
{#if project.url || project.github}
|
{#if project.url || project.github}
|
||||||
<div class="project-links">
|
<div class="project-links">
|
||||||
{#if project.url}
|
{#if project.url}
|
||||||
<a href={project.url} target="_blank" rel="noopener noreferrer" class="project-link primary">
|
<a
|
||||||
|
href={project.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="project-link primary"
|
||||||
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
<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"/>
|
<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>
|
</svg>
|
||||||
Visit Project
|
Visit Project
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{#if project.github}
|
{#if project.github}
|
||||||
<a href={project.github} target="_blank" rel="noopener noreferrer" class="project-link secondary">
|
<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">
|
<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"/>
|
<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>
|
</svg>
|
||||||
GitHub
|
GitHub
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -39,7 +61,9 @@
|
||||||
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;
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,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>
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,9 @@
|
||||||
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;
|
||||||
transform-style: preserve-3d;
|
transform-style: preserve-3d;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
SVGComponent: MaitsuLogo,
|
SVGComponent: MaitsuLogo,
|
||||||
backgroundColor: '#FFF7EA',
|
backgroundColor: '#FFF7EA',
|
||||||
name: 'Maitsu',
|
name: 'Maitsu',
|
||||||
description: "Maitsu is a hobby journal that helps people make something new every week.",
|
description: 'Maitsu is a hobby journal that helps people make something new every week.',
|
||||||
highlightColor: '#F77754'
|
highlightColor: '#F77754'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -35,7 +35,8 @@
|
||||||
SVGComponent: FigmaLogo,
|
SVGComponent: FigmaLogo,
|
||||||
backgroundColor: '#2c2c2c',
|
backgroundColor: '#2c2c2c',
|
||||||
name: 'Figma',
|
name: 'Figma',
|
||||||
description: 'At Figma, I designed features and led R&D and strategy for the nascent prototyping team.',
|
description:
|
||||||
|
'At Figma, I designed features and led R&D and strategy for the nascent prototyping team.',
|
||||||
highlightColor: '#0ACF83'
|
highlightColor: '#0ACF83'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -54,10 +55,12 @@
|
||||||
<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>
|
||||||
|
|
@ -108,7 +111,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.highlighted {
|
.highlighted {
|
||||||
color: #D0290D;
|
color: #d0290d;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -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('/photos')
|
||||||
currentPath.startsWith('/universe') ? 3 :
|
? 1
|
||||||
-1
|
: currentPath.startsWith('/labs')
|
||||||
|
? 2
|
||||||
|
: currentPath.startsWith('/universe')
|
||||||
|
? 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}
|
||||||
|
|
@ -148,7 +165,9 @@
|
||||||
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
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,7 +184,6 @@
|
||||||
animation: iconPulse 0.6s ease;
|
animation: iconPulse 0.6s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Different animations for each nav item
|
// Different animations for each nav item
|
||||||
|
|
@ -203,5 +221,4 @@
|
||||||
.nav-item:nth-of-type(2) :global(svg.animate rect:nth-child(4)) {
|
.nav-item:nth-of-type(2) :global(svg.animate rect:nth-child(4)) {
|
||||||
animation: masonryRect4 0.6s ease;
|
animation: masonryRect4 0.6s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
53
src/lib/components/admin/AdminSegmentedControl.svelte
Normal file
53
src/lib/components/admin/AdminSegmentedControl.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<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;
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
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>
|
||||||
274
src/lib/components/admin/AdminSegmentedController.svelte
Normal file
274
src/lib/components/admin/AdminSegmentedController.svelte
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
<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;
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
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;
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||||
|
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>
|
||||||
163
src/lib/components/admin/DataTable.svelte
Normal file
163
src/lib/components/admin/DataTable.svelte
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
<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
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
data = [],
|
||||||
|
columns = [],
|
||||||
|
isLoading = false,
|
||||||
|
emptyMessage = 'No data found',
|
||||||
|
onRowClick
|
||||||
|
}: 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">
|
||||||
|
{#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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
380
src/lib/components/admin/Editor.svelte
Normal file
380
src/lib/components/admin/Editor.svelte
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
<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
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
data = $bindable({
|
||||||
|
type: 'doc',
|
||||||
|
content: [{ type: 'paragraph' }]
|
||||||
|
}),
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Type "/" for commands...',
|
||||||
|
readOnly = false,
|
||||||
|
minHeight = 400,
|
||||||
|
autofocus = false,
|
||||||
|
class: className = '',
|
||||||
|
showToolbar = true
|
||||||
|
}: 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) {
|
||||||
|
editor.commands.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="editor-wrapper {className}" style="--min-height: {minHeight}px">
|
||||||
|
<div class="editor-container">
|
||||||
|
<EditorWithUpload
|
||||||
|
bind:editor
|
||||||
|
content={data}
|
||||||
|
{onUpdate}
|
||||||
|
editable={!readOnly}
|
||||||
|
{showToolbar}
|
||||||
|
{placeholder}
|
||||||
|
showSlashCommands={true}
|
||||||
|
showLinkBubbleMenu={true}
|
||||||
|
showTableBubbleMenu={true}
|
||||||
|
class="editor-content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '$lib/../assets/styles/variables.scss';
|
||||||
|
@import '$lib/../assets/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-bottom: 1px solid $grey-80;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override Edra styles to match our design
|
||||||
|
:global(.edra-editor) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: $unit-4x;
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
padding: $unit-3x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.edra .ProseMirror) {
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
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-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: $unit-3x 0 $unit-2x;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.edra .ProseMirror h2) {
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: $unit-3x 0 $unit-2x;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.edra .ProseMirror h3) {
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
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;
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background: $grey-95;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: $grey-60;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
272
src/lib/components/admin/EditorWithUpload.svelte
Normal file
272
src/lib/components/admin/EditorWithUpload.svelte
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
<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 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 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)
|
||||||
|
|
||||||
|
// Custom image paste handler
|
||||||
|
function handleImagePaste(view: any, event: ClipboardEvent) {
|
||||||
|
const item = event.clipboardData?.items[0]
|
||||||
|
|
||||||
|
if (item?.type.indexOf('image') !== 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = item.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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
IFramePlaceholder(IFramePlaceholderComponent),
|
||||||
|
IFrameExtended(IFrameExtendedComponent),
|
||||||
|
VideoPlaceholder(VideoPlaceholderComponent),
|
||||||
|
AudioExtended(AudioExtendedComponent),
|
||||||
|
ImageExtended(ImageExtendedComponent),
|
||||||
|
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: handleImagePaste
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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">
|
||||||
|
<EdraToolbar {editor} />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.edra {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
border-bottom: 1px solid var(--edra-border-color);
|
||||||
|
background: var(--edra-button-bg-color);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
: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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
||||||
75
src/lib/components/admin/FormFieldWrapper.svelte
Normal file
75
src/lib/components/admin/FormFieldWrapper.svelte
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
<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-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: 0.925rem;
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #c33;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
margin-top: $unit;
|
||||||
|
color: #c33;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
margin-top: $unit;
|
||||||
|
color: $grey-40;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
165
src/lib/components/admin/ImageUploadPlaceholder.svelte
Normal file
165
src/lib/components/admin/ImageUploadPlaceholder.svelte
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
|
import Image from 'lucide-svelte/icons/image'
|
||||||
|
import Upload from 'lucide-svelte/icons/upload'
|
||||||
|
import Link from 'lucide-svelte/icons/link'
|
||||||
|
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||||
|
|
||||||
|
const { editor }: NodeViewProps = $props()
|
||||||
|
|
||||||
|
let fileInput: HTMLInputElement
|
||||||
|
let isDragging = $state(false)
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (!editor.isEditable) return
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
// Show options: upload file or enter URL
|
||||||
|
const choice = confirm('Click OK to upload a file, or Cancel to enter a URL')
|
||||||
|
|
||||||
|
if (choice) {
|
||||||
|
// Upload file
|
||||||
|
fileInput?.click()
|
||||||
|
} else {
|
||||||
|
// Enter URL
|
||||||
|
const imageUrl = prompt('Enter the URL of an image:')
|
||||||
|
if (imageUrl) {
|
||||||
|
editor.chain().focus().setImage({ src: imageUrl }).run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
// 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.filename || '',
|
||||||
|
width: displayWidth,
|
||||||
|
height: media.height,
|
||||||
|
align: 'center'
|
||||||
|
}).run()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Image upload failed:', error)
|
||||||
|
alert('Failed to upload image. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
style="display: none;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<span
|
||||||
|
class="edra-media-placeholder-content {isDragging ? 'dragging' : ''}"
|
||||||
|
onclick={handleClick}
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondrop={handleDrop}
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="Insert An Image"
|
||||||
|
>
|
||||||
|
<Image class="edra-media-placeholder-icon" />
|
||||||
|
<span class="edra-media-placeholder-text">
|
||||||
|
{isDragging ? 'Drop image here' : 'Click to upload or drag & drop'}
|
||||||
|
</span>
|
||||||
|
<span class="edra-media-placeholder-subtext">
|
||||||
|
or paste from clipboard
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.edra-media-placeholder-content {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-placeholder-content.dragging {
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
border-color: rgb(59, 130, 246);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-placeholder-subtext {
|
||||||
|
font-size: 0.875em;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
</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>
|
||||||
38
src/lib/components/admin/ProjectTitleCell.svelte
Normal file
38
src/lib/components/admin/ProjectTitleCell.svelte
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
item: {
|
||||||
|
title: string
|
||||||
|
backgroundColor: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let { item }: Props = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="title-cell">
|
||||||
|
{#if item.backgroundColor}
|
||||||
|
<span class="color-dot" style="background-color: {item.backgroundColor}"></span>
|
||||||
|
{/if}
|
||||||
|
<span class="title">{item.title}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.title-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
345
src/lib/components/edra/commands/commands.ts
Normal file
345
src/lib/components/edra/commands/commands.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
import { isMac } from '../utils.js';
|
||||||
|
import type { EdraCommandGroup } from './types.js';
|
||||||
|
|
||||||
|
export const commands: Record<string, EdraCommandGroup> = {
|
||||||
|
'undo-redo': {
|
||||||
|
name: 'redo undo',
|
||||||
|
label: 'Redo/Undo',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
iconName: 'Undo',
|
||||||
|
name: 'undo',
|
||||||
|
label: 'Undo',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Z`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().undo().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Redo',
|
||||||
|
name: 'redo',
|
||||||
|
label: 'Redo',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Y`, `${isMac ? 'Cmd' : 'Ctrl'}+Shift+Z`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().redo().run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
headings: {
|
||||||
|
name: 'Headings',
|
||||||
|
label: 'Headings',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
iconName: 'Heading1',
|
||||||
|
name: 'heading1',
|
||||||
|
label: 'Heading 1',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+1`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleHeading({ level: 1 }).run();
|
||||||
|
},
|
||||||
|
isActive: (editor) => editor.isActive('heading', { level: 1 })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Heading2',
|
||||||
|
name: 'heading2',
|
||||||
|
label: 'Heading 2',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+2`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||||
|
},
|
||||||
|
isActive: (editor) => editor.isActive('heading', { level: 2 })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Heading3',
|
||||||
|
name: 'heading3',
|
||||||
|
label: 'Heading 3',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+3`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleHeading({ level: 3 }).run();
|
||||||
|
},
|
||||||
|
isActive: (editor) => editor.isActive('heading', { level: 3 })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'text-formatting': {
|
||||||
|
name: 'Text Formatting',
|
||||||
|
label: 'Text Formatting',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
iconName: 'Link',
|
||||||
|
name: 'link',
|
||||||
|
label: 'Link',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+K`],
|
||||||
|
action: (editor) => {
|
||||||
|
const href = prompt('Enter the URL of the link:');
|
||||||
|
if (href !== null) editor.chain().focus().setLink({ href, target: '_blank' }).run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Bold',
|
||||||
|
name: 'bold',
|
||||||
|
label: 'Bold',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+B`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleBold().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Italic',
|
||||||
|
name: 'italic',
|
||||||
|
label: 'Italic',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+I`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleItalic().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Underline',
|
||||||
|
name: 'underline',
|
||||||
|
label: 'Underline',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+U`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleUnderline().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Strikethrough',
|
||||||
|
name: 'strike',
|
||||||
|
label: 'Strikethrough',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+S`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleStrike().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Quote',
|
||||||
|
name: 'blockquote',
|
||||||
|
label: 'Blockquote',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+B`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleBlockquote().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Superscript',
|
||||||
|
name: 'superscript',
|
||||||
|
label: 'Superscript',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Period`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleSuperscript().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Subscript',
|
||||||
|
name: 'subscript',
|
||||||
|
label: 'Subscript',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Comma`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleSubscript().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Code',
|
||||||
|
name: 'code',
|
||||||
|
label: 'Code',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+E`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleCode().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Braces',
|
||||||
|
name: 'codeBlock',
|
||||||
|
label: 'Code Block',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Alt+C`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleCodeBlock().run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
alignment: {
|
||||||
|
name: 'Alignment',
|
||||||
|
label: 'Alignment',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
iconName: 'AlignLeft',
|
||||||
|
name: 'alignLeft',
|
||||||
|
label: 'Align Left',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+L`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().setTextAlign('left').run();
|
||||||
|
},
|
||||||
|
isActive: (editor) => editor.isActive({ textAlign: 'left' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'AlignCenter',
|
||||||
|
name: 'alignCenter',
|
||||||
|
label: 'Align Center',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+E`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().setTextAlign('center').run();
|
||||||
|
},
|
||||||
|
isActive: (editor) => editor.isActive({ textAlign: 'center' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'AlignRight',
|
||||||
|
name: 'alignRight',
|
||||||
|
label: 'Align Right',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+R`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().setTextAlign('right').run();
|
||||||
|
},
|
||||||
|
isActive: (editor) => editor.isActive({ textAlign: 'right' })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'AlignJustify',
|
||||||
|
name: 'alignJustify',
|
||||||
|
label: 'Align Justify',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+J`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().setTextAlign('justify').run();
|
||||||
|
},
|
||||||
|
isActive: (editor) => editor.isActive({ textAlign: 'justify' })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
lists: {
|
||||||
|
name: 'Lists',
|
||||||
|
label: 'Lists',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
iconName: 'List',
|
||||||
|
name: 'bulletList',
|
||||||
|
label: 'Bullet List',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+8`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleBulletList().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'ListOrdered',
|
||||||
|
name: 'orderedList',
|
||||||
|
label: 'Ordered List',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+7`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleOrderedList().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'ListChecks',
|
||||||
|
name: 'taskList',
|
||||||
|
label: 'Task List',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+9`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleTaskList().run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
media: {
|
||||||
|
name: 'Media',
|
||||||
|
label: 'Media',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
iconName: 'AudioLines',
|
||||||
|
name: 'audio-placeholder',
|
||||||
|
label: 'Audio',
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().insertAudioPlaceholder().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Image',
|
||||||
|
name: 'image-placeholder',
|
||||||
|
label: 'Image',
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().insertImagePlaceholder().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Video',
|
||||||
|
name: 'video-placeholder',
|
||||||
|
label: 'Video',
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().insertVideoPlaceholder().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'CodeXml',
|
||||||
|
name: 'iframe-placeholder',
|
||||||
|
label: 'IFrame',
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().insertIFramePlaceholder().run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
name: 'Colors',
|
||||||
|
label: 'Colors and Highlights',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
iconName: 'PenLine',
|
||||||
|
name: 'color',
|
||||||
|
label: 'Color',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+C`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().unsetColor().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Highlighter',
|
||||||
|
name: 'highlight',
|
||||||
|
label: 'Highlight',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+H`],
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().toggleHighlight().run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
name: 'Table',
|
||||||
|
label: 'Table',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
iconName: 'Table',
|
||||||
|
name: 'table',
|
||||||
|
label: 'Table',
|
||||||
|
shortCuts: [`${isMac ? 'Cmd' : 'Ctrl'}+Shift+T`],
|
||||||
|
action: (editor) => {
|
||||||
|
if (editor.isActive('table')) editor.chain().focus().deleteTable().run();
|
||||||
|
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: false }).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
name: 'fonts',
|
||||||
|
label: 'Fonts',
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
iconName: 'Plus',
|
||||||
|
name: 'font increment',
|
||||||
|
label: 'Increase Font Size',
|
||||||
|
action: (editor) => {
|
||||||
|
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px');
|
||||||
|
currentFontSize++;
|
||||||
|
editor.chain().focus().setFontSize(`${currentFontSize}px`).run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Minus',
|
||||||
|
name: 'font decrement',
|
||||||
|
label: 'Decrease Font Size',
|
||||||
|
action: (editor) => {
|
||||||
|
let currentFontSize = parseInt(editor.getAttributes('textStyle').fontSize ?? '16px');
|
||||||
|
currentFontSize--;
|
||||||
|
editor.chain().focus().setFontSize(`${currentFontSize}px`).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
21
src/lib/components/edra/commands/types.ts
Normal file
21
src/lib/components/edra/commands/types.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { Editor } from '@tiptap/core';
|
||||||
|
import type { icons } from 'lucide-svelte';
|
||||||
|
|
||||||
|
export interface EdraCommand {
|
||||||
|
iconName: keyof typeof icons;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
shortCuts?: string[];
|
||||||
|
action: (editor: Editor) => void;
|
||||||
|
isActive?: (editor: Editor) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdraCommandShortCuts {
|
||||||
|
key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdraCommandGroup {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
commands: EdraCommand[];
|
||||||
|
}
|
||||||
31
src/lib/components/edra/drag-handle.svelte
Normal file
31
src/lib/components/edra/drag-handle.svelte
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Editor } from '@tiptap/core';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import GripVertical from 'lucide-svelte/icons/grip-vertical';
|
||||||
|
import { DragHandlePlugin } from './extensions/drag-handle/index.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
editor: Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { editor }: Props = $props();
|
||||||
|
|
||||||
|
const pluginKey = 'globalDragHandle';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const plugin = DragHandlePlugin({
|
||||||
|
pluginKey: pluginKey,
|
||||||
|
dragHandleWidth: 20,
|
||||||
|
scrollTreshold: 100,
|
||||||
|
dragHandleSelector: '.drag-handle',
|
||||||
|
excludedTags: ['pre', 'code', 'table p'],
|
||||||
|
customNodes: []
|
||||||
|
});
|
||||||
|
editor.registerPlugin(plugin);
|
||||||
|
return () => editor.unregisterPlugin(pluginKey);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="drag-handle">
|
||||||
|
<GripVertical />
|
||||||
|
</div>
|
||||||
429
src/lib/components/edra/editor.css
Normal file
429
src/lib/components/edra/editor.css
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
/* Base TipTap Editor Styles with Light/Dark Theme Support */
|
||||||
|
|
||||||
|
.tiptap :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Placeholder */
|
||||||
|
.tiptap .is-empty::before {
|
||||||
|
pointer-events: none;
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
color: var(--border-color-hover);
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heading Styles */
|
||||||
|
.tiptap h1,
|
||||||
|
.tiptap h2,
|
||||||
|
.tiptap h3,
|
||||||
|
.tiptap h4,
|
||||||
|
.tiptap h5,
|
||||||
|
.tiptap h6 {
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h1,
|
||||||
|
.tiptap h2 {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h4,
|
||||||
|
.tiptap h5,
|
||||||
|
.tiptap h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blockquote Styles */
|
||||||
|
.tiptap blockquote {
|
||||||
|
border-left: 0.5rem solid var(--blockquote-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background-color: var(--code-bg);
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap blockquote p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Horizontal Rule */
|
||||||
|
.tiptap hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline Code */
|
||||||
|
.tiptap code:not(pre code) {
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
color: var(--code-color);
|
||||||
|
background-color: var(--code-bg);
|
||||||
|
border: 0.5px solid var(--code-border) !important;
|
||||||
|
padding: 0.25rem;
|
||||||
|
font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List Styling */
|
||||||
|
.tiptap ul li p,
|
||||||
|
.tiptap ol li p {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task List Styling */
|
||||||
|
.tiptap ul[data-type='taskList'] {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type='taskList'] li {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type='taskList'] li > label {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type='taskList'] li > div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type='taskList'] input[type='checkbox'] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul[data-type='taskList'] ul[data-type='taskList'] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul[data-type='taskList'] li[data-checked='true'] div {
|
||||||
|
color: var(--task-completed-color);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='checkbox'] {
|
||||||
|
position: relative;
|
||||||
|
top: 0.25rem;
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color Swatches */
|
||||||
|
.color {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color::before {
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
margin-right: 0.1rem;
|
||||||
|
display: inline-block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
vertical-align: middle;
|
||||||
|
background-color: var(--color);
|
||||||
|
content: ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code Block Styling */
|
||||||
|
.tiptap pre {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
height: fit-content;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap pre code {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background-color: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag Handle Styling */
|
||||||
|
.drag-handle {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 100;
|
||||||
|
transition-property: opacity;
|
||||||
|
transition-duration: 200ms;
|
||||||
|
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
|
||||||
|
color: var(--border-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle.hide {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
.drag-handle {
|
||||||
|
display: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-handle svg {
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Math Equations (KaTeX) */
|
||||||
|
.katex:hover {
|
||||||
|
background-color: var(--code-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.katex.result {
|
||||||
|
border-bottom: 1px dashed var(--highlight-border);
|
||||||
|
background-color: var(--highlight-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table Styling */
|
||||||
|
.ProseMirror .tableWrapper {
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 1px solid var(--table-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table td,
|
||||||
|
.ProseMirror table th {
|
||||||
|
position: relative;
|
||||||
|
min-width: 100px;
|
||||||
|
border: 1px solid var(--table-border);
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table td:first-of-type:not(a),
|
||||||
|
.ProseMirror table th:first-of-type:not(a) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table td p,
|
||||||
|
.ProseMirror table th p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table td p + p,
|
||||||
|
.ProseMirror table th p + p {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table th {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .column-resize-handle {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -0.25rem;
|
||||||
|
bottom: -2px;
|
||||||
|
display: flex;
|
||||||
|
width: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .column-resize-handle::before {
|
||||||
|
content: '';
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
height: 100%;
|
||||||
|
width: 1px;
|
||||||
|
background-color: var(--table-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .selectedCell {
|
||||||
|
border-style: double;
|
||||||
|
border-color: var(--table-border);
|
||||||
|
background-color: var(--table-bg-selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-column,
|
||||||
|
.ProseMirror table .grip-row {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--table-bg-selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-column {
|
||||||
|
top: -0.75rem;
|
||||||
|
left: 0;
|
||||||
|
margin-left: -1px;
|
||||||
|
height: 0.75rem;
|
||||||
|
width: calc(100% + 1px);
|
||||||
|
border-left: 1px solid var(--table-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-column:hover::before,
|
||||||
|
.ProseMirror table .grip-column.selected::before {
|
||||||
|
content: '';
|
||||||
|
width: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-column:hover {
|
||||||
|
background-color: var(--table-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-column:hover::before {
|
||||||
|
border-bottom: 2px dotted var(--border-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-column.first {
|
||||||
|
border-top-left-radius: 0.125rem;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-column.last {
|
||||||
|
border-top-right-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-column.selected {
|
||||||
|
border-color: var(--table-border);
|
||||||
|
background-color: var(--table-bg-hover);
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-column.selected::before {
|
||||||
|
border-bottom: 2px dotted var(--border-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-row {
|
||||||
|
left: -0.75rem;
|
||||||
|
top: 0;
|
||||||
|
margin-top: -1px;
|
||||||
|
height: calc(100% + 1px);
|
||||||
|
width: 0.75rem;
|
||||||
|
border-top: 1px solid var(--table-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-row:hover::before,
|
||||||
|
.ProseMirror table .grip-row.selected::before {
|
||||||
|
content: '';
|
||||||
|
height: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-row:hover {
|
||||||
|
background-color: var(--table-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-row:hover::before {
|
||||||
|
border-left: 2px dotted var(--border-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-row.first {
|
||||||
|
border-top-left-radius: 0.125rem;
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-row.last {
|
||||||
|
border-bottom-left-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-row.selected {
|
||||||
|
border-color: var(--table-border);
|
||||||
|
background-color: var(--table-bg-hover);
|
||||||
|
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror table .grip-row.selected::before {
|
||||||
|
border-left: 2px dotted var(--border-color-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap .search-result {
|
||||||
|
background-color: var(--search-result-bg);
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap .search-result-current {
|
||||||
|
background-color: var(--search-result-current-bg);
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-wrapper {
|
||||||
|
background-color: var(--code-bg);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-wrapper-tile {
|
||||||
|
opacity: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
right: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-wrapper:hover .code-wrapper-tile {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap iframe {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
}
|
||||||
131
src/lib/components/edra/editor.ts
Normal file
131
src/lib/components/edra/editor.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { Editor, type Content, type EditorOptions, type Extensions } from '@tiptap/core';
|
||||||
|
import Color from '@tiptap/extension-color';
|
||||||
|
import Link from '@tiptap/extension-link';
|
||||||
|
import Subscript from '@tiptap/extension-subscript';
|
||||||
|
import Superscript from '@tiptap/extension-superscript';
|
||||||
|
import TaskItem from '@tiptap/extension-task-item';
|
||||||
|
import TaskList from '@tiptap/extension-task-list';
|
||||||
|
import TextAlign from '@tiptap/extension-text-align';
|
||||||
|
import TextStyle from '@tiptap/extension-text-style';
|
||||||
|
import Typography from '@tiptap/extension-typography';
|
||||||
|
import Underline from '@tiptap/extension-underline';
|
||||||
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import Highlight from '@tiptap/extension-highlight';
|
||||||
|
import Text from '@tiptap/extension-text';
|
||||||
|
import { SmilieReplacer } from './extensions/SmilieReplacer.js';
|
||||||
|
import { ColorHighlighter } from './extensions/ColorHighlighter.js';
|
||||||
|
import AutoJoiner from 'tiptap-extension-auto-joiner';
|
||||||
|
import { MathExtension } from '@aarkue/tiptap-math-extension';
|
||||||
|
import { Table, TableCell, TableHeader, TableRow } from './extensions/table/index.js';
|
||||||
|
import FontSize from './extensions/FontSize.js';
|
||||||
|
import Placeholder from '@tiptap/extension-placeholder';
|
||||||
|
import CharacterCount from '@tiptap/extension-character-count';
|
||||||
|
import SearchAndReplace from './extensions/FindAndReplace.js';
|
||||||
|
import { getHandlePaste } from './utils.js';
|
||||||
|
import { Markdown } from 'tiptap-markdown';
|
||||||
|
|
||||||
|
export const initiateEditor = (
|
||||||
|
element?: HTMLElement,
|
||||||
|
content?: Content,
|
||||||
|
limit?: number,
|
||||||
|
extensions?: Extensions,
|
||||||
|
options?: Partial<EditorOptions>
|
||||||
|
): Editor => {
|
||||||
|
const editor = new Editor({
|
||||||
|
element: element,
|
||||||
|
content: content,
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({
|
||||||
|
orderedList: {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'list-decimal'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bulletList: {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'list-disc'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
levels: [1, 2, 3, 4],
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'tiptap-heading'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
codeBlock: false,
|
||||||
|
text: false
|
||||||
|
}),
|
||||||
|
SmilieReplacer,
|
||||||
|
ColorHighlighter,
|
||||||
|
Superscript,
|
||||||
|
Subscript,
|
||||||
|
Underline,
|
||||||
|
Link.configure({
|
||||||
|
openOnClick: false,
|
||||||
|
autolink: true,
|
||||||
|
defaultProtocol: 'https',
|
||||||
|
HTMLAttributes: {
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
TaskList,
|
||||||
|
TaskItem.configure({
|
||||||
|
nested: true
|
||||||
|
}),
|
||||||
|
TextStyle,
|
||||||
|
Color,
|
||||||
|
Highlight.configure({ multicolor: true }),
|
||||||
|
Text,
|
||||||
|
Typography,
|
||||||
|
TextAlign.configure({
|
||||||
|
types: ['heading', 'paragraph']
|
||||||
|
}),
|
||||||
|
AutoJoiner,
|
||||||
|
MathExtension.configure({ evaluation: true }),
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
FontSize,
|
||||||
|
Markdown.configure({
|
||||||
|
html: true,
|
||||||
|
tightLists: true,
|
||||||
|
tightListClass: 'tight',
|
||||||
|
bulletListMarker: '-',
|
||||||
|
linkify: true, // Create links from "https://..." text
|
||||||
|
breaks: true, // New lines (\n) in markdown input are converted to <br>
|
||||||
|
transformPastedText: true, // Allow to paste markdown text in the editor
|
||||||
|
transformCopiedText: false // Copied text is transformed to markdown
|
||||||
|
}),
|
||||||
|
Placeholder.configure({
|
||||||
|
emptyEditorClass: 'is-empty',
|
||||||
|
// Use a placeholder:
|
||||||
|
// Use different placeholders depending on the node type:
|
||||||
|
placeholder: ({ node }) => {
|
||||||
|
if (node.type.name === 'heading') {
|
||||||
|
return 'What’s the title?';
|
||||||
|
} else if (node.type.name === 'paragraph') {
|
||||||
|
return 'Press / or write something ...';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
CharacterCount.configure({
|
||||||
|
limit
|
||||||
|
}),
|
||||||
|
SearchAndReplace,
|
||||||
|
|
||||||
|
...(extensions ?? [])
|
||||||
|
],
|
||||||
|
autofocus: true,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.setOptions({
|
||||||
|
editorProps: {
|
||||||
|
handlePaste: getHandlePaste(editor)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return editor;
|
||||||
|
};
|
||||||
28
src/lib/components/edra/extensions/ColorHighlighter.ts
Normal file
28
src/lib/components/edra/extensions/ColorHighlighter.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import { Plugin } from '@tiptap/pm/state';
|
||||||
|
|
||||||
|
import { findColors } from '../utils.js';
|
||||||
|
|
||||||
|
export const ColorHighlighter = Extension.create({
|
||||||
|
name: 'colorHighlighter',
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
state: {
|
||||||
|
init(_, { doc }) {
|
||||||
|
return findColors(doc);
|
||||||
|
},
|
||||||
|
apply(transaction, oldState) {
|
||||||
|
return transaction.docChanged ? findColors(transaction.doc) : oldState;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return this.getState(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
408
src/lib/components/edra/extensions/FindAndReplace.ts
Normal file
408
src/lib/components/edra/extensions/FindAndReplace.ts
Normal file
|
|
@ -0,0 +1,408 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade)
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
import { Extension, type Range, type Dispatch } from '@tiptap/core';
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||||
|
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state';
|
||||||
|
import { Node as PMNode } from '@tiptap/pm/model';
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
search: {
|
||||||
|
/**
|
||||||
|
* @description Set search term in extension.
|
||||||
|
*/
|
||||||
|
setSearchTerm: (searchTerm: string) => ReturnType;
|
||||||
|
/**
|
||||||
|
* @description Set replace term in extension.
|
||||||
|
*/
|
||||||
|
setReplaceTerm: (replaceTerm: string) => ReturnType;
|
||||||
|
/**
|
||||||
|
* @description Set case sensitivity in extension.
|
||||||
|
*/
|
||||||
|
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
|
||||||
|
/**
|
||||||
|
* @description Reset current search result to first instance.
|
||||||
|
*/
|
||||||
|
resetIndex: () => ReturnType;
|
||||||
|
/**
|
||||||
|
* @description Find next instance of search result.
|
||||||
|
*/
|
||||||
|
nextSearchResult: () => ReturnType;
|
||||||
|
/**
|
||||||
|
* @description Find previous instance of search result.
|
||||||
|
*/
|
||||||
|
previousSearchResult: () => ReturnType;
|
||||||
|
/**
|
||||||
|
* @description Replace first instance of search result with given replace term.
|
||||||
|
*/
|
||||||
|
replace: () => ReturnType;
|
||||||
|
/**
|
||||||
|
* @description Replace all instances of search result with given replace term.
|
||||||
|
*/
|
||||||
|
replaceAll: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextNodesWithPosition {
|
||||||
|
text: string;
|
||||||
|
pos: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRegex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => {
|
||||||
|
return RegExp(
|
||||||
|
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : s,
|
||||||
|
caseSensitive ? 'gu' : 'gui'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProcessedSearches {
|
||||||
|
decorationsToReturn: DecorationSet;
|
||||||
|
results: Range[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function processSearches(
|
||||||
|
doc: PMNode,
|
||||||
|
searchTerm: RegExp,
|
||||||
|
searchResultClass: string,
|
||||||
|
resultIndex: number
|
||||||
|
): ProcessedSearches {
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
const results: Range[] = [];
|
||||||
|
|
||||||
|
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
if (!searchTerm) {
|
||||||
|
return {
|
||||||
|
decorationsToReturn: DecorationSet.empty,
|
||||||
|
results: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
doc?.descendants((node, pos) => {
|
||||||
|
if (node.isText) {
|
||||||
|
if (textNodesWithPosition[index]) {
|
||||||
|
textNodesWithPosition[index] = {
|
||||||
|
text: textNodesWithPosition[index].text + node.text,
|
||||||
|
pos: textNodesWithPosition[index].pos
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
textNodesWithPosition[index] = {
|
||||||
|
text: `${node.text}`,
|
||||||
|
pos
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
|
||||||
|
|
||||||
|
for (const element of textNodesWithPosition) {
|
||||||
|
const { text, pos } = element;
|
||||||
|
const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim());
|
||||||
|
|
||||||
|
for (const m of matches) {
|
||||||
|
if (m[0] === '') break;
|
||||||
|
|
||||||
|
if (m.index !== undefined) {
|
||||||
|
results.push({
|
||||||
|
from: pos + m.index,
|
||||||
|
to: pos + m.index + m[0].length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < results.length; i += 1) {
|
||||||
|
const r = results[i];
|
||||||
|
const className =
|
||||||
|
i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass;
|
||||||
|
const decoration: Decoration = Decoration.inline(r.from, r.to, {
|
||||||
|
class: className
|
||||||
|
});
|
||||||
|
|
||||||
|
decorations.push(decoration);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
decorationsToReturn: DecorationSet.create(doc, decorations),
|
||||||
|
results
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const replace = (
|
||||||
|
replaceTerm: string,
|
||||||
|
results: Range[],
|
||||||
|
{ state, dispatch }: { state: EditorState; dispatch: Dispatch }
|
||||||
|
) => {
|
||||||
|
const firstResult = results[0];
|
||||||
|
|
||||||
|
if (!firstResult) return;
|
||||||
|
|
||||||
|
const { from, to } = results[0];
|
||||||
|
|
||||||
|
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rebaseNextResult = (
|
||||||
|
replaceTerm: string,
|
||||||
|
index: number,
|
||||||
|
lastOffset: number,
|
||||||
|
results: Range[]
|
||||||
|
): [number, Range[]] | null => {
|
||||||
|
const nextIndex = index + 1;
|
||||||
|
|
||||||
|
if (!results[nextIndex]) return null;
|
||||||
|
|
||||||
|
const { from: currentFrom, to: currentTo } = results[index];
|
||||||
|
|
||||||
|
const offset = currentTo - currentFrom - replaceTerm.length + lastOffset;
|
||||||
|
|
||||||
|
const { from, to } = results[nextIndex];
|
||||||
|
|
||||||
|
results[nextIndex] = {
|
||||||
|
to: to - offset,
|
||||||
|
from: from - offset
|
||||||
|
};
|
||||||
|
|
||||||
|
return [offset, results];
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceAll = (
|
||||||
|
replaceTerm: string,
|
||||||
|
results: Range[],
|
||||||
|
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch }
|
||||||
|
) => {
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
let resultsCopy = results.slice();
|
||||||
|
|
||||||
|
if (!resultsCopy.length) return;
|
||||||
|
|
||||||
|
for (let i = 0; i < resultsCopy.length; i += 1) {
|
||||||
|
const { from, to } = resultsCopy[i];
|
||||||
|
|
||||||
|
tr.insertText(replaceTerm, from, to);
|
||||||
|
|
||||||
|
const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, resultsCopy);
|
||||||
|
|
||||||
|
if (!rebaseNextResultResponse) continue;
|
||||||
|
|
||||||
|
offset = rebaseNextResultResponse[0];
|
||||||
|
resultsCopy = rebaseNextResultResponse[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
dispatch(tr);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin');
|
||||||
|
|
||||||
|
export interface SearchAndReplaceOptions {
|
||||||
|
searchResultClass: string;
|
||||||
|
disableRegex: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchAndReplaceStorage {
|
||||||
|
searchTerm: string;
|
||||||
|
replaceTerm: string;
|
||||||
|
results: Range[];
|
||||||
|
lastSearchTerm: string;
|
||||||
|
caseSensitive: boolean;
|
||||||
|
lastCaseSensitive: boolean;
|
||||||
|
resultIndex: number;
|
||||||
|
lastResultIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, SearchAndReplaceStorage>({
|
||||||
|
name: 'searchAndReplace',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
searchResultClass: 'search-result',
|
||||||
|
disableRegex: true
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addStorage() {
|
||||||
|
return {
|
||||||
|
searchTerm: '',
|
||||||
|
replaceTerm: '',
|
||||||
|
results: [],
|
||||||
|
lastSearchTerm: '',
|
||||||
|
caseSensitive: false,
|
||||||
|
lastCaseSensitive: false,
|
||||||
|
resultIndex: 0,
|
||||||
|
lastResultIndex: 0
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setSearchTerm:
|
||||||
|
(searchTerm: string) =>
|
||||||
|
({ editor }) => {
|
||||||
|
editor.storage.searchAndReplace.searchTerm = searchTerm;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
setReplaceTerm:
|
||||||
|
(replaceTerm: string) =>
|
||||||
|
({ editor }) => {
|
||||||
|
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
setCaseSensitive:
|
||||||
|
(caseSensitive: boolean) =>
|
||||||
|
({ editor }) => {
|
||||||
|
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
resetIndex:
|
||||||
|
() =>
|
||||||
|
({ editor }) => {
|
||||||
|
editor.storage.searchAndReplace.resultIndex = 0;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
nextSearchResult:
|
||||||
|
() =>
|
||||||
|
({ editor }) => {
|
||||||
|
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||||
|
|
||||||
|
const nextIndex = resultIndex + 1;
|
||||||
|
|
||||||
|
if (results[nextIndex]) {
|
||||||
|
editor.storage.searchAndReplace.resultIndex = nextIndex;
|
||||||
|
} else {
|
||||||
|
editor.storage.searchAndReplace.resultIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
previousSearchResult:
|
||||||
|
() =>
|
||||||
|
({ editor }) => {
|
||||||
|
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||||
|
|
||||||
|
const prevIndex = resultIndex - 1;
|
||||||
|
|
||||||
|
if (results[prevIndex]) {
|
||||||
|
editor.storage.searchAndReplace.resultIndex = prevIndex;
|
||||||
|
} else {
|
||||||
|
editor.storage.searchAndReplace.resultIndex = results.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
replace:
|
||||||
|
() =>
|
||||||
|
({ editor, state, dispatch }) => {
|
||||||
|
const { replaceTerm, results } = editor.storage.searchAndReplace;
|
||||||
|
|
||||||
|
replace(replaceTerm, results, { state, dispatch });
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
replaceAll:
|
||||||
|
() =>
|
||||||
|
({ editor, tr, dispatch }) => {
|
||||||
|
const { replaceTerm, results } = editor.storage.searchAndReplace;
|
||||||
|
|
||||||
|
replaceAll(replaceTerm, results, { tr, dispatch });
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const editor = this.editor;
|
||||||
|
const { searchResultClass, disableRegex } = this.options;
|
||||||
|
|
||||||
|
const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t);
|
||||||
|
const setLastCaseSensitive = (t: boolean) =>
|
||||||
|
(editor.storage.searchAndReplace.lastCaseSensitive = t);
|
||||||
|
const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t);
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: searchAndReplacePluginKey,
|
||||||
|
state: {
|
||||||
|
init: () => DecorationSet.empty,
|
||||||
|
apply({ doc, docChanged }, oldState) {
|
||||||
|
const {
|
||||||
|
searchTerm,
|
||||||
|
lastSearchTerm,
|
||||||
|
caseSensitive,
|
||||||
|
lastCaseSensitive,
|
||||||
|
resultIndex,
|
||||||
|
lastResultIndex
|
||||||
|
} = editor.storage.searchAndReplace;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!docChanged &&
|
||||||
|
lastSearchTerm === searchTerm &&
|
||||||
|
lastCaseSensitive === caseSensitive &&
|
||||||
|
lastResultIndex === resultIndex
|
||||||
|
)
|
||||||
|
return oldState;
|
||||||
|
|
||||||
|
setLastSearchTerm(searchTerm);
|
||||||
|
setLastCaseSensitive(caseSensitive);
|
||||||
|
setLastResultIndex(resultIndex);
|
||||||
|
|
||||||
|
if (!searchTerm) {
|
||||||
|
editor.storage.searchAndReplace.results = [];
|
||||||
|
return DecorationSet.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { decorationsToReturn, results } = processSearches(
|
||||||
|
doc,
|
||||||
|
getRegex(searchTerm, disableRegex, caseSensitive),
|
||||||
|
searchResultClass,
|
||||||
|
resultIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
editor.storage.searchAndReplace.results = results;
|
||||||
|
|
||||||
|
return decorationsToReturn;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
decorations(state) {
|
||||||
|
return this.getState(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SearchAndReplace;
|
||||||
64
src/lib/components/edra/extensions/FontSize.ts
Normal file
64
src/lib/components/edra/extensions/FontSize.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { type Attributes, Extension } from '@tiptap/core';
|
||||||
|
import '@tiptap/extension-text-style';
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
fontSize: {
|
||||||
|
setFontSize: (size: string) => ReturnType;
|
||||||
|
unsetFontSize: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FontSize = Extension.create({
|
||||||
|
name: 'fontSize',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
types: ['textStyle']
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addGlobalAttributes() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
types: ['paragraph'],
|
||||||
|
attributes: {
|
||||||
|
class: {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
types: this.options.types,
|
||||||
|
attributes: {
|
||||||
|
fontSize: {
|
||||||
|
parseHTML: (element) => element.style.fontSize.replace(/['"]+/g, ''),
|
||||||
|
renderHTML: (attributes) => {
|
||||||
|
if (!attributes.fontSize) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: `font-size: ${attributes.fontSize}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as Attributes
|
||||||
|
}
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setFontSize:
|
||||||
|
(fontSize: string) =>
|
||||||
|
({ chain }) =>
|
||||||
|
chain().setMark('textStyle', { fontSize }).run(),
|
||||||
|
unsetFontSize:
|
||||||
|
() =>
|
||||||
|
({ chain }) =>
|
||||||
|
chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FontSize;
|
||||||
133
src/lib/components/edra/extensions/SmilieReplacer.ts
Normal file
133
src/lib/components/edra/extensions/SmilieReplacer.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { Extension, textInputRule } from '@tiptap/core';
|
||||||
|
|
||||||
|
export const SmilieReplacer = Extension.create({
|
||||||
|
name: 'smilieReplacer',
|
||||||
|
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
textInputRule({ find: /-___- $/, replace: '😑 ' }),
|
||||||
|
textInputRule({ find: /:'-\) $/, replace: '😂 ' }),
|
||||||
|
textInputRule({ find: /':-\) $/, replace: '😅 ' }),
|
||||||
|
textInputRule({ find: /':-D $/, replace: '😅 ' }),
|
||||||
|
textInputRule({ find: />:-\) $/, replace: '😆 ' }),
|
||||||
|
textInputRule({ find: /-__- $/, replace: '😑 ' }),
|
||||||
|
textInputRule({ find: /':-\( $/, replace: '😓 ' }),
|
||||||
|
textInputRule({ find: /:'-\( $/, replace: '😢 ' }),
|
||||||
|
textInputRule({ find: />:-\( $/, replace: '😠 ' }),
|
||||||
|
textInputRule({ find: /O:-\) $/, replace: '😇 ' }),
|
||||||
|
textInputRule({ find: /0:-3 $/, replace: '😇 ' }),
|
||||||
|
textInputRule({ find: /0:-\) $/, replace: '😇 ' }),
|
||||||
|
textInputRule({ find: /0;\^\) $/, replace: '😇 ' }),
|
||||||
|
textInputRule({ find: /O;-\) $/, replace: '😇 ' }),
|
||||||
|
textInputRule({ find: /0;-\) $/, replace: '😇 ' }),
|
||||||
|
textInputRule({ find: /O:-3 $/, replace: '😇 ' }),
|
||||||
|
textInputRule({ find: /:'\) $/, replace: '😂 ' }),
|
||||||
|
textInputRule({ find: /:-D $/, replace: '😃 ' }),
|
||||||
|
textInputRule({ find: /':\) $/, replace: '😅 ' }),
|
||||||
|
textInputRule({ find: /'=\) $/, replace: '😅 ' }),
|
||||||
|
textInputRule({ find: /':D $/, replace: '😅 ' }),
|
||||||
|
textInputRule({ find: /'=D $/, replace: '😅 ' }),
|
||||||
|
textInputRule({ find: />:\) $/, replace: '😆 ' }),
|
||||||
|
textInputRule({ find: />;\) $/, replace: '😆 ' }),
|
||||||
|
textInputRule({ find: />=\) $/, replace: '😆 ' }),
|
||||||
|
textInputRule({ find: /;-\) $/, replace: '😉 ' }),
|
||||||
|
textInputRule({ find: /\*-\) $/, replace: '😉 ' }),
|
||||||
|
textInputRule({ find: /;-\] $/, replace: '😉 ' }),
|
||||||
|
textInputRule({ find: /;\^\) $/, replace: '😉 ' }),
|
||||||
|
textInputRule({ find: /B-\) $/, replace: '😎 ' }),
|
||||||
|
textInputRule({ find: /8-\) $/, replace: '😎 ' }),
|
||||||
|
textInputRule({ find: /B-D $/, replace: '😎 ' }),
|
||||||
|
textInputRule({ find: /8-D $/, replace: '😎 ' }),
|
||||||
|
textInputRule({ find: /:-\* $/, replace: '😘 ' }),
|
||||||
|
textInputRule({ find: /:\^\* $/, replace: '😘 ' }),
|
||||||
|
textInputRule({ find: /:-\) $/, replace: '🙂 ' }),
|
||||||
|
textInputRule({ find: /-_- $/, replace: '😑 ' }),
|
||||||
|
textInputRule({ find: /:-X $/, replace: '😶 ' }),
|
||||||
|
textInputRule({ find: /:-# $/, replace: '😶 ' }),
|
||||||
|
textInputRule({ find: /:-x $/, replace: '😶 ' }),
|
||||||
|
textInputRule({ find: />.< $/, replace: '😣 ' }),
|
||||||
|
textInputRule({ find: /:-O $/, replace: '😮 ' }),
|
||||||
|
textInputRule({ find: /:-o $/, replace: '😮 ' }),
|
||||||
|
textInputRule({ find: /O_O $/, replace: '😮 ' }),
|
||||||
|
textInputRule({ find: />:O $/, replace: '😮 ' }),
|
||||||
|
textInputRule({ find: /:-P $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: /:-p $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: /:-Þ $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: /:-þ $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: /:-b $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: />:P $/, replace: '😜 ' }),
|
||||||
|
textInputRule({ find: /X-P $/, replace: '😜 ' }),
|
||||||
|
textInputRule({ find: /x-p $/, replace: '😜 ' }),
|
||||||
|
textInputRule({ find: /':\( $/, replace: '😓 ' }),
|
||||||
|
textInputRule({ find: /'=\( $/, replace: '😓 ' }),
|
||||||
|
textInputRule({ find: />:\\ $/, replace: '😕 ' }),
|
||||||
|
textInputRule({ find: />:\/ $/, replace: '😕 ' }),
|
||||||
|
textInputRule({ find: /:-\/ $/, replace: '😕 ' }),
|
||||||
|
textInputRule({ find: /:-. $/, replace: '😕 ' }),
|
||||||
|
textInputRule({ find: />:\[ $/, replace: '😞 ' }),
|
||||||
|
textInputRule({ find: /:-\( $/, replace: '😞 ' }),
|
||||||
|
textInputRule({ find: /:-\[ $/, replace: '😞 ' }),
|
||||||
|
textInputRule({ find: /:'\( $/, replace: '😢 ' }),
|
||||||
|
textInputRule({ find: /;-\( $/, replace: '😢 ' }),
|
||||||
|
textInputRule({ find: /#-\) $/, replace: '😵 ' }),
|
||||||
|
textInputRule({ find: /%-\) $/, replace: '😵 ' }),
|
||||||
|
textInputRule({ find: /X-\) $/, replace: '😵 ' }),
|
||||||
|
textInputRule({ find: />:\( $/, replace: '😠 ' }),
|
||||||
|
textInputRule({ find: /0:3 $/, replace: '😇 ' }),
|
||||||
|
textInputRule({ find: /0:\) $/, replace: '😇 ' }),
|
||||||
|
textInputRule({ find: /O:\) $/, replace: '😇 ' }),
|
||||||
|
textInputRule({ find: /O=\) $/, replace: '😇 ' }),
|
||||||
|
textInputRule({ find: /O:3 $/, replace: '😇 ' }),
|
||||||
|
textInputRule({ find: /<\/3 $/, replace: '💔 ' }),
|
||||||
|
textInputRule({ find: /:D $/, replace: '😃 ' }),
|
||||||
|
textInputRule({ find: /=D $/, replace: '😃 ' }),
|
||||||
|
textInputRule({ find: /;\) $/, replace: '😉 ' }),
|
||||||
|
textInputRule({ find: /\*\) $/, replace: '😉 ' }),
|
||||||
|
textInputRule({ find: /;\] $/, replace: '😉 ' }),
|
||||||
|
textInputRule({ find: /;D $/, replace: '😉 ' }),
|
||||||
|
textInputRule({ find: /B\) $/, replace: '😎 ' }),
|
||||||
|
textInputRule({ find: /8\) $/, replace: '😎 ' }),
|
||||||
|
textInputRule({ find: /:\* $/, replace: '😘 ' }),
|
||||||
|
textInputRule({ find: /=\* $/, replace: '😘 ' }),
|
||||||
|
textInputRule({ find: /:\) $/, replace: '🙂 ' }),
|
||||||
|
textInputRule({ find: /=\] $/, replace: '🙂 ' }),
|
||||||
|
textInputRule({ find: /=\) $/, replace: '🙂 ' }),
|
||||||
|
textInputRule({ find: /:\] $/, replace: '🙂 ' }),
|
||||||
|
textInputRule({ find: /:X $/, replace: '😶 ' }),
|
||||||
|
textInputRule({ find: /:# $/, replace: '😶 ' }),
|
||||||
|
textInputRule({ find: /=X $/, replace: '😶 ' }),
|
||||||
|
textInputRule({ find: /=x $/, replace: '😶 ' }),
|
||||||
|
textInputRule({ find: /:x $/, replace: '😶 ' }),
|
||||||
|
textInputRule({ find: /=# $/, replace: '😶 ' }),
|
||||||
|
textInputRule({ find: /:O $/, replace: '😮 ' }),
|
||||||
|
textInputRule({ find: /:o $/, replace: '😮 ' }),
|
||||||
|
textInputRule({ find: /:P $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: /=P $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: /:p $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: /=p $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: /:Þ $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: /:þ $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: /:b $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: /d: $/, replace: '😛 ' }),
|
||||||
|
textInputRule({ find: /:\/ $/, replace: '😕 ' }),
|
||||||
|
textInputRule({ find: /:\\ $/, replace: '😕 ' }),
|
||||||
|
textInputRule({ find: /=\/ $/, replace: '😕 ' }),
|
||||||
|
textInputRule({ find: /=\\ $/, replace: '😕 ' }),
|
||||||
|
textInputRule({ find: /:L $/, replace: '😕 ' }),
|
||||||
|
textInputRule({ find: /=L $/, replace: '😕 ' }),
|
||||||
|
textInputRule({ find: /:\( $/, replace: '😞 ' }),
|
||||||
|
textInputRule({ find: /:\[ $/, replace: '😞 ' }),
|
||||||
|
textInputRule({ find: /=\( $/, replace: '😞 ' }),
|
||||||
|
textInputRule({ find: /;\( $/, replace: '😢 ' }),
|
||||||
|
textInputRule({ find: /D: $/, replace: '😨 ' }),
|
||||||
|
textInputRule({ find: /:\$ $/, replace: '😳 ' }),
|
||||||
|
textInputRule({ find: /=\$ $/, replace: '😳 ' }),
|
||||||
|
textInputRule({ find: /#\) $/, replace: '😵 ' }),
|
||||||
|
textInputRule({ find: /%\) $/, replace: '😵 ' }),
|
||||||
|
textInputRule({ find: /X\) $/, replace: '😵 ' }),
|
||||||
|
textInputRule({ find: /:@ $/, replace: '😠 ' }),
|
||||||
|
textInputRule({ find: /<3 $/, replace: '❤️ ' }),
|
||||||
|
textInputRule({ find: /\/shrug $/, replace: '¯\\_(ツ)_/¯' })
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
34
src/lib/components/edra/extensions/audio/AudiExtended.ts
Normal file
34
src/lib/components/edra/extensions/audio/AudiExtended.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||||
|
import { Audio } from './AudioExtension.js';
|
||||||
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
|
export const AudioExtended = (content: Component<NodeViewProps>) =>
|
||||||
|
Audio.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
default: '100%'
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
align: {
|
||||||
|
default: 'left'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView: () => {
|
||||||
|
return SvelteNodeViewRenderer(content);
|
||||||
|
}
|
||||||
|
});
|
||||||
147
src/lib/components/edra/extensions/audio/AudioExtension.ts
Normal file
147
src/lib/components/edra/extensions/audio/AudioExtension.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { Node, nodeInputRule } from '@tiptap/core';
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
|
|
||||||
|
export interface AudioOptions {
|
||||||
|
HTMLAttributes: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
audio: {
|
||||||
|
/**
|
||||||
|
* Set a audio node
|
||||||
|
*/
|
||||||
|
setAudio: (src: string) => ReturnType;
|
||||||
|
/**
|
||||||
|
* Toggle a audio
|
||||||
|
*/
|
||||||
|
toggleAudio: (src: string) => ReturnType;
|
||||||
|
/**
|
||||||
|
* Remove a audio
|
||||||
|
*/
|
||||||
|
removeAudio: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUDIO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
|
||||||
|
|
||||||
|
export const Audio = Node.create<AudioOptions>({
|
||||||
|
name: 'audio',
|
||||||
|
group: 'block',
|
||||||
|
draggable: true,
|
||||||
|
isolating: true,
|
||||||
|
atom: true,
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
|
||||||
|
renderHTML: (attrs) => ({ src: attrs.src })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'audio',
|
||||||
|
getAttrs: (el) => ({ src: (el as HTMLAudioElement).getAttribute('src') })
|
||||||
|
}
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'audio',
|
||||||
|
{ controls: 'true', style: 'width: 100%;', ...HTMLAttributes },
|
||||||
|
['source', HTMLAttributes]
|
||||||
|
];
|
||||||
|
},
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setAudio:
|
||||||
|
(src: string) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.insertContent(
|
||||||
|
`<audio controls autoplay="false" style="width: 100%;" src="${src}"></audio>`
|
||||||
|
),
|
||||||
|
|
||||||
|
toggleAudio:
|
||||||
|
() =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.toggleNode(this.name, 'paragraph'),
|
||||||
|
removeAudio:
|
||||||
|
() =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.deleteNode(this.name)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
nodeInputRule({
|
||||||
|
find: AUDIO_INPUT_REGEX,
|
||||||
|
type: this.type,
|
||||||
|
getAttributes: (match) => {
|
||||||
|
const [, , src] = match;
|
||||||
|
|
||||||
|
return { src };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
},
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey('audioDropPlugin'),
|
||||||
|
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
drop(view, event) {
|
||||||
|
const {
|
||||||
|
state: { schema, tr },
|
||||||
|
dispatch
|
||||||
|
} = view;
|
||||||
|
const hasFiles =
|
||||||
|
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
|
||||||
|
|
||||||
|
if (!hasFiles) return false;
|
||||||
|
|
||||||
|
const audios = Array.from(event.dataTransfer.files).filter((file) =>
|
||||||
|
/audio/i.test(file.type)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (audios.length === 0) return false;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||||
|
|
||||||
|
audios.forEach((audio) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (readerEvent) => {
|
||||||
|
const node = schema.nodes.audio.create({ src: readerEvent.target?.result });
|
||||||
|
|
||||||
|
if (coordinates && typeof coordinates.pos === 'number') {
|
||||||
|
const transaction = tr.insert(coordinates?.pos, node);
|
||||||
|
|
||||||
|
dispatch(transaction);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(audio);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
64
src/lib/components/edra/extensions/audio/AudioPlaceholder.ts
Normal file
64
src/lib/components/edra/extensions/audio/AudioPlaceholder.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||||
|
|
||||||
|
export interface AudioPlaceholderOptions {
|
||||||
|
HTMLAttributes: Record<string, object>;
|
||||||
|
onDrop: (files: File[], editor: Editor) => void;
|
||||||
|
onDropRejected?: (files: File[], editor: Editor) => void;
|
||||||
|
onEmbed: (url: string, editor: Editor) => void;
|
||||||
|
allowedMimeTypes?: Record<string, string[]>;
|
||||||
|
maxFiles?: number;
|
||||||
|
maxSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
audioPlaceholder: {
|
||||||
|
/**
|
||||||
|
* Inserts an audio placeholder
|
||||||
|
*/
|
||||||
|
insertAudioPlaceholder: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AudioPlaceholder = (
|
||||||
|
component: Component<NodeViewProps>
|
||||||
|
): Node<AudioPlaceholderOptions> =>
|
||||||
|
Node.create<AudioPlaceholderOptions>({
|
||||||
|
name: 'audio-placeholder',
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
onDrop: () => {},
|
||||||
|
onDropRejected: () => {},
|
||||||
|
onEmbed: () => {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['div', mergeAttributes(HTMLAttributes)];
|
||||||
|
},
|
||||||
|
group: 'block',
|
||||||
|
draggable: true,
|
||||||
|
atom: true,
|
||||||
|
content: 'inline*',
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return SvelteNodeViewRenderer(component);
|
||||||
|
},
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertAudioPlaceholder: () => (props: CommandProps) => {
|
||||||
|
return props.commands.insertContent({
|
||||||
|
type: 'audio-placeholder'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Slice } from '@tiptap/pm/model';
|
||||||
|
import { EditorView } from '@tiptap/pm/view';
|
||||||
|
import * as pmView from '@tiptap/pm/view';
|
||||||
|
|
||||||
|
function getPmView() {
|
||||||
|
try {
|
||||||
|
return pmView;
|
||||||
|
} catch (error: Error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeForClipboard(view: EditorView, slice: Slice) {
|
||||||
|
// Newer Tiptap/ProseMirror
|
||||||
|
if (view && typeof view.serializeForClipboard === 'function') {
|
||||||
|
return view.serializeForClipboard(slice);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older version fallback
|
||||||
|
const proseMirrorView = getPmView();
|
||||||
|
|
||||||
|
if (proseMirrorView && typeof proseMirrorView?.__serializeForClipboard === 'function') {
|
||||||
|
return proseMirrorView.__serializeForClipboard(view, slice);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('No supported clipboard serialization method found.');
|
||||||
|
}
|
||||||
381
src/lib/components/edra/extensions/drag-handle/index.ts
Normal file
381
src/lib/components/edra/extensions/drag-handle/index.ts
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
import { Extension } from '@tiptap/core';
|
||||||
|
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
|
||||||
|
import { Fragment, Slice, Node } from '@tiptap/pm/model';
|
||||||
|
import { EditorView } from '@tiptap/pm/view';
|
||||||
|
import { serializeForClipboard } from './ClipboardSerializer.js';
|
||||||
|
|
||||||
|
export interface GlobalDragHandleOptions {
|
||||||
|
/**
|
||||||
|
* The width of the drag handle
|
||||||
|
*/
|
||||||
|
dragHandleWidth: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The treshold for scrolling
|
||||||
|
*/
|
||||||
|
scrollTreshold: number;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
||||||
|
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
|
||||||
|
*/
|
||||||
|
dragHandleSelector?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tags to be excluded for drag handle
|
||||||
|
*/
|
||||||
|
excludedTags: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom nodes to be included for drag handle
|
||||||
|
*/
|
||||||
|
customNodes: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* onNodeChange callback for drag handle
|
||||||
|
* @param data
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
onMouseMove?: (data: { node: Node; pos: number }) => void;
|
||||||
|
}
|
||||||
|
function absoluteRect(node: Element) {
|
||||||
|
const data = node.getBoundingClientRect();
|
||||||
|
const modal = node.closest('[role="dialog"]');
|
||||||
|
|
||||||
|
if (modal && window.getComputedStyle(modal).transform !== 'none') {
|
||||||
|
const modalRect = modal.getBoundingClientRect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: data.top - modalRect.top,
|
||||||
|
left: data.left - modalRect.left,
|
||||||
|
width: data.width
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
top: data.top,
|
||||||
|
left: data.left,
|
||||||
|
width: data.width
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) {
|
||||||
|
const selectors = [
|
||||||
|
'li',
|
||||||
|
'p:not(:first-child)',
|
||||||
|
'pre',
|
||||||
|
'blockquote',
|
||||||
|
'h1',
|
||||||
|
'h2',
|
||||||
|
'h3',
|
||||||
|
'h4',
|
||||||
|
'h5',
|
||||||
|
'h6',
|
||||||
|
...options.customNodes.map((node) => `[data-type=${node}]`)
|
||||||
|
].join(', ');
|
||||||
|
return document
|
||||||
|
.elementsFromPoint(coords.x, coords.y)
|
||||||
|
.find(
|
||||||
|
(elem: Element) => elem.parentElement?.matches?.('.ProseMirror') || elem.matches(selectors)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) {
|
||||||
|
const boundingRect = node.getBoundingClientRect();
|
||||||
|
|
||||||
|
return view.posAtCoords({
|
||||||
|
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||||
|
top: boundingRect.top + 1
|
||||||
|
})?.inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcNodePos(pos: number, view: EditorView) {
|
||||||
|
const $pos = view.state.doc.resolve(pos);
|
||||||
|
if ($pos.depth > 1) return $pos.before($pos.depth);
|
||||||
|
return pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) {
|
||||||
|
let listType = '';
|
||||||
|
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||||
|
view.focus();
|
||||||
|
|
||||||
|
if (!event.dataTransfer) return;
|
||||||
|
|
||||||
|
const node = nodeDOMAtCoords(
|
||||||
|
{
|
||||||
|
x: event.clientX + 50 + options.dragHandleWidth,
|
||||||
|
y: event.clientY
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!(node instanceof Element)) return;
|
||||||
|
|
||||||
|
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||||
|
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||||
|
draggedNodePos = calcNodePos(draggedNodePos, view);
|
||||||
|
|
||||||
|
const { from, to } = view.state.selection;
|
||||||
|
const diff = from - to;
|
||||||
|
|
||||||
|
const fromSelectionPos = calcNodePos(from, view);
|
||||||
|
let differentNodeSelected = false;
|
||||||
|
|
||||||
|
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
||||||
|
|
||||||
|
// Check if nodePos points to the top level node
|
||||||
|
if (nodePos.node().type.name === 'doc') differentNodeSelected = true;
|
||||||
|
else {
|
||||||
|
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
|
||||||
|
|
||||||
|
// Check if the node where the drag event started is part of the current selection
|
||||||
|
differentNodeSelected = !(
|
||||||
|
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let selection = view.state.selection;
|
||||||
|
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
|
||||||
|
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
||||||
|
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
|
||||||
|
} else {
|
||||||
|
selection = NodeSelection.create(view.state.doc, draggedNodePos);
|
||||||
|
|
||||||
|
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
|
||||||
|
// if table row is selected, go to the parent node to select the whole node
|
||||||
|
if (
|
||||||
|
(selection as NodeSelection).node.type.isInline ||
|
||||||
|
(selection as NodeSelection).node.type.name === 'tableRow'
|
||||||
|
) {
|
||||||
|
const $pos = view.state.doc.resolve(selection.from);
|
||||||
|
selection = NodeSelection.create(view.state.doc, $pos.before());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.dispatch(view.state.tr.setSelection(selection));
|
||||||
|
|
||||||
|
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
||||||
|
if (
|
||||||
|
view.state.selection instanceof NodeSelection &&
|
||||||
|
view.state.selection.node.type.name === 'listItem'
|
||||||
|
) {
|
||||||
|
listType = node.parentElement!.tagName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slice = view.state.selection.content();
|
||||||
|
const { dom, text } = serializeForClipboard(view, slice);
|
||||||
|
|
||||||
|
event.dataTransfer.clearData();
|
||||||
|
event.dataTransfer.setData('text/html', dom.innerHTML);
|
||||||
|
event.dataTransfer.setData('text/plain', text);
|
||||||
|
event.dataTransfer.effectAllowed = 'copyMove';
|
||||||
|
|
||||||
|
event.dataTransfer.setDragImage(node, 0, 0);
|
||||||
|
|
||||||
|
view.dragging = { slice, move: event.ctrlKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragHandleElement: HTMLElement | null = null;
|
||||||
|
|
||||||
|
function hideDragHandle() {
|
||||||
|
if (dragHandleElement) {
|
||||||
|
dragHandleElement.classList.add('hide');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDragHandle() {
|
||||||
|
if (dragHandleElement) {
|
||||||
|
dragHandleElement.classList.remove('hide');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideHandleOnEditorOut(event: MouseEvent) {
|
||||||
|
if (event.target instanceof Element) {
|
||||||
|
// Check if the relatedTarget class is still inside the editor
|
||||||
|
const relatedTarget = event.relatedTarget as HTMLElement;
|
||||||
|
const isInsideEditor =
|
||||||
|
relatedTarget?.classList.contains('tiptap') ||
|
||||||
|
relatedTarget?.classList.contains('drag-handle');
|
||||||
|
|
||||||
|
if (isInsideEditor) return;
|
||||||
|
}
|
||||||
|
hideDragHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Plugin({
|
||||||
|
key: new PluginKey(options.pluginKey),
|
||||||
|
view: (view) => {
|
||||||
|
const handleBySelector = options.dragHandleSelector
|
||||||
|
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
||||||
|
: null;
|
||||||
|
dragHandleElement = handleBySelector ?? document.createElement('div');
|
||||||
|
dragHandleElement.draggable = true;
|
||||||
|
dragHandleElement.dataset.dragHandle = '';
|
||||||
|
dragHandleElement.classList.add('drag-handle');
|
||||||
|
|
||||||
|
function onDragHandleDragStart(e: DragEvent) {
|
||||||
|
handleDragStart(e, view);
|
||||||
|
}
|
||||||
|
|
||||||
|
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart);
|
||||||
|
|
||||||
|
function onDragHandleDrag(e: DragEvent) {
|
||||||
|
hideDragHandle();
|
||||||
|
const scrollY = window.scrollY;
|
||||||
|
if (e.clientY < options.scrollTreshold) {
|
||||||
|
window.scrollTo({ top: scrollY - 30, behavior: 'smooth' });
|
||||||
|
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
|
||||||
|
window.scrollTo({ top: scrollY + 30, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dragHandleElement.addEventListener('drag', onDragHandleDrag);
|
||||||
|
|
||||||
|
hideDragHandle();
|
||||||
|
|
||||||
|
if (!handleBySelector) {
|
||||||
|
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
||||||
|
}
|
||||||
|
view?.dom?.parentElement?.addEventListener('mouseout', hideHandleOnEditorOut);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destroy: () => {
|
||||||
|
if (!handleBySelector) {
|
||||||
|
dragHandleElement?.remove?.();
|
||||||
|
}
|
||||||
|
dragHandleElement?.removeEventListener('drag', onDragHandleDrag);
|
||||||
|
dragHandleElement?.removeEventListener('dragstart', onDragHandleDragStart);
|
||||||
|
dragHandleElement = null;
|
||||||
|
view?.dom?.parentElement?.removeEventListener('mouseout', hideHandleOnEditorOut);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
mousemove: (view, event) => {
|
||||||
|
if (!view.editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = nodeDOMAtCoords(
|
||||||
|
{
|
||||||
|
x: event.clientX + 50 + options.dragHandleWidth,
|
||||||
|
y: event.clientY
|
||||||
|
},
|
||||||
|
options
|
||||||
|
);
|
||||||
|
|
||||||
|
const notDragging = node?.closest('.not-draggable');
|
||||||
|
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ');
|
||||||
|
|
||||||
|
if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) {
|
||||||
|
hideDragHandle();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodePos = nodePosAtDOM(node, view, options);
|
||||||
|
if (nodePos !== undefined) {
|
||||||
|
const currentNode = view.state.doc.nodeAt(nodePos);
|
||||||
|
if (currentNode !== null) {
|
||||||
|
options.onMouseMove?.({ node: currentNode, pos: nodePos });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compStyle = window.getComputedStyle(node);
|
||||||
|
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
||||||
|
const lineHeight = isNaN(parsedLineHeight)
|
||||||
|
? parseInt(compStyle.fontSize) * 1.2
|
||||||
|
: parsedLineHeight;
|
||||||
|
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
||||||
|
|
||||||
|
const rect = absoluteRect(node);
|
||||||
|
|
||||||
|
rect.top += (lineHeight - 24) / 2;
|
||||||
|
rect.top += paddingTop;
|
||||||
|
// Li markers
|
||||||
|
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
|
||||||
|
rect.left -= options.dragHandleWidth;
|
||||||
|
}
|
||||||
|
rect.width = options.dragHandleWidth;
|
||||||
|
|
||||||
|
if (!dragHandleElement) return;
|
||||||
|
|
||||||
|
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||||
|
dragHandleElement.style.top = `${rect.top}px`;
|
||||||
|
showDragHandle();
|
||||||
|
},
|
||||||
|
keydown: () => {
|
||||||
|
hideDragHandle();
|
||||||
|
},
|
||||||
|
mousewheel: () => {
|
||||||
|
hideDragHandle();
|
||||||
|
},
|
||||||
|
// dragging class is used for CSS
|
||||||
|
dragstart: (view) => {
|
||||||
|
view.dom.classList.add('dragging');
|
||||||
|
},
|
||||||
|
drop: (view, event) => {
|
||||||
|
view.dom.classList.remove('dragging');
|
||||||
|
hideDragHandle();
|
||||||
|
let droppedNode: Node | null = null;
|
||||||
|
const dropPos = view.posAtCoords({
|
||||||
|
left: event.clientX,
|
||||||
|
top: event.clientY
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dropPos) return;
|
||||||
|
|
||||||
|
if (view.state.selection instanceof NodeSelection) {
|
||||||
|
droppedNode = view.state.selection.node;
|
||||||
|
}
|
||||||
|
if (!droppedNode) return;
|
||||||
|
|
||||||
|
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||||
|
|
||||||
|
const isDroppedInsideList = resolvedPos.parent.type.name === 'listItem';
|
||||||
|
|
||||||
|
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||||
|
if (
|
||||||
|
view.state.selection instanceof NodeSelection &&
|
||||||
|
view.state.selection.node.type.name === 'listItem' &&
|
||||||
|
!isDroppedInsideList &&
|
||||||
|
listType == 'OL'
|
||||||
|
) {
|
||||||
|
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode);
|
||||||
|
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||||
|
view.dragging = { slice, move: event.ctrlKey };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dragend: (view) => {
|
||||||
|
view.dom.classList.remove('dragging');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlobalDragHandle = Extension.create({
|
||||||
|
name: 'globalDragHandle',
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
dragHandleWidth: 20,
|
||||||
|
scrollTreshold: 100,
|
||||||
|
excludedTags: [],
|
||||||
|
customNodes: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
DragHandlePlugin({
|
||||||
|
pluginKey: 'globalDragHandle',
|
||||||
|
dragHandleWidth: this.options.dragHandleWidth,
|
||||||
|
scrollTreshold: this.options.scrollTreshold,
|
||||||
|
dragHandleSelector: this.options.dragHandleSelector,
|
||||||
|
excludedTags: this.options.excludedTags,
|
||||||
|
customNodes: this.options.customNodes,
|
||||||
|
onMouseMove: this.options.onMouseMove
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default GlobalDragHandle;
|
||||||
85
src/lib/components/edra/extensions/iframe/IFrame.ts
Normal file
85
src/lib/components/edra/extensions/iframe/IFrame.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import { Node } from '@tiptap/core';
|
||||||
|
|
||||||
|
export interface IframeOptions {
|
||||||
|
allowFullscreen: boolean;
|
||||||
|
HTMLAttributes: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
iframe: {
|
||||||
|
/**
|
||||||
|
* Add an iframe with src
|
||||||
|
*/
|
||||||
|
setIframe: (options: { src: string }) => ReturnType;
|
||||||
|
removeIframe: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Node.create<IframeOptions>({
|
||||||
|
name: 'iframe',
|
||||||
|
|
||||||
|
group: 'block',
|
||||||
|
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
allowFullscreen: true,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: 'iframe-wrapper'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
frameborder: {
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
allowfullscreen: {
|
||||||
|
default: this.options.allowFullscreen,
|
||||||
|
parseHTML: () => this.options.allowFullscreen
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'iframe'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]];
|
||||||
|
},
|
||||||
|
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setIframe:
|
||||||
|
(options: { src: string }) =>
|
||||||
|
({ tr, dispatch }) => {
|
||||||
|
const { selection } = tr;
|
||||||
|
const node = this.type.create(options);
|
||||||
|
|
||||||
|
if (dispatch) {
|
||||||
|
tr.replaceRangeWith(selection.from, selection.to, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
removeIframe:
|
||||||
|
() =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.deleteNode(this.name)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
35
src/lib/components/edra/extensions/iframe/IFrameExtended.ts
Normal file
35
src/lib/components/edra/extensions/iframe/IFrameExtended.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||||
|
|
||||||
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
import IFrame from './IFrame.js';
|
||||||
|
|
||||||
|
export const IFrameExtended = (content: Component<NodeViewProps>) =>
|
||||||
|
IFrame.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
default: '100%'
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
align: {
|
||||||
|
default: 'left'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView: () => {
|
||||||
|
return SvelteNodeViewRenderer(content);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||||
|
|
||||||
|
export interface IFramePlaceholderOptions {
|
||||||
|
HTMLAttributes: Record<string, object>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
iframePlaceholder: {
|
||||||
|
/**
|
||||||
|
* Inserts a IFrame placeholder
|
||||||
|
*/
|
||||||
|
insertIFramePlaceholder: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IFramePlaceholder = (content: Component<NodeViewProps>) =>
|
||||||
|
Node.create<IFramePlaceholderOptions>({
|
||||||
|
name: 'iframe-placeholder',
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
onDrop: () => {},
|
||||||
|
onDropRejected: () => {},
|
||||||
|
onEmbed: () => {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['div', mergeAttributes(HTMLAttributes)];
|
||||||
|
},
|
||||||
|
group: 'block',
|
||||||
|
draggable: true,
|
||||||
|
atom: true,
|
||||||
|
content: 'inline*',
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return SvelteNodeViewRenderer(content);
|
||||||
|
},
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertIFramePlaceholder: () => (props: CommandProps) => {
|
||||||
|
return props.commands.insertContent({
|
||||||
|
type: 'iframe-placeholder'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
36
src/lib/components/edra/extensions/image/ImageExtended.ts
Normal file
36
src/lib/components/edra/extensions/image/ImageExtended.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||||
|
import Image, { type ImageOptions } from '@tiptap/extension-image';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
import type { NodeViewProps, Node } from '@tiptap/core';
|
||||||
|
|
||||||
|
export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOptions, unknown> => {
|
||||||
|
return Image.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
default: '100%'
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
align: {
|
||||||
|
default: 'left'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addNodeView: () => {
|
||||||
|
return SvelteNodeViewRenderer(component);
|
||||||
|
}
|
||||||
|
}).configure({
|
||||||
|
allowBase64: true
|
||||||
|
});
|
||||||
|
};
|
||||||
64
src/lib/components/edra/extensions/image/ImagePlaceholder.ts
Normal file
64
src/lib/components/edra/extensions/image/ImagePlaceholder.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||||
|
|
||||||
|
export interface ImagePlaceholderOptions {
|
||||||
|
HTMLAttributes: Record<string, object>;
|
||||||
|
onDrop: (files: File[], editor: Editor) => void;
|
||||||
|
onDropRejected?: (files: File[], editor: Editor) => void;
|
||||||
|
onEmbed: (url: string, editor: Editor) => void;
|
||||||
|
allowedMimeTypes?: Record<string, string[]>;
|
||||||
|
maxFiles?: number;
|
||||||
|
maxSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
imagePlaceholder: {
|
||||||
|
/**
|
||||||
|
* Inserts an image placeholder
|
||||||
|
*/
|
||||||
|
insertImagePlaceholder: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImagePlaceholder = (
|
||||||
|
component: Component<NodeViewProps>
|
||||||
|
): Node<ImagePlaceholderOptions> =>
|
||||||
|
Node.create<ImagePlaceholderOptions>({
|
||||||
|
name: 'image-placeholder',
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
onDrop: () => {},
|
||||||
|
onDropRejected: () => {},
|
||||||
|
onEmbed: () => {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['div', mergeAttributes(HTMLAttributes)];
|
||||||
|
},
|
||||||
|
group: 'block',
|
||||||
|
draggable: true,
|
||||||
|
atom: true,
|
||||||
|
content: 'inline*',
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return SvelteNodeViewRenderer(component);
|
||||||
|
},
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertImagePlaceholder: () => (props: CommandProps) => {
|
||||||
|
return props.commands.insertContent({
|
||||||
|
type: 'image-placeholder'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
55
src/lib/components/edra/extensions/slash-command/groups.ts
Normal file
55
src/lib/components/edra/extensions/slash-command/groups.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { commands } from '../../commands/commands.js';
|
||||||
|
|
||||||
|
import type { EdraCommand } from '../../commands/types.js';
|
||||||
|
import type { Editor } from '@tiptap/core';
|
||||||
|
|
||||||
|
export interface Group {
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
commands: EdraCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GROUPS: Group[] = [
|
||||||
|
{
|
||||||
|
name: 'format',
|
||||||
|
title: 'Format',
|
||||||
|
commands: [
|
||||||
|
...commands.headings.commands,
|
||||||
|
{
|
||||||
|
iconName: 'Quote',
|
||||||
|
name: 'blockquote',
|
||||||
|
label: 'Blockquote',
|
||||||
|
action: (editor: Editor) => {
|
||||||
|
editor.chain().focus().setBlockquote().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
iconName: 'SquareCode',
|
||||||
|
name: 'codeBlock',
|
||||||
|
label: 'Code Block',
|
||||||
|
action: (editor: Editor) => {
|
||||||
|
editor.chain().focus().setCodeBlock().run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
...commands.lists.commands
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'insert',
|
||||||
|
title: 'Insert',
|
||||||
|
commands: [
|
||||||
|
...commands.media.commands,
|
||||||
|
...commands.table.commands,
|
||||||
|
{
|
||||||
|
iconName: 'Minus',
|
||||||
|
name: 'horizontalRule',
|
||||||
|
label: 'Horizontal Rule',
|
||||||
|
action: (editor: Editor) => {
|
||||||
|
editor.chain().focus().setHorizontalRule().run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export default GROUPS;
|
||||||
254
src/lib/components/edra/extensions/slash-command/slashcommand.ts
Normal file
254
src/lib/components/edra/extensions/slash-command/slashcommand.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
import { Editor, Extension } from '@tiptap/core';
|
||||||
|
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion';
|
||||||
|
import { PluginKey } from '@tiptap/pm/state';
|
||||||
|
|
||||||
|
import { GROUPS } from './groups.js';
|
||||||
|
import SvelteRenderer from '../../svelte-renderer.js';
|
||||||
|
import tippy from 'tippy.js';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
|
const extensionName = 'slashCommand';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let popup: any;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export default (menuList: Component<any, any, ''>): Extension =>
|
||||||
|
Extension.create({
|
||||||
|
name: extensionName,
|
||||||
|
|
||||||
|
priority: 200,
|
||||||
|
|
||||||
|
onCreate() {
|
||||||
|
popup = tippy('body', {
|
||||||
|
interactive: true,
|
||||||
|
trigger: 'manual',
|
||||||
|
placement: 'bottom-start',
|
||||||
|
theme: 'slash-command',
|
||||||
|
maxWidth: '16rem',
|
||||||
|
offset: [16, 8],
|
||||||
|
popperOptions: {
|
||||||
|
strategy: 'fixed',
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: 'flip',
|
||||||
|
enabled: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
Suggestion({
|
||||||
|
editor: this.editor,
|
||||||
|
char: '/',
|
||||||
|
allowSpaces: true,
|
||||||
|
pluginKey: new PluginKey(extensionName),
|
||||||
|
allow: ({ state, range }) => {
|
||||||
|
const $from = state.doc.resolve(range.from);
|
||||||
|
const afterContent = $from.parent.textContent?.substring(
|
||||||
|
$from.parent.textContent?.indexOf('/')
|
||||||
|
);
|
||||||
|
const isValidAfterContent = !afterContent?.endsWith(' ');
|
||||||
|
|
||||||
|
return isValidAfterContent;
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
command: ({ editor, props }: { editor: Editor; props: any }) => {
|
||||||
|
const { view, state } = editor;
|
||||||
|
const { $head, $from } = view.state.selection;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const end = $from.pos;
|
||||||
|
const from = $head?.nodeBefore
|
||||||
|
? end -
|
||||||
|
($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf('/')).length ??
|
||||||
|
0)
|
||||||
|
: $from.start();
|
||||||
|
|
||||||
|
const tr = state.tr.deleteRange(from, end);
|
||||||
|
view.dispatch(tr);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
props.action(editor);
|
||||||
|
view.focus();
|
||||||
|
},
|
||||||
|
items: ({ query }: { query: string }) => {
|
||||||
|
const withFilteredCommands = GROUPS.map((group) => ({
|
||||||
|
...group,
|
||||||
|
commands: group.commands.filter((item) => {
|
||||||
|
const labelNormalized = item.label.toLowerCase().trim();
|
||||||
|
const queryNormalized = query.toLowerCase().trim();
|
||||||
|
return labelNormalized.includes(queryNormalized);
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const withoutEmptyGroups = withFilteredCommands.filter((group) => {
|
||||||
|
if (group.commands.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const withEnabledSettings = withoutEmptyGroups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
commands: group.commands.map((command) => ({
|
||||||
|
...command,
|
||||||
|
isEnabled: true
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
|
||||||
|
return withEnabledSettings;
|
||||||
|
},
|
||||||
|
render: () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let component: any;
|
||||||
|
|
||||||
|
let scrollHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props: SuggestionProps) => {
|
||||||
|
component = new SvelteRenderer(menuList, {
|
||||||
|
props,
|
||||||
|
editor: props.editor
|
||||||
|
});
|
||||||
|
|
||||||
|
const { view } = props.editor;
|
||||||
|
|
||||||
|
const getReferenceClientRect = () => {
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return props.editor.storage[extensionName].rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = props.clientRect();
|
||||||
|
|
||||||
|
if (!rect) {
|
||||||
|
return props.editor.storage[extensionName].rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
let yPos = rect.y;
|
||||||
|
|
||||||
|
if (rect.top + component.element.offsetHeight + 40 > window.innerHeight) {
|
||||||
|
const diff =
|
||||||
|
rect.top + component.element.offsetHeight - window.innerHeight + 40;
|
||||||
|
yPos = rect.y - diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DOMRect(rect.x, yPos, rect.width, rect.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollHandler = () => {
|
||||||
|
popup?.[0].setProps({
|
||||||
|
getReferenceClientRect
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
|
||||||
|
|
||||||
|
popup?.[0].setProps({
|
||||||
|
getReferenceClientRect,
|
||||||
|
appendTo: () => document.body,
|
||||||
|
content: component.element
|
||||||
|
});
|
||||||
|
|
||||||
|
popup?.[0].show();
|
||||||
|
},
|
||||||
|
|
||||||
|
onUpdate(props: SuggestionProps) {
|
||||||
|
component.updateProps(props);
|
||||||
|
|
||||||
|
const { view } = props.editor;
|
||||||
|
|
||||||
|
const getReferenceClientRect = () => {
|
||||||
|
if (!props.clientRect) {
|
||||||
|
return props.editor.storage[extensionName].rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = props.clientRect();
|
||||||
|
|
||||||
|
if (!rect) {
|
||||||
|
return props.editor.storage[extensionName].rect;
|
||||||
|
}
|
||||||
|
|
||||||
|
let yPos = rect.y;
|
||||||
|
|
||||||
|
if (rect.top + component.element.offsetHeight + 40 > window.innerHeight) {
|
||||||
|
const diff =
|
||||||
|
rect.top + component.element.offsetHeight - window.innerHeight + 40;
|
||||||
|
yPos = rect.y - diff;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DOMRect(rect.x, yPos, rect.width, rect.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollHandler = () => {
|
||||||
|
popup?.[0].setProps({
|
||||||
|
getReferenceClientRect
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
|
||||||
|
|
||||||
|
props.editor.storage[extensionName].rect = props.clientRect
|
||||||
|
? getReferenceClientRect()
|
||||||
|
: {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0
|
||||||
|
};
|
||||||
|
popup?.[0].setProps({
|
||||||
|
getReferenceClientRect
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onKeyDown(props: SuggestionKeyDownProps) {
|
||||||
|
if (props.event.key === 'Escape') {
|
||||||
|
popup?.[0].hide();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!popup?.[0].state.isShown) {
|
||||||
|
popup?.[0].show();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.event.key === 'Enter') return true;
|
||||||
|
|
||||||
|
// return component.ref?.onKeyDown(props);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
onExit(props) {
|
||||||
|
popup?.[0].hide();
|
||||||
|
if (scrollHandler) {
|
||||||
|
const { view } = props.editor;
|
||||||
|
view.dom.parentElement?.removeEventListener('scroll', scrollHandler);
|
||||||
|
}
|
||||||
|
component.destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
addStorage() {
|
||||||
|
return {
|
||||||
|
rect: {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
4
src/lib/components/edra/extensions/table/index.ts
Normal file
4
src/lib/components/edra/extensions/table/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { Table } from './table.js';
|
||||||
|
export { TableCell } from './table-cell.js';
|
||||||
|
export { TableRow } from './table-row.js';
|
||||||
|
export { TableHeader } from './table-header.js';
|
||||||
124
src/lib/components/edra/extensions/table/table-cell.ts
Normal file
124
src/lib/components/edra/extensions/table/table-cell.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { mergeAttributes, Node } from '@tiptap/core';
|
||||||
|
import { Plugin } from '@tiptap/pm/state';
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||||
|
|
||||||
|
import { getCellsInColumn, isRowSelected, selectRow } from './utils.js';
|
||||||
|
|
||||||
|
export interface TableCellOptions {
|
||||||
|
HTMLAttributes: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableCell = Node.create<TableCellOptions>({
|
||||||
|
name: 'tableCell',
|
||||||
|
|
||||||
|
content: 'block+',
|
||||||
|
tableRole: 'cell',
|
||||||
|
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: 'td' }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
colspan: {
|
||||||
|
default: 1,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const colspan = element.getAttribute('colspan');
|
||||||
|
const value = colspan ? parseInt(colspan, 10) : 1;
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rowspan: {
|
||||||
|
default: 1,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const rowspan = element.getAttribute('rowspan');
|
||||||
|
const value = rowspan ? parseInt(rowspan, 10) : 1;
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colwidth: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const colwidth = element.getAttribute('colwidth');
|
||||||
|
const value = colwidth ? [parseInt(colwidth, 10)] : null;
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const { isEditable } = this.editor;
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
props: {
|
||||||
|
decorations: (state) => {
|
||||||
|
if (!isEditable) {
|
||||||
|
return DecorationSet.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { doc, selection } = state;
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
const cells = getCellsInColumn(0)(selection);
|
||||||
|
|
||||||
|
if (cells) {
|
||||||
|
cells.forEach(({ pos }: { pos: number }, index: number) => {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget(pos + 1, () => {
|
||||||
|
const rowSelected = isRowSelected(index)(selection);
|
||||||
|
let className = 'grip-row';
|
||||||
|
|
||||||
|
if (rowSelected) {
|
||||||
|
className += ' selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
className += ' first';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === cells.length - 1) {
|
||||||
|
className += ' last';
|
||||||
|
}
|
||||||
|
|
||||||
|
const grip = document.createElement('a');
|
||||||
|
|
||||||
|
grip.className = className;
|
||||||
|
grip.addEventListener('mousedown', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
this.editor.view.dispatch(selectRow(index)(this.editor.state.tr));
|
||||||
|
});
|
||||||
|
|
||||||
|
return grip;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
89
src/lib/components/edra/extensions/table/table-header.ts
Normal file
89
src/lib/components/edra/extensions/table/table-header.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import TiptapTableHeader from '@tiptap/extension-table-header';
|
||||||
|
import { Plugin } from '@tiptap/pm/state';
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||||
|
|
||||||
|
import { getCellsInRow, isColumnSelected, selectColumn } from './utils.js';
|
||||||
|
|
||||||
|
export const TableHeader = TiptapTableHeader.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
colspan: {
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
rowspan: {
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
colwidth: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (element) => {
|
||||||
|
const colwidth = element.getAttribute('colwidth');
|
||||||
|
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null;
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
const { isEditable } = this.editor;
|
||||||
|
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
props: {
|
||||||
|
decorations: (state) => {
|
||||||
|
if (!isEditable) {
|
||||||
|
return DecorationSet.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { doc, selection } = state;
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
const cells = getCellsInRow(0)(selection);
|
||||||
|
|
||||||
|
if (cells) {
|
||||||
|
cells.forEach(({ pos }: { pos: number }, index: number) => {
|
||||||
|
decorations.push(
|
||||||
|
Decoration.widget(pos + 1, () => {
|
||||||
|
const colSelected = isColumnSelected(index)(selection);
|
||||||
|
let className = 'grip-column';
|
||||||
|
|
||||||
|
if (colSelected) {
|
||||||
|
className += ' selected';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
className += ' first';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index === cells.length - 1) {
|
||||||
|
className += ' last';
|
||||||
|
}
|
||||||
|
|
||||||
|
const grip = document.createElement('a');
|
||||||
|
|
||||||
|
grip.className = className;
|
||||||
|
grip.addEventListener('mousedown', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr));
|
||||||
|
});
|
||||||
|
|
||||||
|
return grip;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TableHeader;
|
||||||
8
src/lib/components/edra/extensions/table/table-row.ts
Normal file
8
src/lib/components/edra/extensions/table/table-row.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import TiptapTableRow from '@tiptap/extension-table-row';
|
||||||
|
|
||||||
|
export const TableRow = TiptapTableRow.extend({
|
||||||
|
allowGapCursor: false,
|
||||||
|
content: 'tableCell*'
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TableRow;
|
||||||
9
src/lib/components/edra/extensions/table/table.ts
Normal file
9
src/lib/components/edra/extensions/table/table.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import TiptapTable from '@tiptap/extension-table';
|
||||||
|
|
||||||
|
export const Table = TiptapTable.configure({
|
||||||
|
resizable: true,
|
||||||
|
lastColumnResizable: true,
|
||||||
|
allowTableNodeSelection: true
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Table;
|
||||||
322
src/lib/components/edra/extensions/table/utils.ts
Normal file
322
src/lib/components/edra/extensions/table/utils.ts
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
import { Editor, findParentNode } from '@tiptap/core';
|
||||||
|
import { EditorState, Selection, Transaction } from '@tiptap/pm/state';
|
||||||
|
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables';
|
||||||
|
import { Node, ResolvedPos } from '@tiptap/pm/model';
|
||||||
|
import type { EditorView } from '@tiptap/pm/view';
|
||||||
|
import Table from './table.js';
|
||||||
|
|
||||||
|
export const isRectSelected = (rect: Rect) => (selection: CellSelection) => {
|
||||||
|
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||||
|
const start = selection.$anchorCell.start(-1);
|
||||||
|
const cells = map.cellsInRect(rect);
|
||||||
|
const selectedCells = map.cellsInRect(
|
||||||
|
map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0, count = cells.length; i < count; i += 1) {
|
||||||
|
if (selectedCells.indexOf(cells[i]) === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findTable = (selection: Selection) =>
|
||||||
|
findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(
|
||||||
|
selection
|
||||||
|
);
|
||||||
|
|
||||||
|
export const isCellSelection = (selection: Selection): selection is CellSelection =>
|
||||||
|
selection instanceof CellSelection;
|
||||||
|
|
||||||
|
export const isColumnSelected = (columnIndex: number) => (selection: Selection) => {
|
||||||
|
if (isCellSelection(selection)) {
|
||||||
|
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||||
|
|
||||||
|
return isRectSelected({
|
||||||
|
left: columnIndex,
|
||||||
|
right: columnIndex + 1,
|
||||||
|
top: 0,
|
||||||
|
bottom: map.height
|
||||||
|
})(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isRowSelected = (rowIndex: number) => (selection: Selection) => {
|
||||||
|
if (isCellSelection(selection)) {
|
||||||
|
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||||
|
|
||||||
|
return isRectSelected({
|
||||||
|
left: 0,
|
||||||
|
right: map.width,
|
||||||
|
top: rowIndex,
|
||||||
|
bottom: rowIndex + 1
|
||||||
|
})(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isTableSelected = (selection: Selection) => {
|
||||||
|
if (isCellSelection(selection)) {
|
||||||
|
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||||
|
|
||||||
|
return isRectSelected({
|
||||||
|
left: 0,
|
||||||
|
right: map.width,
|
||||||
|
top: 0,
|
||||||
|
bottom: map.height
|
||||||
|
})(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => {
|
||||||
|
const table = findTable(selection);
|
||||||
|
if (table) {
|
||||||
|
const map = TableMap.get(table.node);
|
||||||
|
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]);
|
||||||
|
|
||||||
|
return indexes.reduce(
|
||||||
|
(acc, index) => {
|
||||||
|
if (index >= 0 && index <= map.width - 1) {
|
||||||
|
const cells = map.cellsInRect({
|
||||||
|
left: index,
|
||||||
|
right: index + 1,
|
||||||
|
top: 0,
|
||||||
|
bottom: map.height
|
||||||
|
});
|
||||||
|
|
||||||
|
return acc.concat(
|
||||||
|
cells.map((nodePos) => {
|
||||||
|
const node = table.node.nodeAt(nodePos);
|
||||||
|
const pos = nodePos + table.start;
|
||||||
|
|
||||||
|
return { pos, start: pos + 1, node };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[] as { pos: number; start: number; node: Node | null | undefined }[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => {
|
||||||
|
const table = findTable(selection);
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
const map = TableMap.get(table.node);
|
||||||
|
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]);
|
||||||
|
|
||||||
|
return indexes.reduce(
|
||||||
|
(acc, index) => {
|
||||||
|
if (index >= 0 && index <= map.height - 1) {
|
||||||
|
const cells = map.cellsInRect({
|
||||||
|
left: 0,
|
||||||
|
right: map.width,
|
||||||
|
top: index,
|
||||||
|
bottom: index + 1
|
||||||
|
});
|
||||||
|
|
||||||
|
return acc.concat(
|
||||||
|
cells.map((nodePos) => {
|
||||||
|
const node = table.node.nodeAt(nodePos);
|
||||||
|
const pos = nodePos + table.start;
|
||||||
|
return { pos, start: pos + 1, node };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
[] as { pos: number; start: number; node: Node | null | undefined }[]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCellsInTable = (selection: Selection) => {
|
||||||
|
const table = findTable(selection);
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
const map = TableMap.get(table.node);
|
||||||
|
const cells = map.cellsInRect({
|
||||||
|
left: 0,
|
||||||
|
right: map.width,
|
||||||
|
top: 0,
|
||||||
|
bottom: map.height
|
||||||
|
});
|
||||||
|
|
||||||
|
return cells.map((nodePos) => {
|
||||||
|
const node = table.node.nodeAt(nodePos);
|
||||||
|
const pos = nodePos + table.start;
|
||||||
|
|
||||||
|
return { pos, start: pos + 1, node };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findParentNodeClosestToPos = (
|
||||||
|
$pos: ResolvedPos,
|
||||||
|
predicate: (node: Node) => boolean
|
||||||
|
) => {
|
||||||
|
for (let i = $pos.depth; i > 0; i -= 1) {
|
||||||
|
const node = $pos.node(i);
|
||||||
|
|
||||||
|
if (predicate(node)) {
|
||||||
|
return {
|
||||||
|
pos: i > 0 ? $pos.before(i) : 0,
|
||||||
|
start: $pos.start(i),
|
||||||
|
depth: i,
|
||||||
|
node
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findCellClosestToPos = ($pos: ResolvedPos) => {
|
||||||
|
const predicate = (node: Node) =>
|
||||||
|
node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
|
||||||
|
|
||||||
|
return findParentNodeClosestToPos($pos, predicate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction) => {
|
||||||
|
const table = findTable(tr.selection);
|
||||||
|
const isRowSelection = type === 'row';
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
const map = TableMap.get(table.node);
|
||||||
|
|
||||||
|
// Check if the index is valid
|
||||||
|
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
|
||||||
|
const left = isRowSelection ? 0 : index;
|
||||||
|
const top = isRowSelection ? index : 0;
|
||||||
|
const right = isRowSelection ? map.width : index + 1;
|
||||||
|
const bottom = isRowSelection ? index + 1 : map.height;
|
||||||
|
|
||||||
|
const cellsInFirstRow = map.cellsInRect({
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
right: isRowSelection ? right : left + 1,
|
||||||
|
bottom: isRowSelection ? top + 1 : bottom
|
||||||
|
});
|
||||||
|
|
||||||
|
const cellsInLastRow =
|
||||||
|
bottom - top === 1
|
||||||
|
? cellsInFirstRow
|
||||||
|
: map.cellsInRect({
|
||||||
|
left: isRowSelection ? left : right - 1,
|
||||||
|
top: isRowSelection ? bottom - 1 : top,
|
||||||
|
right,
|
||||||
|
bottom
|
||||||
|
});
|
||||||
|
|
||||||
|
const head = table.start + cellsInFirstRow[0];
|
||||||
|
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1];
|
||||||
|
const $head = tr.doc.resolve(head);
|
||||||
|
const $anchor = tr.doc.resolve(anchor);
|
||||||
|
|
||||||
|
return tr.setSelection(new CellSelection($anchor, $head));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectColumn = select('column');
|
||||||
|
|
||||||
|
export const selectRow = select('row');
|
||||||
|
|
||||||
|
export const selectTable = (tr: Transaction) => {
|
||||||
|
const table = findTable(tr.selection);
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
const { map } = TableMap.get(table.node);
|
||||||
|
|
||||||
|
if (map && map.length) {
|
||||||
|
const head = table.start + map[0];
|
||||||
|
const anchor = table.start + map[map.length - 1];
|
||||||
|
const $head = tr.doc.resolve(head);
|
||||||
|
const $anchor = tr.doc.resolve(anchor);
|
||||||
|
|
||||||
|
return tr.setSelection(new CellSelection($anchor, $head));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tr;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isColumnGripSelected = ({
|
||||||
|
editor,
|
||||||
|
view,
|
||||||
|
state,
|
||||||
|
from
|
||||||
|
}: {
|
||||||
|
editor: Editor;
|
||||||
|
view: EditorView;
|
||||||
|
state: EditorState;
|
||||||
|
from: number;
|
||||||
|
}) => {
|
||||||
|
const domAtPos = view.domAtPos(from).node as HTMLElement;
|
||||||
|
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
||||||
|
const node = nodeDOM || domAtPos;
|
||||||
|
|
||||||
|
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let container = node;
|
||||||
|
|
||||||
|
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||||
|
container = container.parentElement!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gripColumn =
|
||||||
|
container && container.querySelector && container.querySelector('a.grip-column.selected');
|
||||||
|
|
||||||
|
return !!gripColumn;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isRowGripSelected = ({
|
||||||
|
editor,
|
||||||
|
view,
|
||||||
|
state,
|
||||||
|
from
|
||||||
|
}: {
|
||||||
|
editor: Editor;
|
||||||
|
view: EditorView;
|
||||||
|
state: EditorState;
|
||||||
|
from: number;
|
||||||
|
}) => {
|
||||||
|
const domAtPos = view.domAtPos(from).node as HTMLElement;
|
||||||
|
const nodeDOM = view.nodeDOM(from) as HTMLElement;
|
||||||
|
const node = nodeDOM || domAtPos;
|
||||||
|
|
||||||
|
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let container = node;
|
||||||
|
|
||||||
|
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||||
|
container = container.parentElement!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gripRow =
|
||||||
|
container && container.querySelector && container.querySelector('a.grip-row.selected');
|
||||||
|
|
||||||
|
return !!gripRow;
|
||||||
|
};
|
||||||
34
src/lib/components/edra/extensions/video/VideoExtended.ts
Normal file
34
src/lib/components/edra/extensions/video/VideoExtended.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||||
|
import { Video } from './VideoExtension.js';
|
||||||
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
|
export const VideoExtended = (content: Component<NodeViewProps>) =>
|
||||||
|
Video.extend({
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
alt: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
default: '100%'
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
align: {
|
||||||
|
default: 'left'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addNodeView: () => {
|
||||||
|
return SvelteNodeViewRenderer(content);
|
||||||
|
}
|
||||||
|
});
|
||||||
147
src/lib/components/edra/extensions/video/VideoExtension.ts
Normal file
147
src/lib/components/edra/extensions/video/VideoExtension.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
import { Node, nodeInputRule } from '@tiptap/core';
|
||||||
|
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||||
|
|
||||||
|
export interface VideoOptions {
|
||||||
|
HTMLAttributes: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
video: {
|
||||||
|
/**
|
||||||
|
* Set a video node
|
||||||
|
*/
|
||||||
|
setVideo: (src: string) => ReturnType;
|
||||||
|
/**
|
||||||
|
* Toggle a video
|
||||||
|
*/
|
||||||
|
toggleVideo: (src: string) => ReturnType;
|
||||||
|
/**
|
||||||
|
* Remove a video
|
||||||
|
*/
|
||||||
|
removeVideo: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
|
||||||
|
|
||||||
|
export const Video = Node.create<VideoOptions>({
|
||||||
|
name: 'video',
|
||||||
|
group: 'block',
|
||||||
|
content: 'inline*',
|
||||||
|
draggable: true,
|
||||||
|
isolating: true,
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
src: {
|
||||||
|
default: null,
|
||||||
|
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
|
||||||
|
renderHTML: (attrs) => ({ src: attrs.src })
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: 'video',
|
||||||
|
getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') })
|
||||||
|
}
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return [
|
||||||
|
'video',
|
||||||
|
{ controls: 'true', style: 'width: fit-content;', ...HTMLAttributes },
|
||||||
|
['source', HTMLAttributes]
|
||||||
|
];
|
||||||
|
},
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
setVideo:
|
||||||
|
(src: string) =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.insertContent(
|
||||||
|
`<video controls="true" autoplay="false" style="width: fit-content" src="${src}" />`
|
||||||
|
),
|
||||||
|
|
||||||
|
toggleVideo:
|
||||||
|
() =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.toggleNode(this.name, 'paragraph'),
|
||||||
|
removeVideo:
|
||||||
|
() =>
|
||||||
|
({ commands }) =>
|
||||||
|
commands.deleteNode(this.name)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
nodeInputRule({
|
||||||
|
find: VIDEO_INPUT_REGEX,
|
||||||
|
type: this.type,
|
||||||
|
getAttributes: (match) => {
|
||||||
|
const [, , src] = match;
|
||||||
|
|
||||||
|
return { src };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
},
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
new Plugin({
|
||||||
|
key: new PluginKey('videoDropPlugin'),
|
||||||
|
|
||||||
|
props: {
|
||||||
|
handleDOMEvents: {
|
||||||
|
drop(view, event) {
|
||||||
|
const {
|
||||||
|
state: { schema, tr },
|
||||||
|
dispatch
|
||||||
|
} = view;
|
||||||
|
const hasFiles =
|
||||||
|
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
|
||||||
|
|
||||||
|
if (!hasFiles) return false;
|
||||||
|
|
||||||
|
const videos = Array.from(event.dataTransfer.files).filter((file) =>
|
||||||
|
/video/i.test(file.type)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (videos.length === 0) return false;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
|
||||||
|
|
||||||
|
videos.forEach((video) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (readerEvent) => {
|
||||||
|
const node = schema.nodes.video.create({ src: readerEvent.target?.result });
|
||||||
|
|
||||||
|
if (coordinates && typeof coordinates.pos === 'number') {
|
||||||
|
const transaction = tr.insert(coordinates?.pos, node);
|
||||||
|
|
||||||
|
dispatch(transaction);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(video);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
});
|
||||||
62
src/lib/components/edra/extensions/video/VideoPlaceholder.ts
Normal file
62
src/lib/components/edra/extensions/video/VideoPlaceholder.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||||
|
|
||||||
|
export interface VideoPlaceholderOptions {
|
||||||
|
HTMLAttributes: Record<string, object>;
|
||||||
|
onDrop: (files: File[], editor: Editor) => void;
|
||||||
|
onDropRejected?: (files: File[], editor: Editor) => void;
|
||||||
|
onEmbed: (url: string, editor: Editor) => void;
|
||||||
|
allowedMimeTypes?: Record<string, string[]>;
|
||||||
|
maxFiles?: number;
|
||||||
|
maxSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tiptap/core' {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
videoPlaceholder: {
|
||||||
|
/**
|
||||||
|
* Inserts a video placeholder
|
||||||
|
*/
|
||||||
|
insertVideoPlaceholder: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VideoPlaceholder = (content: Component<NodeViewProps>) =>
|
||||||
|
Node.create<VideoPlaceholderOptions>({
|
||||||
|
name: 'video-placeholder',
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {},
|
||||||
|
onDrop: () => {},
|
||||||
|
onDropRejected: () => {},
|
||||||
|
onEmbed: () => {}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parseHTML() {
|
||||||
|
return [{ tag: `div[data-type="${this.name}"]` }];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ['div', mergeAttributes(HTMLAttributes)];
|
||||||
|
},
|
||||||
|
group: 'block',
|
||||||
|
draggable: true,
|
||||||
|
atom: true,
|
||||||
|
content: 'inline*',
|
||||||
|
isolating: true,
|
||||||
|
|
||||||
|
addNodeView() {
|
||||||
|
return SvelteNodeViewRenderer(content);
|
||||||
|
},
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
insertVideoPlaceholder: () => (props: CommandProps) => {
|
||||||
|
return props.commands.insertContent({
|
||||||
|
type: 'video-placeholder'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
226
src/lib/components/edra/headless/components/AudioExtended.svelte
Normal file
226
src/lib/components/edra/headless/components/AudioExtended.svelte
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||||
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
|
|
||||||
|
import AlignLeft from 'lucide-svelte/icons/align-left';
|
||||||
|
import AlignCenter from 'lucide-svelte/icons/align-center';
|
||||||
|
import AlignRight from 'lucide-svelte/icons/align-right';
|
||||||
|
import CopyIcon from 'lucide-svelte/icons/copy';
|
||||||
|
import Fullscreen from 'lucide-svelte/icons/fullscreen';
|
||||||
|
import Trash from 'lucide-svelte/icons/trash';
|
||||||
|
import Captions from 'lucide-svelte/icons/captions';
|
||||||
|
|
||||||
|
import { duplicateContent } from '../../utils.js';
|
||||||
|
|
||||||
|
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props();
|
||||||
|
|
||||||
|
const minWidth = 150;
|
||||||
|
|
||||||
|
let audRef: HTMLAudioElement;
|
||||||
|
let nodeRef: HTMLDivElement;
|
||||||
|
|
||||||
|
let caption: string | null = $state(node.attrs.title);
|
||||||
|
$effect(() => {
|
||||||
|
if (caption?.trim() === '') caption = null;
|
||||||
|
updateAttributes({ title: caption });
|
||||||
|
});
|
||||||
|
|
||||||
|
let resizing = $state(false);
|
||||||
|
let resizingInitialWidth = $state(0);
|
||||||
|
let resizingInitialMouseX = $state(0);
|
||||||
|
let resizingPosition = $state<'left' | 'right'>('left');
|
||||||
|
|
||||||
|
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||||
|
startResize(e);
|
||||||
|
resizingPosition = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
resizing = true;
|
||||||
|
resizingInitialMouseX = e.clientX;
|
||||||
|
if (audRef) resizingInitialWidth = audRef.offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize(e: MouseEvent) {
|
||||||
|
if (!resizing) return;
|
||||||
|
let dx = e.clientX - resizingInitialMouseX;
|
||||||
|
if (resizingPosition === 'left') {
|
||||||
|
dx = resizingInitialMouseX - e.clientX;
|
||||||
|
}
|
||||||
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||||
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||||
|
if (newWidth < parentWidth) {
|
||||||
|
updateAttributes({ width: newWidth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endResize() {
|
||||||
|
resizing = false;
|
||||||
|
resizingInitialMouseX = 0;
|
||||||
|
resizingInitialWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||||
|
e.preventDefault();
|
||||||
|
resizing = true;
|
||||||
|
resizingPosition = position;
|
||||||
|
resizingInitialMouseX = e.touches[0].clientX;
|
||||||
|
if (audRef) resizingInitialWidth = audRef.offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(e: TouchEvent) {
|
||||||
|
if (!resizing) return;
|
||||||
|
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
||||||
|
if (resizingPosition === 'left') {
|
||||||
|
dx = resizingInitialMouseX - e.touches[0].clientX;
|
||||||
|
}
|
||||||
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||||
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||||
|
if (newWidth < parentWidth) {
|
||||||
|
updateAttributes({ width: newWidth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd() {
|
||||||
|
resizing = false;
|
||||||
|
resizingInitialMouseX = 0;
|
||||||
|
resizingInitialWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Attach id to nodeRef
|
||||||
|
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement;
|
||||||
|
|
||||||
|
// Mouse events
|
||||||
|
window.addEventListener('mousemove', resize);
|
||||||
|
window.addEventListener('mouseup', endResize);
|
||||||
|
// Touch events
|
||||||
|
window.addEventListener('touchmove', handleTouchMove);
|
||||||
|
window.addEventListener('touchend', handleTouchEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('mousemove', resize);
|
||||||
|
window.removeEventListener('mouseup', endResize);
|
||||||
|
window.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
window.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NodeViewWrapper
|
||||||
|
id="resizable-container-audio"
|
||||||
|
class={`edra-media-container ${selected ? 'selected' : ''} align-${node.attrs.align}`}
|
||||||
|
style={`width: ${node.attrs.width}px`}
|
||||||
|
>
|
||||||
|
<div class={`edra-media-group ${resizing ? 'resizing' : ''}`}>
|
||||||
|
<audio
|
||||||
|
bind:this={audRef}
|
||||||
|
src={node.attrs.src}
|
||||||
|
controls
|
||||||
|
title={node.attrs.title}
|
||||||
|
class="edra-media-content"
|
||||||
|
style="width: 100%;"
|
||||||
|
>
|
||||||
|
</audio>
|
||||||
|
|
||||||
|
{#if caption !== null}
|
||||||
|
<input bind:value={caption} type="text" class="edra-media-caption" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editor?.isEditable}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Resize left"
|
||||||
|
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||||
|
onmousedown={(event: MouseEvent) => {
|
||||||
|
handleResizingPosition(event, 'left');
|
||||||
|
}}
|
||||||
|
ontouchstart={(event: TouchEvent) => {
|
||||||
|
handleTouchStart(event, 'left');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Resize right"
|
||||||
|
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||||
|
onmousedown={(event: MouseEvent) => {
|
||||||
|
handleResizingPosition(event, 'right');
|
||||||
|
}}
|
||||||
|
ontouchstart={(event: TouchEvent) => {
|
||||||
|
handleTouchStart(event, 'right');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="edra-media-toolbar edra-media-toolbar-audio">
|
||||||
|
<button
|
||||||
|
class={`edra-toolbar-button ${node.attrs.align === 'left' ? 'active' : ''}`}
|
||||||
|
onclick={() => updateAttributes({ align: 'left' })}
|
||||||
|
title="Align Left"
|
||||||
|
>
|
||||||
|
<AlignLeft />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`edra-toolbar-button ${node.attrs.align === 'center' ? 'active' : ''}`}
|
||||||
|
onclick={() => updateAttributes({ align: 'center' })}
|
||||||
|
title="Align Center"
|
||||||
|
>
|
||||||
|
<AlignCenter />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`edra-toolbar-button ${node.attrs.align === 'right' ? 'active' : ''}`}
|
||||||
|
onclick={() => updateAttributes({ align: 'right' })}
|
||||||
|
title="Align Right"
|
||||||
|
>
|
||||||
|
<AlignRight />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button"
|
||||||
|
onclick={() => {
|
||||||
|
if (caption === null || caption.trim() === '') caption = 'Audio Caption';
|
||||||
|
}}
|
||||||
|
title="Caption"
|
||||||
|
>
|
||||||
|
<Captions />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button"
|
||||||
|
onclick={() => {
|
||||||
|
duplicateContent(editor, node);
|
||||||
|
}}
|
||||||
|
title="Duplicate"
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button"
|
||||||
|
onclick={() => {
|
||||||
|
updateAttributes({
|
||||||
|
width: 'fit-content'
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title="Full Screen"
|
||||||
|
>
|
||||||
|
<Fullscreen />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button edra-destructive"
|
||||||
|
onclick={() => {
|
||||||
|
deleteNode();
|
||||||
|
}}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
|
import AudioLines from 'lucide-svelte/icons/audio-lines';
|
||||||
|
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||||
|
const { editor }: NodeViewProps = $props();
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (!editor.isEditable) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const audioUrl = prompt('Enter the URL of an audio:');
|
||||||
|
if (!audioUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.chain().focus().setAudio(audioUrl).run();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<span
|
||||||
|
class="edra-media-placeholder-content"
|
||||||
|
onclick={handleClick}
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="Insert An Audio"
|
||||||
|
>
|
||||||
|
<AudioLines class="edra-media-placeholder-icon" />
|
||||||
|
<span class="edra-media-placeholder-text">Insert An Audio</span>
|
||||||
|
</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap';
|
||||||
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
|
const { node, updateAttributes, extension }: NodeViewProps = $props();
|
||||||
|
|
||||||
|
let preRef = $state<HTMLPreElement>();
|
||||||
|
|
||||||
|
let isCopying = $state(false);
|
||||||
|
|
||||||
|
const languages: string[] = extension.options.lowlight.listLanguages().sort();
|
||||||
|
|
||||||
|
let defaultLanguage = $state(node.attrs.language);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
updateAttributes({ language: defaultLanguage });
|
||||||
|
});
|
||||||
|
|
||||||
|
function copyCode() {
|
||||||
|
if (isCopying) return;
|
||||||
|
if (!preRef) return;
|
||||||
|
isCopying = true;
|
||||||
|
navigator.clipboard.writeText(preRef.innerText);
|
||||||
|
setTimeout(() => {
|
||||||
|
isCopying = false;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NodeViewWrapper class="code-wrapper">
|
||||||
|
<div class="code-wrapper-tile" contenteditable="false">
|
||||||
|
<select bind:value={defaultLanguage} class="code-wrapper-select">
|
||||||
|
{#each languages as language}
|
||||||
|
<option value={language}>{language}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button class="code-wrapper-copy" onclick={copyCode}>
|
||||||
|
{#if isCopying}
|
||||||
|
<span class="code-wrapper-copy-text copied">Copied!</span>
|
||||||
|
{:else}
|
||||||
|
<span class="code-wrapper-copy-text">Copy</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre bind:this={preRef} spellcheck="false">
|
||||||
|
<NodeViewContent as="code" class={`language-${defaultLanguage}`} {...node.attrs} />
|
||||||
|
</pre>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { EdraCommand } from '../../commands/types.js';
|
||||||
|
import type { Editor } from '@tiptap/core';
|
||||||
|
import { icons } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
command: EdraCommand;
|
||||||
|
editor: Editor;
|
||||||
|
style?: string;
|
||||||
|
onclick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { command, editor, style, onclick }: Props = $props();
|
||||||
|
|
||||||
|
const Icon = icons[command.iconName];
|
||||||
|
const shortcut = command.shortCuts ? ` (${command.shortCuts[0]})` : '';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
class:active={editor.isActive(command.name) || command.isActive?.(editor)}
|
||||||
|
onclick={() => {
|
||||||
|
if (onclick !== undefined) onclick();
|
||||||
|
else command.action(editor);
|
||||||
|
}}
|
||||||
|
title={`${command.label}${shortcut}`}
|
||||||
|
{style}
|
||||||
|
>
|
||||||
|
<Icon class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||||
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
|
|
||||||
|
import AlignLeft from 'lucide-svelte/icons/align-left';
|
||||||
|
import AlignCenter from 'lucide-svelte/icons/align-center';
|
||||||
|
import AlignRight from 'lucide-svelte/icons/align-right';
|
||||||
|
import CopyIcon from 'lucide-svelte/icons/copy';
|
||||||
|
import Fullscreen from 'lucide-svelte/icons/fullscreen';
|
||||||
|
import Trash from 'lucide-svelte/icons/trash';
|
||||||
|
import Captions from 'lucide-svelte/icons/captions';
|
||||||
|
|
||||||
|
import { duplicateContent } from '../../utils.js';
|
||||||
|
|
||||||
|
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props();
|
||||||
|
|
||||||
|
const minWidth = 150;
|
||||||
|
|
||||||
|
let iframeRef: HTMLIFrameElement;
|
||||||
|
let nodeRef: HTMLDivElement;
|
||||||
|
|
||||||
|
let caption: string | null = $state(node.attrs.title);
|
||||||
|
$effect(() => {
|
||||||
|
if (caption?.trim() === '') caption = null;
|
||||||
|
updateAttributes({ title: caption });
|
||||||
|
});
|
||||||
|
|
||||||
|
let resizing = $state(false);
|
||||||
|
let resizingInitialWidth = $state(0);
|
||||||
|
let resizingInitialMouseX = $state(0);
|
||||||
|
let resizingPosition = $state<'left' | 'right'>('left');
|
||||||
|
|
||||||
|
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||||
|
startResize(e);
|
||||||
|
resizingPosition = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
resizing = true;
|
||||||
|
resizingInitialMouseX = e.clientX;
|
||||||
|
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize(e: MouseEvent) {
|
||||||
|
if (!resizing) return;
|
||||||
|
let dx = e.clientX - resizingInitialMouseX;
|
||||||
|
if (resizingPosition === 'left') {
|
||||||
|
dx = resizingInitialMouseX - e.clientX;
|
||||||
|
}
|
||||||
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||||
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||||
|
if (newWidth < parentWidth) {
|
||||||
|
updateAttributes({ width: newWidth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endResize() {
|
||||||
|
resizing = false;
|
||||||
|
resizingInitialMouseX = 0;
|
||||||
|
resizingInitialWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||||
|
e.preventDefault();
|
||||||
|
resizing = true;
|
||||||
|
resizingPosition = position;
|
||||||
|
resizingInitialMouseX = e.touches[0].clientX;
|
||||||
|
if (iframeRef) resizingInitialWidth = iframeRef.offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(e: TouchEvent) {
|
||||||
|
if (!resizing) return;
|
||||||
|
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
||||||
|
if (resizingPosition === 'left') {
|
||||||
|
dx = resizingInitialMouseX - e.touches[0].clientX;
|
||||||
|
}
|
||||||
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||||
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||||
|
if (newWidth < parentWidth) {
|
||||||
|
updateAttributes({ width: newWidth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd() {
|
||||||
|
resizing = false;
|
||||||
|
resizingInitialMouseX = 0;
|
||||||
|
resizingInitialWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Attach id to nodeRef
|
||||||
|
nodeRef = document.getElementById('resizable-container-audio') as HTMLDivElement;
|
||||||
|
|
||||||
|
// Mouse events
|
||||||
|
window.addEventListener('mousemove', resize);
|
||||||
|
window.addEventListener('mouseup', endResize);
|
||||||
|
// Touch events
|
||||||
|
window.addEventListener('touchmove', handleTouchMove);
|
||||||
|
window.addEventListener('touchend', handleTouchEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('mousemove', resize);
|
||||||
|
window.removeEventListener('mouseup', endResize);
|
||||||
|
window.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
window.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NodeViewWrapper
|
||||||
|
id="resizable-container-audio"
|
||||||
|
class={`edra-media-container ${selected ? 'selected' : ''} align-${node.attrs.align}`}
|
||||||
|
style={`width: ${node.attrs.width}px`}
|
||||||
|
spellcheck={false}
|
||||||
|
>
|
||||||
|
<div class={`edra-media-group ${resizing ? 'resizing' : ''}`}>
|
||||||
|
<iframe bind:this={iframeRef} class="edra-media-content" style="width: 100%;" {...node.attrs}>
|
||||||
|
</iframe>
|
||||||
|
|
||||||
|
{#if caption !== null}
|
||||||
|
<input bind:value={caption} type="text" class="edra-media-caption" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editor?.isEditable}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Resize left"
|
||||||
|
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||||
|
onmousedown={(event: MouseEvent) => {
|
||||||
|
handleResizingPosition(event, 'left');
|
||||||
|
}}
|
||||||
|
ontouchstart={(event: TouchEvent) => {
|
||||||
|
handleTouchStart(event, 'left');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Resize right"
|
||||||
|
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||||
|
onmousedown={(event: MouseEvent) => {
|
||||||
|
handleResizingPosition(event, 'right');
|
||||||
|
}}
|
||||||
|
ontouchstart={(event: TouchEvent) => {
|
||||||
|
handleTouchStart(event, 'right');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="edra-media-toolbar edra-media-toolbar-audio">
|
||||||
|
<button
|
||||||
|
class={`edra-toolbar-button ${node.attrs.align === 'left' ? 'active' : ''}`}
|
||||||
|
onclick={() => updateAttributes({ align: 'left' })}
|
||||||
|
title="Align Left"
|
||||||
|
>
|
||||||
|
<AlignLeft />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`edra-toolbar-button ${node.attrs.align === 'center' ? 'active' : ''}`}
|
||||||
|
onclick={() => updateAttributes({ align: 'center' })}
|
||||||
|
title="Align Center"
|
||||||
|
>
|
||||||
|
<AlignCenter />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`edra-toolbar-button ${node.attrs.align === 'right' ? 'active' : ''}`}
|
||||||
|
onclick={() => updateAttributes({ align: 'right' })}
|
||||||
|
title="Align Right"
|
||||||
|
>
|
||||||
|
<AlignRight />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button"
|
||||||
|
onclick={() => {
|
||||||
|
if (caption === null || caption.trim() === '') caption = 'Audio Caption';
|
||||||
|
}}
|
||||||
|
title="Caption"
|
||||||
|
>
|
||||||
|
<Captions />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button"
|
||||||
|
onclick={() => {
|
||||||
|
duplicateContent(editor, node);
|
||||||
|
}}
|
||||||
|
title="Duplicate"
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button"
|
||||||
|
onclick={() => {
|
||||||
|
updateAttributes({
|
||||||
|
width: 'fit-content'
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title="Full Screen"
|
||||||
|
>
|
||||||
|
<Fullscreen />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button edra-destructive"
|
||||||
|
onclick={() => {
|
||||||
|
deleteNode();
|
||||||
|
}}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
|
import CodeXML from 'lucide-svelte/icons/code-xml';
|
||||||
|
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||||
|
const { editor }: NodeViewProps = $props();
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (!editor.isEditable) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const iFrameURL = prompt('Enter the URL of an iFrame:');
|
||||||
|
if (!iFrameURL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.chain().focus().setIframe({ src: iFrameURL }).run();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable={false} spellcheck={false}>
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<span
|
||||||
|
class="edra-media-placeholder-content"
|
||||||
|
onclick={handleClick}
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="Insert An Audio"
|
||||||
|
>
|
||||||
|
<CodeXML class="edra-media-placeholder-icon" />
|
||||||
|
<span class="edra-media-placeholder-text">Insert An IFrame</span>
|
||||||
|
</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
223
src/lib/components/edra/headless/components/ImageExtended.svelte
Normal file
223
src/lib/components/edra/headless/components/ImageExtended.svelte
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||||
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
|
|
||||||
|
import AlignLeft from 'lucide-svelte/icons/align-left';
|
||||||
|
import AlignCenter from 'lucide-svelte/icons/align-center';
|
||||||
|
import AlignRight from 'lucide-svelte/icons/align-right';
|
||||||
|
import CopyIcon from 'lucide-svelte/icons/copy';
|
||||||
|
import Fullscreen from 'lucide-svelte/icons/fullscreen';
|
||||||
|
import Trash from 'lucide-svelte/icons/trash';
|
||||||
|
import Captions from 'lucide-svelte/icons/captions';
|
||||||
|
|
||||||
|
import { duplicateContent } from '../../utils.js';
|
||||||
|
|
||||||
|
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props();
|
||||||
|
|
||||||
|
const minWidth = 150;
|
||||||
|
|
||||||
|
let imgRef: HTMLImageElement;
|
||||||
|
let nodeRef: HTMLDivElement;
|
||||||
|
|
||||||
|
let caption: string | null = $state(node.attrs.title);
|
||||||
|
$effect(() => {
|
||||||
|
if (caption?.trim() === '') caption = null;
|
||||||
|
updateAttributes({ title: caption });
|
||||||
|
});
|
||||||
|
|
||||||
|
let resizing = $state(false);
|
||||||
|
let resizingInitialWidth = $state(0);
|
||||||
|
let resizingInitialMouseX = $state(0);
|
||||||
|
let resizingPosition = $state<'left' | 'right'>('left');
|
||||||
|
|
||||||
|
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||||
|
startResize(e);
|
||||||
|
resizingPosition = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
resizing = true;
|
||||||
|
resizingInitialMouseX = e.clientX;
|
||||||
|
if (imgRef) resizingInitialWidth = imgRef.offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize(e: MouseEvent) {
|
||||||
|
if (!resizing) return;
|
||||||
|
let dx = e.clientX - resizingInitialMouseX;
|
||||||
|
if (resizingPosition === 'left') {
|
||||||
|
dx = resizingInitialMouseX - e.clientX;
|
||||||
|
}
|
||||||
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||||
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||||
|
if (newWidth < parentWidth) {
|
||||||
|
updateAttributes({ width: newWidth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endResize() {
|
||||||
|
resizing = false;
|
||||||
|
resizingInitialMouseX = 0;
|
||||||
|
resizingInitialWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||||
|
e.preventDefault();
|
||||||
|
resizing = true;
|
||||||
|
resizingPosition = position;
|
||||||
|
resizingInitialMouseX = e.touches[0].clientX;
|
||||||
|
if (imgRef) resizingInitialWidth = imgRef.offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(e: TouchEvent) {
|
||||||
|
if (!resizing) return;
|
||||||
|
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
||||||
|
if (resizingPosition === 'left') {
|
||||||
|
dx = resizingInitialMouseX - e.touches[0].clientX;
|
||||||
|
}
|
||||||
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||||
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||||
|
if (newWidth < parentWidth) {
|
||||||
|
updateAttributes({ width: newWidth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd() {
|
||||||
|
resizing = false;
|
||||||
|
resizingInitialMouseX = 0;
|
||||||
|
resizingInitialWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Attach id to nodeRef
|
||||||
|
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement;
|
||||||
|
|
||||||
|
// Mouse events
|
||||||
|
window.addEventListener('mousemove', resize);
|
||||||
|
window.addEventListener('mouseup', endResize);
|
||||||
|
// Touch events
|
||||||
|
window.addEventListener('touchmove', handleTouchMove);
|
||||||
|
window.addEventListener('touchend', handleTouchEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('mousemove', resize);
|
||||||
|
window.removeEventListener('mouseup', endResize);
|
||||||
|
window.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
window.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NodeViewWrapper
|
||||||
|
id="resizable-container-media"
|
||||||
|
class={`edra-media-container ${selected ? 'selected' : ''} align-${node.attrs.align}`}
|
||||||
|
style={`width: ${node.attrs.width}px`}
|
||||||
|
>
|
||||||
|
<div class={`edra-media-group ${resizing ? 'resizing' : ''}`}>
|
||||||
|
<img
|
||||||
|
bind:this={imgRef}
|
||||||
|
src={node.attrs.src}
|
||||||
|
alt={node.attrs.alt}
|
||||||
|
title={node.attrs.title}
|
||||||
|
class="edra-media-content"
|
||||||
|
/>
|
||||||
|
{#if caption !== null}
|
||||||
|
<input bind:value={caption} type="text" class="edra-media-caption" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editor?.isEditable}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Resize left"
|
||||||
|
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||||
|
onmousedown={(event: MouseEvent) => {
|
||||||
|
handleResizingPosition(event, 'left');
|
||||||
|
}}
|
||||||
|
ontouchstart={(event: TouchEvent) => {
|
||||||
|
handleTouchStart(event, 'left');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Resize right"
|
||||||
|
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||||
|
onmousedown={(event: MouseEvent) => {
|
||||||
|
handleResizingPosition(event, 'right');
|
||||||
|
}}
|
||||||
|
ontouchstart={(event: TouchEvent) => {
|
||||||
|
handleTouchStart(event, 'right');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="edra-media-toolbar">
|
||||||
|
<button
|
||||||
|
class={`edra-toolbar-button ${node.attrs.align === 'left' ? 'active' : ''}`}
|
||||||
|
onclick={() => updateAttributes({ align: 'left' })}
|
||||||
|
title="Align Left"
|
||||||
|
>
|
||||||
|
<AlignLeft />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`edra-toolbar-button ${node.attrs.align === 'center' ? 'active' : ''}`}
|
||||||
|
onclick={() => updateAttributes({ align: 'center' })}
|
||||||
|
title="Align Center"
|
||||||
|
>
|
||||||
|
<AlignCenter />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`edra-toolbar-button ${node.attrs.align === 'right' ? 'active' : ''}`}
|
||||||
|
onclick={() => updateAttributes({ align: 'right' })}
|
||||||
|
title="Align Right"
|
||||||
|
>
|
||||||
|
<AlignRight />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button"
|
||||||
|
onclick={() => {
|
||||||
|
if (caption === null || caption.trim() === '') caption = 'Image Caption';
|
||||||
|
}}
|
||||||
|
title="Caption"
|
||||||
|
>
|
||||||
|
<Captions />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button"
|
||||||
|
onclick={() => {
|
||||||
|
duplicateContent(editor, node);
|
||||||
|
}}
|
||||||
|
title="Duplicate"
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button"
|
||||||
|
onclick={() => {
|
||||||
|
updateAttributes({
|
||||||
|
width: 'fit-content'
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title="Full Screen"
|
||||||
|
>
|
||||||
|
<Fullscreen />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button edra-destructive"
|
||||||
|
onclick={() => {
|
||||||
|
deleteNode();
|
||||||
|
}}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
|
import Image from 'lucide-svelte/icons/image';
|
||||||
|
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||||
|
const { editor }: NodeViewProps = $props();
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (!editor.isEditable) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const imageUrl = prompt('Enter the URL of an image:');
|
||||||
|
if (!imageUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.chain().focus().setImage({ src: imageUrl }).run();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<span
|
||||||
|
class="edra-media-placeholder-content"
|
||||||
|
onclick={handleClick}
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="Insert An Image"
|
||||||
|
>
|
||||||
|
<Image class="edra-media-placeholder-icon" />
|
||||||
|
<span class="edra-media-placeholder-text">Insert An Image</span>
|
||||||
|
</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Editor } from '@tiptap/core';
|
||||||
|
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||||
|
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||||
|
import CaseSensitive from 'lucide-svelte/icons/case-sensitive';
|
||||||
|
import Replace from 'lucide-svelte/icons/replace';
|
||||||
|
import ReplaceAll from 'lucide-svelte/icons/replace-all';
|
||||||
|
import Search from 'lucide-svelte/icons/search';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
editor: Editor;
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { editor, show = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
|
let searchText = $state('');
|
||||||
|
let replaceText = $state('');
|
||||||
|
let caseSensitive = $state(false);
|
||||||
|
|
||||||
|
let searchIndex = $derived(editor.storage?.searchAndReplace?.resultIndex);
|
||||||
|
let searchCount = $derived(editor.storage?.searchAndReplace?.results.length);
|
||||||
|
|
||||||
|
function updateSearchTerm(clearIndex: boolean = false) {
|
||||||
|
if (clearIndex) editor.commands.resetIndex();
|
||||||
|
|
||||||
|
editor.commands.setSearchTerm(searchText);
|
||||||
|
editor.commands.setReplaceTerm(replaceText);
|
||||||
|
editor.commands.setCaseSensitive(caseSensitive);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToSelection() {
|
||||||
|
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||||
|
const position = results[resultIndex];
|
||||||
|
if (!position) return;
|
||||||
|
editor.commands.setTextSelection(position);
|
||||||
|
const { node } = editor.view.domAtPos(editor.state.selection.anchor);
|
||||||
|
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function replace() {
|
||||||
|
editor.commands.replace();
|
||||||
|
goToSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
editor.commands.nextSearchResult();
|
||||||
|
goToSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const previous = () => {
|
||||||
|
editor.commands.previousSearchResult();
|
||||||
|
goToSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
searchText = '';
|
||||||
|
replaceText = '';
|
||||||
|
caseSensitive = false;
|
||||||
|
editor.commands.resetIndex();
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceAll = () => editor.commands.replaceAll();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="edra-search-and-replace">
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
onclick={() => {
|
||||||
|
show = !show;
|
||||||
|
clear();
|
||||||
|
updateSearchTerm();
|
||||||
|
}}
|
||||||
|
title={show ? 'Go Back' : 'Search and Replace'}
|
||||||
|
>
|
||||||
|
{#if show}
|
||||||
|
<ArrowLeft class="edra-toolbar-icon" />
|
||||||
|
{:else}
|
||||||
|
<Search class="edra-toolbar-icon" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if show}
|
||||||
|
<div class="edra-search-and-replace-content">
|
||||||
|
<input placeholder="Search..." bind:value={searchText} oninput={() => updateSearchTerm()} />
|
||||||
|
<span>{searchCount > 0 ? searchIndex + 1 : 0}/{searchCount}</span>
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
class:active={caseSensitive}
|
||||||
|
onclick={() => {
|
||||||
|
caseSensitive = !caseSensitive;
|
||||||
|
updateSearchTerm();
|
||||||
|
}}
|
||||||
|
title="Case Sensitive"
|
||||||
|
>
|
||||||
|
<CaseSensitive class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
<button class="edra-command-button" onclick={previous} title="Previous">
|
||||||
|
<ArrowLeft class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
<button class="edra-command-button" onclick={next} title="Next">
|
||||||
|
<ArrowRight class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
<span class="separator"></span>
|
||||||
|
|
||||||
|
<input placeholder="Replace..." bind:value={replaceText} oninput={() => updateSearchTerm()} />
|
||||||
|
<button class="edra-command-button" onclick={replace} title="Replace">
|
||||||
|
<Replace class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
<button class="edra-command-button" onclick={replaceAll} title="Replace All">
|
||||||
|
<ReplaceAll class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { icons } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
props: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { props }: Props = $props();
|
||||||
|
|
||||||
|
let scrollContainer = $state<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
let selectedGroupIndex = $state<number>(0);
|
||||||
|
let selectedCommandIndex = $state<number>(0);
|
||||||
|
|
||||||
|
const items = $derived.by(() => props.items);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (items) {
|
||||||
|
selectedGroupIndex = 0;
|
||||||
|
selectedCommandIndex = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const activeItem = document.getElementById(`${selectedGroupIndex}-${selectedCommandIndex}`);
|
||||||
|
if (activeItem !== null && scrollContainer !== null) {
|
||||||
|
const offsetTop = activeItem.offsetTop;
|
||||||
|
const offsetHeight = activeItem.offsetHeight;
|
||||||
|
scrollContainer.scrollTop = offsetTop - offsetHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectItem = (groupIndex: number, commandIndex: number) => {
|
||||||
|
const command = props.items[groupIndex].commands[commandIndex];
|
||||||
|
props.command(command);
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown' || ((e.ctrlKey || e.metaKey) && e.key === 'j') || e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!props.items.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const commands = props.items[selectedGroupIndex].commands;
|
||||||
|
let newCommandIndex = selectedCommandIndex + 1;
|
||||||
|
let newGroupIndex = selectedGroupIndex;
|
||||||
|
if (commands.length - 1 < newCommandIndex) {
|
||||||
|
newCommandIndex = 0;
|
||||||
|
newGroupIndex = selectedGroupIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.items.length - 1 < newGroupIndex) {
|
||||||
|
newGroupIndex = 0;
|
||||||
|
}
|
||||||
|
selectedCommandIndex = newCommandIndex;
|
||||||
|
selectedGroupIndex = newGroupIndex;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowUp' || ((e.ctrlKey || e.metaKey) && e.key === 'k')) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!props.items.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let newCommandIndex = selectedCommandIndex - 1;
|
||||||
|
let newGroupIndex = selectedGroupIndex;
|
||||||
|
if (newCommandIndex < 0) {
|
||||||
|
newGroupIndex = selectedGroupIndex - 1;
|
||||||
|
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0;
|
||||||
|
}
|
||||||
|
if (newGroupIndex < 0) {
|
||||||
|
newGroupIndex = props.items.length - 1;
|
||||||
|
newCommandIndex = props.items[newGroupIndex].commands.length - 1;
|
||||||
|
}
|
||||||
|
selectedCommandIndex = newCommandIndex;
|
||||||
|
selectedGroupIndex = newGroupIndex;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!props.items.length || selectedGroupIndex === -1 || selectedCommandIndex === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
selectItem(selectedGroupIndex, selectedCommandIndex);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeyDown} />
|
||||||
|
|
||||||
|
{#if items.length}
|
||||||
|
<div bind:this={scrollContainer} class="edra-slash-command-list">
|
||||||
|
{#each items as grp, groupIndex}
|
||||||
|
<span class="edra-slash-command-list-title">{grp.title}</span>
|
||||||
|
|
||||||
|
{#each grp.commands as command, commandIndex}
|
||||||
|
{@const Icon = icons[command.iconName]}
|
||||||
|
{@const isActive =
|
||||||
|
selectedGroupIndex === groupIndex && selectedCommandIndex === commandIndex}
|
||||||
|
<button
|
||||||
|
id={`${groupIndex}-${commandIndex}`}
|
||||||
|
class="edra-slash-command-list-item"
|
||||||
|
class:active={isActive}
|
||||||
|
onclick={() => selectItem(groupIndex, commandIndex)}
|
||||||
|
>
|
||||||
|
<Icon class="edra-toolbar-icon" />
|
||||||
|
<span>{command.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
226
src/lib/components/edra/headless/components/VideoExtended.svelte
Normal file
226
src/lib/components/edra/headless/components/VideoExtended.svelte
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||||
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
|
|
||||||
|
import AlignLeft from 'lucide-svelte/icons/align-left';
|
||||||
|
import AlignCenter from 'lucide-svelte/icons/align-center';
|
||||||
|
import AlignRight from 'lucide-svelte/icons/align-right';
|
||||||
|
import CopyIcon from 'lucide-svelte/icons/copy';
|
||||||
|
import Fullscreen from 'lucide-svelte/icons/fullscreen';
|
||||||
|
import Trash from 'lucide-svelte/icons/trash';
|
||||||
|
import Captions from 'lucide-svelte/icons/captions';
|
||||||
|
|
||||||
|
import { duplicateContent } from '../../utils.js';
|
||||||
|
|
||||||
|
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props();
|
||||||
|
|
||||||
|
const minWidth = 150;
|
||||||
|
|
||||||
|
let vidRef: HTMLVideoElement;
|
||||||
|
let nodeRef: HTMLDivElement;
|
||||||
|
|
||||||
|
let caption: string | null = $state(node.attrs.title);
|
||||||
|
$effect(() => {
|
||||||
|
if (caption?.trim() === '') caption = null;
|
||||||
|
updateAttributes({ title: caption });
|
||||||
|
});
|
||||||
|
|
||||||
|
let resizing = $state(false);
|
||||||
|
let resizingInitialWidth = $state(0);
|
||||||
|
let resizingInitialMouseX = $state(0);
|
||||||
|
let resizingPosition = $state<'left' | 'right'>('left');
|
||||||
|
|
||||||
|
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
|
||||||
|
startResize(e);
|
||||||
|
resizingPosition = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResize(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
resizing = true;
|
||||||
|
resizingInitialMouseX = e.clientX;
|
||||||
|
if (vidRef) resizingInitialWidth = vidRef.offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize(e: MouseEvent) {
|
||||||
|
if (!resizing) return;
|
||||||
|
let dx = e.clientX - resizingInitialMouseX;
|
||||||
|
if (resizingPosition === 'left') {
|
||||||
|
dx = resizingInitialMouseX - e.clientX;
|
||||||
|
}
|
||||||
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||||
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||||
|
if (newWidth < parentWidth) {
|
||||||
|
updateAttributes({ width: newWidth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endResize() {
|
||||||
|
resizing = false;
|
||||||
|
resizingInitialMouseX = 0;
|
||||||
|
resizingInitialWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
|
||||||
|
e.preventDefault();
|
||||||
|
resizing = true;
|
||||||
|
resizingPosition = position;
|
||||||
|
resizingInitialMouseX = e.touches[0].clientX;
|
||||||
|
if (vidRef) resizingInitialWidth = vidRef.offsetWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(e: TouchEvent) {
|
||||||
|
if (!resizing) return;
|
||||||
|
let dx = e.touches[0].clientX - resizingInitialMouseX;
|
||||||
|
if (resizingPosition === 'left') {
|
||||||
|
dx = resizingInitialMouseX - e.touches[0].clientX;
|
||||||
|
}
|
||||||
|
const newWidth = Math.max(resizingInitialWidth + dx, minWidth);
|
||||||
|
const parentWidth = nodeRef?.parentElement?.offsetWidth || 0;
|
||||||
|
if (newWidth < parentWidth) {
|
||||||
|
updateAttributes({ width: newWidth });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd() {
|
||||||
|
resizing = false;
|
||||||
|
resizingInitialMouseX = 0;
|
||||||
|
resizingInitialWidth = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Attach id to nodeRef
|
||||||
|
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement;
|
||||||
|
|
||||||
|
// Mouse events
|
||||||
|
window.addEventListener('mousemove', resize);
|
||||||
|
window.addEventListener('mouseup', endResize);
|
||||||
|
// Touch events
|
||||||
|
window.addEventListener('touchmove', handleTouchMove);
|
||||||
|
window.addEventListener('touchend', handleTouchEnd);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('mousemove', resize);
|
||||||
|
window.removeEventListener('mouseup', endResize);
|
||||||
|
window.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
window.removeEventListener('touchend', handleTouchEnd);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NodeViewWrapper
|
||||||
|
id="resizable-container-media"
|
||||||
|
class={`edra-media-container ${selected ? 'selected' : ''} align-${node.attrs.align}`}
|
||||||
|
style={`width: ${node.attrs.width}px`}
|
||||||
|
>
|
||||||
|
<div class={`edra-media-group ${resizing ? 'resizing' : ''}`}>
|
||||||
|
<video
|
||||||
|
bind:this={vidRef}
|
||||||
|
src={node.attrs.src}
|
||||||
|
controls
|
||||||
|
title={node.attrs.title}
|
||||||
|
class="edra-media-content"
|
||||||
|
>
|
||||||
|
<track kind="captions" />
|
||||||
|
</video>
|
||||||
|
|
||||||
|
{#if caption !== null}
|
||||||
|
<input bind:value={caption} type="text" class="edra-media-caption" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if editor?.isEditable}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Resize left"
|
||||||
|
class="edra-media-resize-handle edra-media-resize-handle-left"
|
||||||
|
onmousedown={(event: MouseEvent) => {
|
||||||
|
handleResizingPosition(event, 'left');
|
||||||
|
}}
|
||||||
|
ontouchstart={(event: TouchEvent) => {
|
||||||
|
handleTouchStart(event, 'left');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Resize right"
|
||||||
|
class="edra-media-resize-handle edra-media-resize-handle-right"
|
||||||
|
onmousedown={(event: MouseEvent) => {
|
||||||
|
handleResizingPosition(event, 'right');
|
||||||
|
}}
|
||||||
|
ontouchstart={(event: TouchEvent) => {
|
||||||
|
handleTouchStart(event, 'right');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="edra-media-resize-indicator"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="edra-media-toolbar">
|
||||||
|
<button
|
||||||
|
class={`edra-toolbar-button ${node.attrs.align === 'left' ? 'active' : ''}`}
|
||||||
|
onclick={() => updateAttributes({ align: 'left' })}
|
||||||
|
title="Align Left"
|
||||||
|
>
|
||||||
|
<AlignLeft />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`edra-toolbar-button ${node.attrs.align === 'center' ? 'active' : ''}`}
|
||||||
|
onclick={() => updateAttributes({ align: 'center' })}
|
||||||
|
title="Align Center"
|
||||||
|
>
|
||||||
|
<AlignCenter />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`edra-toolbar-button ${node.attrs.align === 'right' ? 'active' : ''}`}
|
||||||
|
onclick={() => updateAttributes({ align: 'right' })}
|
||||||
|
title="Align Right"
|
||||||
|
>
|
||||||
|
<AlignRight />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button"
|
||||||
|
onclick={() => {
|
||||||
|
if (caption === null || caption.trim() === '') caption = 'Video Caption';
|
||||||
|
}}
|
||||||
|
title="Caption"
|
||||||
|
>
|
||||||
|
<Captions />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button"
|
||||||
|
onclick={() => {
|
||||||
|
duplicateContent(editor, node);
|
||||||
|
}}
|
||||||
|
title="Duplicate"
|
||||||
|
>
|
||||||
|
<CopyIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button"
|
||||||
|
onclick={() => {
|
||||||
|
updateAttributes({
|
||||||
|
width: 'fit-content'
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title="Full Screen"
|
||||||
|
>
|
||||||
|
<Fullscreen />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-toolbar-button edra-destructive"
|
||||||
|
onclick={() => {
|
||||||
|
deleteNode();
|
||||||
|
}}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { NodeViewProps } from '@tiptap/core';
|
||||||
|
import Video from 'lucide-svelte/icons/video';
|
||||||
|
import { NodeViewWrapper } from 'svelte-tiptap';
|
||||||
|
const { editor }: NodeViewProps = $props();
|
||||||
|
|
||||||
|
function handleClick(e: MouseEvent) {
|
||||||
|
if (!editor.isEditable) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const videoUrl = prompt('Enter the URL of the video:');
|
||||||
|
if (!videoUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.chain().focus().setVideo(videoUrl).run();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<span
|
||||||
|
class="edra-media-placeholder-content"
|
||||||
|
onclick={handleClick}
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="Insert A Video"
|
||||||
|
>
|
||||||
|
<Video class="edra-media-placeholder-icon" />
|
||||||
|
<span class="edra-media-placeholder-text">Insert A Video</span>
|
||||||
|
</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
136
src/lib/components/edra/headless/editor.svelte
Normal file
136
src/lib/components/edra/headless/editor.svelte
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { type Editor } from '@tiptap/core';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
import { initiateEditor } from '../editor.js';
|
||||||
|
import './style.css';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
|
||||||
|
// Lowlight
|
||||||
|
import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
|
||||||
|
import { all, createLowlight } from 'lowlight';
|
||||||
|
import '../editor.css';
|
||||||
|
import '../onedark.css';
|
||||||
|
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||||
|
import CodeExtended from './components/CodeExtended.svelte';
|
||||||
|
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder.js';
|
||||||
|
import AudioPlaceholderComponent from './components/AudioPlaceholder.svelte';
|
||||||
|
import AudioExtendedComponent from './components/AudioExtended.svelte';
|
||||||
|
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder.js';
|
||||||
|
import ImagePlaceholderComponent from './components/ImagePlaceholder.svelte';
|
||||||
|
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder.js';
|
||||||
|
import VideoPlaceholderComponent from './components/VideoPlaceholder.svelte';
|
||||||
|
import { ImageExtended } from '../extensions/image/ImageExtended.js';
|
||||||
|
import ImageExtendedComponent from './components/ImageExtended.svelte';
|
||||||
|
import VideoExtendedComponent from './components/VideoExtended.svelte';
|
||||||
|
import { VideoExtended } from '../extensions/video/VideoExtended.js';
|
||||||
|
import { AudioExtended } from '../extensions/audio/AudiExtended.js';
|
||||||
|
import LinkMenu from './menus/link-menu.svelte';
|
||||||
|
import TableRowMenu from './menus/table/table-row-menu.svelte';
|
||||||
|
import TableColMenu from './menus/table/table-col-menu.svelte';
|
||||||
|
import slashcommand from '../extensions/slash-command/slashcommand.js';
|
||||||
|
import SlashCommandList from './components/SlashCommandList.svelte';
|
||||||
|
import LoaderCircle from 'lucide-svelte/icons/loader-circle';
|
||||||
|
import { focusEditor, type EdraProps } from '../utils.js';
|
||||||
|
import IFramePlaceholderComponent from './components/IFramePlaceholder.svelte';
|
||||||
|
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder.js';
|
||||||
|
import { IFrameExtended } from '../extensions/iframe/IFrameExtended.js';
|
||||||
|
import IFrameExtendedComponent from './components/IFrameExtended.svelte';
|
||||||
|
|
||||||
|
const lowlight = createLowlight(all);
|
||||||
|
|
||||||
|
let {
|
||||||
|
class: className = '',
|
||||||
|
content = undefined,
|
||||||
|
editable = true,
|
||||||
|
limit = undefined,
|
||||||
|
editor = $bindable<Editor | undefined>(),
|
||||||
|
showSlashCommands = true,
|
||||||
|
showLinkBubbleMenu = true,
|
||||||
|
showTableBubbleMenu = true,
|
||||||
|
onUpdate,
|
||||||
|
children
|
||||||
|
}: EdraProps = $props();
|
||||||
|
|
||||||
|
let element = $state<HTMLElement>();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
editor = initiateEditor(
|
||||||
|
element,
|
||||||
|
content,
|
||||||
|
limit,
|
||||||
|
[
|
||||||
|
CodeBlockLowlight.configure({
|
||||||
|
lowlight
|
||||||
|
}).extend({
|
||||||
|
addNodeView() {
|
||||||
|
return SvelteNodeViewRenderer(CodeExtended);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
AudioPlaceholder(AudioPlaceholderComponent),
|
||||||
|
ImagePlaceholder(ImagePlaceholderComponent),
|
||||||
|
IFramePlaceholder(IFramePlaceholderComponent),
|
||||||
|
IFrameExtended(IFrameExtendedComponent),
|
||||||
|
VideoPlaceholder(VideoPlaceholderComponent),
|
||||||
|
AudioExtended(AudioExtendedComponent),
|
||||||
|
ImageExtended(ImageExtendedComponent),
|
||||||
|
VideoExtended(VideoExtendedComponent),
|
||||||
|
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
|
||||||
|
],
|
||||||
|
{
|
||||||
|
editable,
|
||||||
|
onUpdate,
|
||||||
|
onTransaction: (props) => {
|
||||||
|
editor = undefined;
|
||||||
|
editor = props.editor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return () => editor?.destroy();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={`edra ${className}`}>
|
||||||
|
{@render children?.()}
|
||||||
|
{#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>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(.ProseMirror) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
src/lib/components/edra/headless/index.ts
Normal file
3
src/lib/components/edra/headless/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as Edra } from './editor.svelte';
|
||||||
|
export { default as EdraToolbar } from './toolbar.svelte';
|
||||||
|
export { default as EdraBubbleMenu } from './menus/bubble-menu.svelte';
|
||||||
162
src/lib/components/edra/headless/menus/bubble-menu.svelte
Normal file
162
src/lib/components/edra/headless/menus/bubble-menu.svelte
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { BubbleMenu } from 'svelte-tiptap';
|
||||||
|
import { isTextSelection, type Editor } from '@tiptap/core';
|
||||||
|
import { commands } from '../../commands/commands.js';
|
||||||
|
import EdraToolBarIcon from '../components/EdraToolBarIcon.svelte';
|
||||||
|
import type { ShouldShowProps } from '../../utils.js';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
editor: Editor;
|
||||||
|
children?: Snippet<[]>;
|
||||||
|
}
|
||||||
|
const { class: className = '', editor, children }: Props = $props();
|
||||||
|
|
||||||
|
let isDragging = $state(false);
|
||||||
|
|
||||||
|
editor.view.dom.addEventListener('dragstart', () => {
|
||||||
|
isDragging = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.view.dom.addEventListener('drop', () => {
|
||||||
|
isDragging = true;
|
||||||
|
|
||||||
|
// Allow some time for the drop action to complete before re-enabling
|
||||||
|
setTimeout(() => {
|
||||||
|
isDragging = false;
|
||||||
|
}, 100); // Adjust delay if needed
|
||||||
|
});
|
||||||
|
|
||||||
|
const bubbleMenuCommands = [
|
||||||
|
...commands['text-formatting'].commands,
|
||||||
|
...commands.alignment.commands,
|
||||||
|
...commands.lists.commands
|
||||||
|
];
|
||||||
|
|
||||||
|
const colorCommands = commands.colors.commands;
|
||||||
|
const fontCommands = commands.fonts.commands;
|
||||||
|
|
||||||
|
function shouldShow(props: ShouldShowProps) {
|
||||||
|
if (!props.editor.isEditable) return false;
|
||||||
|
const { view, editor } = props;
|
||||||
|
if (!view || editor.view.dragging) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (editor.isActive('link')) return false;
|
||||||
|
if (editor.isActive('codeBlock')) return false;
|
||||||
|
const {
|
||||||
|
state: {
|
||||||
|
doc,
|
||||||
|
selection,
|
||||||
|
selection: { empty, from, to }
|
||||||
|
}
|
||||||
|
} = editor;
|
||||||
|
// check if the selection is a table grip
|
||||||
|
const domAtPos = view.domAtPos(from || 0).node as HTMLElement;
|
||||||
|
const nodeDOM = view.nodeDOM(from || 0) as HTMLElement;
|
||||||
|
const node = nodeDOM || domAtPos;
|
||||||
|
|
||||||
|
if (isTableGripSelected(node)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Sometime check for `empty` is not enough.
|
||||||
|
// Doubleclick an empty paragraph returns a node size of 2.
|
||||||
|
// So we check also for an empty text size.
|
||||||
|
const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(selection);
|
||||||
|
if (empty || isEmptyTextBlock || !editor.isEditable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !isDragging && !editor.state.selection.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTableGripSelected = (node: HTMLElement) => {
|
||||||
|
let container = node;
|
||||||
|
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||||
|
container = container.parentElement!;
|
||||||
|
}
|
||||||
|
const gripColumn =
|
||||||
|
container && container.querySelector && container.querySelector('a.grip-column.selected');
|
||||||
|
const gripRow =
|
||||||
|
container && container.querySelector && container.querySelector('a.grip-row.selected');
|
||||||
|
if (gripColumn || gripRow) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BubbleMenu
|
||||||
|
{editor}
|
||||||
|
class={`bubble-menu-wrapper ${className}`}
|
||||||
|
{shouldShow}
|
||||||
|
pluginKey="bubble-menu"
|
||||||
|
updateDelay={100}
|
||||||
|
tippyOptions={{
|
||||||
|
popperOptions: {
|
||||||
|
placement: 'top-start',
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: 'preventOverflow',
|
||||||
|
options: {
|
||||||
|
boundary: 'viewport',
|
||||||
|
padding: 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'flip',
|
||||||
|
options: {
|
||||||
|
fallbackPlacements: ['bottom-start', 'top-end', 'bottom-end']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
maxWidth: 'calc(100vw - 16px)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
{#each bubbleMenuCommands as command}
|
||||||
|
<EdraToolBarIcon {command} {editor} />
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<EdraToolBarIcon command={fontCommands[0]} {editor} />
|
||||||
|
<span>{editor.getAttributes('textStyle').fontSize ?? '16px'}</span>
|
||||||
|
<EdraToolBarIcon command={fontCommands[1]} {editor} />
|
||||||
|
|
||||||
|
<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</BubbleMenu>
|
||||||
120
src/lib/components/edra/headless/menus/link-menu.svelte
Normal file
120
src/lib/components/edra/headless/menus/link-menu.svelte
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { type Editor } from '@tiptap/core';
|
||||||
|
import { BubbleMenu } from 'svelte-tiptap';
|
||||||
|
import type { ShouldShowProps } from '../../utils.js';
|
||||||
|
import Copy from 'lucide-svelte/icons/copy';
|
||||||
|
import Trash from 'lucide-svelte/icons/trash';
|
||||||
|
import Edit from 'lucide-svelte/icons/pen';
|
||||||
|
import Check from 'lucide-svelte/icons/check';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
editor: Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { editor }: Props = $props();
|
||||||
|
|
||||||
|
const link = $derived.by(() => editor.getAttributes('link').href);
|
||||||
|
|
||||||
|
let isEditing = $state(false);
|
||||||
|
|
||||||
|
function setLink(url: string) {
|
||||||
|
if (url.trim() === '') {
|
||||||
|
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
let linkInput = $state('');
|
||||||
|
let isLinkValid = $state(true);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
isLinkValid = validateURL(linkInput);
|
||||||
|
});
|
||||||
|
|
||||||
|
function validateURL(url: string): boolean {
|
||||||
|
const urlPattern = new RegExp(
|
||||||
|
'^(https?:\\/\\/)?' + // protocol (optional)
|
||||||
|
'((([a-zA-Z\\d]([a-zA-Z\\d-]*[a-zA-Z\\d])*)\\.)+[a-zA-Z]{2,}|' + // domain name and extension
|
||||||
|
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address
|
||||||
|
'(\\:\\d+)?(\\/[-a-zA-Z\\d%_.~+]*)*' + // port and path
|
||||||
|
'(\\?[;&a-zA-Z\\d%_.~+=-]*)?' + // query string
|
||||||
|
'(\\#[-a-zA-Z\\d_]*)?$', // fragment locator
|
||||||
|
'i'
|
||||||
|
);
|
||||||
|
return urlPattern.test(url);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BubbleMenu
|
||||||
|
{editor}
|
||||||
|
pluginKey="link-menu"
|
||||||
|
shouldShow={(props: ShouldShowProps) => {
|
||||||
|
if (!props.editor.isEditable) return false;
|
||||||
|
if (props.editor.isActive('link')) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
isEditing = false;
|
||||||
|
linkInput = '';
|
||||||
|
isLinkValid = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
class="bubble-menu-wrapper"
|
||||||
|
>
|
||||||
|
{#if isEditing}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={linkInput}
|
||||||
|
placeholder="Enter the URL"
|
||||||
|
disabled={!isEditing}
|
||||||
|
class:valid={isLinkValid}
|
||||||
|
class:invalid={!isLinkValid}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<a href={link} target="_blank">{link}</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !isEditing}
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
onclick={() => {
|
||||||
|
linkInput = link;
|
||||||
|
isEditing = true;
|
||||||
|
}}
|
||||||
|
title="Edit the URL"
|
||||||
|
>
|
||||||
|
<Edit class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
onclick={() => {
|
||||||
|
navigator.clipboard.writeText(link);
|
||||||
|
}}
|
||||||
|
title="Copy the URL to the clipboard"
|
||||||
|
>
|
||||||
|
<Copy class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
onclick={() => {
|
||||||
|
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||||
|
}}
|
||||||
|
title="Remove the link"
|
||||||
|
>
|
||||||
|
<Trash class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
onclick={() => {
|
||||||
|
isEditing = false;
|
||||||
|
editor.commands.focus();
|
||||||
|
setLink(linkInput);
|
||||||
|
}}
|
||||||
|
disabled={!isLinkValid}
|
||||||
|
>
|
||||||
|
<Check class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</BubbleMenu>
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ShouldShowProps } from '../../../utils.js';
|
||||||
|
import { type Editor } from '@tiptap/core';
|
||||||
|
import { BubbleMenu } from 'svelte-tiptap';
|
||||||
|
import ArrowLeftFromLine from 'lucide-svelte/icons/arrow-left-from-line';
|
||||||
|
import ArrowRightFromLine from 'lucide-svelte/icons/arrow-right-from-line';
|
||||||
|
import Trash from 'lucide-svelte/icons/trash';
|
||||||
|
import { isColumnGripSelected } from '../../../extensions/table/utils.js';
|
||||||
|
interface Props {
|
||||||
|
editor: Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { editor }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BubbleMenu
|
||||||
|
{editor}
|
||||||
|
pluginKey="table-col-menu"
|
||||||
|
shouldShow={(props: ShouldShowProps) => {
|
||||||
|
if (!props.editor.isEditable) return false;
|
||||||
|
if (!props.state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isColumnGripSelected({
|
||||||
|
editor: props.editor,
|
||||||
|
view: props.view,
|
||||||
|
state: props.state,
|
||||||
|
from: props.from
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
class="edra-menu-wrapper"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
title="Add Column After"
|
||||||
|
onclick={() => editor.chain().focus().addColumnAfter().run()}
|
||||||
|
>
|
||||||
|
<ArrowRightFromLine class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
title="Add Column Before"
|
||||||
|
onclick={() => editor.chain().focus().addColumnBefore().run()}
|
||||||
|
>
|
||||||
|
<ArrowLeftFromLine class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
title="Delete Column"
|
||||||
|
onclick={() => editor.chain().focus().deleteColumn().run()}
|
||||||
|
>
|
||||||
|
<Trash class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
</BubbleMenu>
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ShouldShowProps } from '../../../utils.js';
|
||||||
|
import { type Editor } from '@tiptap/core';
|
||||||
|
import { BubbleMenu } from 'svelte-tiptap';
|
||||||
|
import ArrowDownFromLine from 'lucide-svelte/icons/arrow-down-from-line';
|
||||||
|
import ArrowUpFromLine from 'lucide-svelte/icons/arrow-up-from-line';
|
||||||
|
import Trash from 'lucide-svelte/icons/trash';
|
||||||
|
import { isRowGripSelected } from '../../../extensions/table/utils.js';
|
||||||
|
interface Props {
|
||||||
|
editor: Editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { editor }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BubbleMenu
|
||||||
|
{editor}
|
||||||
|
pluginKey="table-row-menu"
|
||||||
|
shouldShow={(props: ShouldShowProps) => {
|
||||||
|
if (!props.editor.isEditable) return false;
|
||||||
|
if (!props.state) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isRowGripSelected({
|
||||||
|
editor: props.editor,
|
||||||
|
view: props.view,
|
||||||
|
state: props.state,
|
||||||
|
from: props.from
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
class="edra-menu-wrapper"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
title="Add Row After"
|
||||||
|
onclick={() => editor.chain().focus().addRowAfter().run()}
|
||||||
|
>
|
||||||
|
<ArrowDownFromLine class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
title="Add Row Before"
|
||||||
|
onclick={() => editor.chain().focus().addRowBefore().run()}
|
||||||
|
>
|
||||||
|
<ArrowUpFromLine class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="edra-command-button"
|
||||||
|
title="Delete Row"
|
||||||
|
onclick={() => editor.chain().focus().deleteRow().run()}
|
||||||
|
>
|
||||||
|
<Trash class="edra-toolbar-icon" />
|
||||||
|
</button>
|
||||||
|
</BubbleMenu>
|
||||||
382
src/lib/components/edra/headless/style.css
Normal file
382
src/lib/components/edra/headless/style.css
Normal file
|
|
@ -0,0 +1,382 @@
|
||||||
|
:root {
|
||||||
|
/* Color Variables */
|
||||||
|
--edra-border-color: #80808050;
|
||||||
|
--edra-button-bg-color: #80808025;
|
||||||
|
--edra-button-hover-bg-color: #80808075;
|
||||||
|
--edra-button-active-bg-color: #80808090;
|
||||||
|
--edra-icon-color: currentColor; /* Default, can be customized */
|
||||||
|
--edra-separator-color: currentColor; /* Default, can be customized */
|
||||||
|
|
||||||
|
/* Size and Spacing Variables */
|
||||||
|
--edra-gap: 0.25rem;
|
||||||
|
--edra-border-radius: 0.5rem;
|
||||||
|
--edra-button-border-radius: 0.5rem;
|
||||||
|
--edra-padding: 0.5rem;
|
||||||
|
--edra-button-padding: 0.25rem;
|
||||||
|
--edra-button-size: 2rem;
|
||||||
|
--edra-icon-size: 1rem;
|
||||||
|
--edra-separator-width: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editor Styles */
|
||||||
|
:root {
|
||||||
|
--border-color: rgba(128, 128, 128, 0.3);
|
||||||
|
--border-color-hover: rgba(128, 128, 128, 0.5);
|
||||||
|
--blockquote-border: rgba(128, 128, 128, 0.7);
|
||||||
|
--code-color: rgb(255, 68, 0);
|
||||||
|
--code-bg: rgba(128, 128, 128, 0.3);
|
||||||
|
--code-border: rgba(128, 128, 128, 0.4);
|
||||||
|
--table-border: rgba(128, 128, 128, 0.3);
|
||||||
|
--table-bg-selected: rgba(128, 128, 128, 0.1);
|
||||||
|
--table-bg-hover: rgba(128, 128, 128, 0.2);
|
||||||
|
--task-completed-color: rgba(128, 128, 128, 0.7);
|
||||||
|
--code-wrapper-bg: rgba(128, 128, 128, 0.05);
|
||||||
|
--highlight-color: rgba(0, 128, 0, 0.3);
|
||||||
|
--highlight-border: greenyellow;
|
||||||
|
--search-result-bg: yellow;
|
||||||
|
--search-result-current-bg: orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--edra-gap);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-editor {
|
||||||
|
padding: var(--edra-padding);
|
||||||
|
flex-grow: 1;
|
||||||
|
padding-left: 2rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--edra-gap);
|
||||||
|
padding: var(--edra-padding);
|
||||||
|
width: fit-content;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-loading {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--edra-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin {
|
||||||
|
animation: animate-spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes animate-spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-command-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
background-color: var(--edra-button-bg-color);
|
||||||
|
border-radius: var(--edra-button-border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
padding: var(--edra-button-padding);
|
||||||
|
min-width: var(--edra-button-size);
|
||||||
|
min-height: var(--edra-button-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-command-button:hover {
|
||||||
|
background-color: var(--edra-button-hover-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-command-button.active {
|
||||||
|
background-color: var(--edra-button-active-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-toolbar-icon {
|
||||||
|
height: var(--edra-icon-size);
|
||||||
|
width: var(--edra-icon-size);
|
||||||
|
color: var(--edra-icon-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
width: var(--edra-separator-width);
|
||||||
|
background-color: var(--edra-separator-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-placeholder-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: fit-content;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-placeholder-content {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
padding-right: 0;
|
||||||
|
background-color: var(--edra-button-bg-color);
|
||||||
|
border-radius: var(--edra-button-border-radius);
|
||||||
|
border: 1px solid var(--edra-border-color);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
gap: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-placeholder-icon {
|
||||||
|
height: var(--edra-icon-size);
|
||||||
|
width: var(--edra-icon-size);
|
||||||
|
color: var(--edra-icon-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-container.selected {
|
||||||
|
border-color: #808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-container.align-left {
|
||||||
|
left: 0;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-container.align-center {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-container.align-right {
|
||||||
|
left: 100%;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-group {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-content {
|
||||||
|
margin: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-caption {
|
||||||
|
margin: 0.125rem 0;
|
||||||
|
width: 100%;
|
||||||
|
background-color: transparent;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #808080;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
width: 0.5rem;
|
||||||
|
cursor: col-resize;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-resize-handle-left {
|
||||||
|
left: 0;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-resize-handle-right {
|
||||||
|
right: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-resize-indicator {
|
||||||
|
z-index: 20;
|
||||||
|
height: 3rem;
|
||||||
|
width: 0.25rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
background-color: #808080;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-group:hover .edra-media-resize-indicator {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-toolbar {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
background-color: #80808075;
|
||||||
|
padding: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-toolbar-audio {
|
||||||
|
top: -32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-media-group:hover .edra-media-toolbar,
|
||||||
|
.edra-media-toolbar.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-toolbar-button {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-toolbar-button:hover {
|
||||||
|
background-color: #80808030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-toolbar-button.active {
|
||||||
|
background-color: #80808080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-destructive {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-menu-wrapper {
|
||||||
|
z-index: 100;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border: 1px solid var(--edra-border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background-color: white;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .bubble-menu-wrapper {
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-menu-wrapper input {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: none;
|
||||||
|
max-width: 10rem;
|
||||||
|
background: none;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.valid {
|
||||||
|
border: 1px solid green;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.invalid {
|
||||||
|
border: 1px solid red;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-slash-command-list {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
max-height: min(80vh, 20rem);
|
||||||
|
width: 12rem;
|
||||||
|
overflow: auto;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--edra-border-color);
|
||||||
|
padding: 0.5rem;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-slash-command-list-title {
|
||||||
|
margin: 0.5rem;
|
||||||
|
user-select: none;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-slash-command-list-item {
|
||||||
|
display: flex;
|
||||||
|
height: fit-content;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.edra-slash-command-list-item.active {
|
||||||
|
background-color: var(--edra-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-search-and-replace {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--edra-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-search-and-replace-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--edra-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edra-search-and-replace-content input {
|
||||||
|
max-width: 10rem;
|
||||||
|
background: none;
|
||||||
|
width: 15rem;
|
||||||
|
border: 1px solid var(--edra-border-color);
|
||||||
|
border-radius: var(--edra-button-border-radius);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
78
src/lib/components/edra/headless/toolbar.svelte
Normal file
78
src/lib/components/edra/headless/toolbar.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Editor } from '@tiptap/core';
|
||||||
|
import { commands } from '../commands/commands.js';
|
||||||
|
import EdraToolBarIcon from './components/EdraToolBarIcon.svelte';
|
||||||
|
import SearchAndReplace from './components/SearchAndReplace.svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: string;
|
||||||
|
editor: Editor;
|
||||||
|
children?: Snippet<[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { class: className = '', editor, children }: Props = $props();
|
||||||
|
|
||||||
|
// Special components that are handled separately
|
||||||
|
let showSearchAndReplace = $state(false);
|
||||||
|
const colorCommands = commands.colors.commands;
|
||||||
|
const fontCommands = commands.fonts.commands;
|
||||||
|
const excludedCommands = ['colors', 'fonts'];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class={`edra-toolbar ${className}`}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
{#if !showSearchAndReplace}
|
||||||
|
{#each Object.keys(commands).filter((key) => !excludedCommands.includes(key)) as keys}
|
||||||
|
{@const groups = commands[keys].commands}
|
||||||
|
{#each groups as command}
|
||||||
|
<EdraToolBarIcon {command} {editor} />
|
||||||
|
{/each}
|
||||||
|
<span class="separator"></span>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<EdraToolBarIcon command={fontCommands[0]} {editor} />
|
||||||
|
<span>{editor.getAttributes('textStyle').fontSize ?? '16px'}</span>
|
||||||
|
<EdraToolBarIcon command={fontCommands[1]} {editor} />
|
||||||
|
|
||||||
|
<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<SearchAndReplace {editor} bind:show={showSearchAndReplace} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
176
src/lib/components/edra/onedark.css
Normal file
176
src/lib/components/edra/onedark.css
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
/* One Dark and Light Theme for Highlight.js using Pure CSS */
|
||||||
|
/* Light Theme (Default) */
|
||||||
|
.tiptap pre code {
|
||||||
|
color: #383a42;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comment */
|
||||||
|
.hljs-comment,
|
||||||
|
.hljs-quote {
|
||||||
|
font-style: italic;
|
||||||
|
color: #a0a1a7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Red */
|
||||||
|
.hljs-variable,
|
||||||
|
.hljs-template-variable,
|
||||||
|
.hljs-tag,
|
||||||
|
.hljs-name,
|
||||||
|
.hljs-selector-id,
|
||||||
|
.hljs-selector-class,
|
||||||
|
.hljs-regexp,
|
||||||
|
.hljs-deletion {
|
||||||
|
color: #e45649;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Orange */
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-literal,
|
||||||
|
.hljs-type,
|
||||||
|
.hljs-params,
|
||||||
|
.hljs-meta,
|
||||||
|
.hljs-link {
|
||||||
|
color: #986801;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Yellow */
|
||||||
|
.hljs-attribute {
|
||||||
|
color: #c18401;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green */
|
||||||
|
.hljs-string,
|
||||||
|
.hljs-symbol,
|
||||||
|
.hljs-bullet,
|
||||||
|
.hljs-addition {
|
||||||
|
color: #50a14f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blue */
|
||||||
|
.hljs-title,
|
||||||
|
.hljs-section {
|
||||||
|
color: #4078f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Purple */
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-selector-tag {
|
||||||
|
color: #a626a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cyan */
|
||||||
|
.hljs-emphasis {
|
||||||
|
font-style: italic;
|
||||||
|
color: #0184bc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
.hljs-doctag,
|
||||||
|
.hljs-formula {
|
||||||
|
color: #a626a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-attr,
|
||||||
|
.hljs-subst {
|
||||||
|
color: #383a42;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line highlights */
|
||||||
|
.hljs-addition {
|
||||||
|
background-color: #e6ffed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-deletion {
|
||||||
|
background-color: #ffeef0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Theme (All dark styles consolidated in one media query) */
|
||||||
|
html.dark {
|
||||||
|
.tiptap pre code {
|
||||||
|
color: #abb2bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comment */
|
||||||
|
.hljs-comment,
|
||||||
|
.hljs-quote {
|
||||||
|
color: #5c6370;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Red */
|
||||||
|
.hljs-variable,
|
||||||
|
.hljs-template-variable,
|
||||||
|
.hljs-tag,
|
||||||
|
.hljs-name,
|
||||||
|
.hljs-selector-id,
|
||||||
|
.hljs-selector-class,
|
||||||
|
.hljs-regexp,
|
||||||
|
.hljs-deletion {
|
||||||
|
color: #e06c75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Orange */
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-literal,
|
||||||
|
.hljs-type,
|
||||||
|
.hljs-params,
|
||||||
|
.hljs-meta,
|
||||||
|
.hljs-link {
|
||||||
|
color: #d19a66;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Yellow */
|
||||||
|
.hljs-attribute {
|
||||||
|
color: #e5c07b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green */
|
||||||
|
.hljs-string,
|
||||||
|
.hljs-symbol,
|
||||||
|
.hljs-bullet,
|
||||||
|
.hljs-addition {
|
||||||
|
color: #98c379;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blue */
|
||||||
|
.hljs-title,
|
||||||
|
.hljs-section {
|
||||||
|
color: #61afef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Purple */
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-selector-tag {
|
||||||
|
color: #c678dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cyan */
|
||||||
|
.hljs-emphasis {
|
||||||
|
color: #56b6c2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base styles */
|
||||||
|
.hljs-doctag,
|
||||||
|
.hljs-formula {
|
||||||
|
color: #c678dd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-attr,
|
||||||
|
.hljs-subst {
|
||||||
|
color: #abb2bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line highlights */
|
||||||
|
.hljs-addition {
|
||||||
|
background-color: #283428;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-deletion {
|
||||||
|
background-color: #342828;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/lib/components/edra/svelte-renderer.ts
Normal file
75
src/lib/components/edra/svelte-renderer.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import { flushSync, mount, unmount } from 'svelte';
|
||||||
|
import type { Editor, NodeViewProps } from '@tiptap/core';
|
||||||
|
|
||||||
|
interface RendererOptions<P extends Record<string, unknown>> {
|
||||||
|
editor: Editor;
|
||||||
|
props: P;
|
||||||
|
}
|
||||||
|
|
||||||
|
type App = ReturnType<typeof mount>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
class SvelteRenderer<R = unknown, P extends Record<string, any> = object> {
|
||||||
|
id: string;
|
||||||
|
component: App;
|
||||||
|
editor: Editor;
|
||||||
|
props: P;
|
||||||
|
element: HTMLElement;
|
||||||
|
ref: R | null = null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
mnt: Record<any, any> | null = null;
|
||||||
|
|
||||||
|
constructor(component: App, { props, editor }: RendererOptions<P>) {
|
||||||
|
this.id = Math.floor(Math.random() * 0xffffffff).toString();
|
||||||
|
this.component = component;
|
||||||
|
this.props = props;
|
||||||
|
this.editor = editor;
|
||||||
|
|
||||||
|
this.element = document.createElement('div');
|
||||||
|
this.element.classList.add('svelte-renderer');
|
||||||
|
|
||||||
|
if (this.editor.isInitialized) {
|
||||||
|
// On first render, we need to flush the render synchronously
|
||||||
|
// Renders afterwards can be async, but this fixes a cursor positioning issue
|
||||||
|
flushSync(() => {
|
||||||
|
this.render();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): void {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.mnt = mount(this.component as any, {
|
||||||
|
target: this.element,
|
||||||
|
props: {
|
||||||
|
props: this.props
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProps(props: Partial<NodeViewProps>): void {
|
||||||
|
Object.assign(this.props, props);
|
||||||
|
this.destroy();
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAttributes(attributes: Record<string, string>): void {
|
||||||
|
Object.keys(attributes).forEach((key) => {
|
||||||
|
this.element.setAttribute(key, attributes[key]);
|
||||||
|
});
|
||||||
|
this.destroy();
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.mnt) {
|
||||||
|
unmount(this.mnt);
|
||||||
|
} else {
|
||||||
|
unmount(this.component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SvelteRenderer;
|
||||||
152
src/lib/components/edra/utils.ts
Normal file
152
src/lib/components/edra/utils.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import type { Content, Editor } from '@tiptap/core';
|
||||||
|
import { Node } from '@tiptap/pm/model';
|
||||||
|
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||||
|
import type { EditorState, Transaction } from '@tiptap/pm/state';
|
||||||
|
import type { EditorView } from '@tiptap/pm/view';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
export interface ShouldShowProps {
|
||||||
|
editor: Editor;
|
||||||
|
element: HTMLElement;
|
||||||
|
view: EditorView;
|
||||||
|
state: EditorState;
|
||||||
|
oldState?: EditorState;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findColors = (doc: Node) => {
|
||||||
|
const hexColor = /(#[0-9a-f]{3,6})\b/gi;
|
||||||
|
const decorations: Decoration[] = [];
|
||||||
|
|
||||||
|
doc.descendants((node, position) => {
|
||||||
|
if (!node.text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Array.from(node.text.matchAll(hexColor)).forEach((match) => {
|
||||||
|
const color = match[0];
|
||||||
|
const index = match.index || 0;
|
||||||
|
const from = position + index;
|
||||||
|
const to = from + color.length;
|
||||||
|
const decoration = Decoration.inline(from, to, {
|
||||||
|
class: 'color',
|
||||||
|
style: `--color: ${color}`
|
||||||
|
});
|
||||||
|
|
||||||
|
decorations.push(decoration);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return DecorationSet.create(doc, decorations);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current browser is mac or not
|
||||||
|
*/
|
||||||
|
export const isMac = browser
|
||||||
|
? navigator.userAgent.includes('Macintosh') || navigator.userAgent.includes('Mac OS X')
|
||||||
|
: false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dupilcate content at the current selection
|
||||||
|
* @param editor Editor instance
|
||||||
|
* @param node Node to be duplicated
|
||||||
|
*/
|
||||||
|
export const duplicateContent = (editor: Editor, node: Node) => {
|
||||||
|
const { view } = editor;
|
||||||
|
const { state } = view;
|
||||||
|
const { selection } = state;
|
||||||
|
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.insertContentAt(selection.to, node.toJSON(), {
|
||||||
|
updateSelection: true
|
||||||
|
})
|
||||||
|
.focus(selection.to)
|
||||||
|
.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to handle paste event of an image
|
||||||
|
* @param editor Editor - editor instance
|
||||||
|
* @param maxSize number - max size of the image to be pasted in MB, default is 2MB
|
||||||
|
*/
|
||||||
|
export function getHandlePaste(editor: Editor, maxSize: number = 2) {
|
||||||
|
return (view: EditorView, event: ClipboardEvent) => {
|
||||||
|
const item = event.clipboardData?.items[0];
|
||||||
|
|
||||||
|
if (item?.type.indexOf('image') !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file === null || file.size === undefined) return;
|
||||||
|
const filesize = (file?.size / 1024 / 1024).toFixed(4);
|
||||||
|
|
||||||
|
if (filesize && Number(filesize) > maxSize) {
|
||||||
|
window.alert(`too large image! filesize: ${filesize} mb`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
reader.onload = (e) => {
|
||||||
|
if (e.target?.result) {
|
||||||
|
editor.commands.setImage({ src: e.target.result as string });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets focus on the editor and moves the cursor to the clicked text position,
|
||||||
|
* defaulting to the end of the document if the click is outside any text.
|
||||||
|
*
|
||||||
|
* @param editor - Editor instance
|
||||||
|
* @param event - Optional MouseEvent or KeyboardEvent triggering the focus
|
||||||
|
*/
|
||||||
|
export function focusEditor(editor: Editor | undefined, event?: MouseEvent | KeyboardEvent) {
|
||||||
|
if (!editor) return;
|
||||||
|
// Check if there is a text selection already (i.e. a non-empty selection)
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.toString().length > 0) {
|
||||||
|
// Focus the editor without modifying selection
|
||||||
|
editor.chain().focus().run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event instanceof MouseEvent) {
|
||||||
|
const { clientX, clientY } = event;
|
||||||
|
const pos = editor.view.posAtCoords({ left: clientX, top: clientY })?.pos;
|
||||||
|
if (pos == null) {
|
||||||
|
// If not a valid position, move cursor to the end of the document
|
||||||
|
const endPos = editor.state.doc.content.size;
|
||||||
|
editor.chain().focus().setTextSelection(endPos).run();
|
||||||
|
} else {
|
||||||
|
editor.chain().focus().setTextSelection(pos).run();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
editor.chain().focus().run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for Edra's editor component
|
||||||
|
*/
|
||||||
|
export interface EdraProps {
|
||||||
|
class?: string;
|
||||||
|
content?: Content;
|
||||||
|
editable?: boolean;
|
||||||
|
limit?: number;
|
||||||
|
editor?: Editor;
|
||||||
|
showSlashCommands?: boolean;
|
||||||
|
showLinkBubbleMenu?: boolean;
|
||||||
|
showTableBubbleMenu?: boolean;
|
||||||
|
/**
|
||||||
|
* Callback function to be called when the content is updated
|
||||||
|
* @param content
|
||||||
|
*/
|
||||||
|
onUpdate?: (props: { editor: Editor; transaction: Transaction }) => void;
|
||||||
|
children?: Snippet<[]>;
|
||||||
|
}
|
||||||
|
|
@ -12,14 +12,16 @@ export interface Post {
|
||||||
content: string
|
content: string
|
||||||
excerpt?: string
|
excerpt?: string
|
||||||
images?: string[]
|
images?: string[]
|
||||||
link?: {
|
link?:
|
||||||
url: string
|
| {
|
||||||
title?: string
|
url: string
|
||||||
description?: string
|
title?: string
|
||||||
image?: string
|
description?: string
|
||||||
favicon?: string
|
image?: string
|
||||||
siteName?: string
|
favicon?: string
|
||||||
} | string
|
siteName?: string
|
||||||
|
}
|
||||||
|
| string
|
||||||
}
|
}
|
||||||
|
|
||||||
const postsDirectory = path.join(process.cwd(), 'src/lib/posts')
|
const postsDirectory = path.join(process.cwd(), 'src/lib/posts')
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
---
|
---
|
||||||
type: "link"
|
type: 'link'
|
||||||
date: "2024-01-22T09:00:00Z"
|
date: '2024-01-22T09:00:00Z'
|
||||||
slug: "auto-metadata-link"
|
slug: 'auto-metadata-link'
|
||||||
published: true
|
published: true
|
||||||
link: "https://github.com/sveltejs/kit"
|
link: 'https://github.com/sveltejs/kit'
|
||||||
---
|
---
|
||||||
|
|
||||||
Check out the SvelteKit repository - the framework that powers this blog!
|
Check out the SvelteKit repository - the framework that powers this blog!
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
---
|
---
|
||||||
title: "Beautiful Sunset Gallery"
|
title: 'Beautiful Sunset Gallery'
|
||||||
type: "image"
|
type: 'image'
|
||||||
date: "2024-01-19T18:30:00Z"
|
date: '2024-01-19T18:30:00Z'
|
||||||
slug: "beautiful-sunset-gallery"
|
slug: 'beautiful-sunset-gallery'
|
||||||
published: true
|
published: true
|
||||||
images:
|
images:
|
||||||
- "https://images.unsplash.com/photo-1495616811223-4d98c6e9c869?w=800&h=600&fit=crop"
|
- 'https://images.unsplash.com/photo-1495616811223-4d98c6e9c869?w=800&h=600&fit=crop'
|
||||||
- "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop"
|
- 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop'
|
||||||
- "https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?w=800&h=600&fit=crop"
|
- 'https://images.unsplash.com/photo-1470252649378-9c29740c9fa8?w=800&h=600&fit=crop'
|
||||||
- "https://images.unsplash.com/photo-1475924156734-496f6cac6ec1?w=800&h=600&fit=crop"
|
- 'https://images.unsplash.com/photo-1475924156734-496f6cac6ec1?w=800&h=600&fit=crop'
|
||||||
- "https://images.unsplash.com/photo-1472214103451-9374bd1c798e?w=800&h=600&fit=crop"
|
- 'https://images.unsplash.com/photo-1472214103451-9374bd1c798e?w=800&h=600&fit=crop'
|
||||||
---
|
---
|
||||||
|
|
||||||
Caught these stunning sunsets during my recent travels. Each one tells its own story of endings and beginnings.
|
Caught these stunning sunsets during my recent travels. Each one tells its own story of endings and beginnings.
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
---
|
---
|
||||||
title: "Interesting Read"
|
title: 'Interesting Read'
|
||||||
type: "link"
|
type: 'link'
|
||||||
date: "2024-01-20T10:00:00Z"
|
date: '2024-01-20T10:00:00Z'
|
||||||
slug: "interesting-article"
|
slug: 'interesting-article'
|
||||||
published: true
|
published: true
|
||||||
link:
|
link:
|
||||||
url: "https://example.com/article"
|
url: 'https://example.com/article'
|
||||||
title: "The Future of Web Development"
|
title: 'The Future of Web Development'
|
||||||
description: "An in-depth look at emerging trends and technologies shaping the future of web development."
|
description: 'An in-depth look at emerging trends and technologies shaping the future of web development.'
|
||||||
siteName: "Example Blog"
|
siteName: 'Example Blog'
|
||||||
---
|
---
|
||||||
|
|
||||||
This article provides great insights into where web development is heading. The discussion about WebAssembly and edge computing is particularly fascinating.
|
This article provides great insights into where web development is heading. The discussion about WebAssembly and edge computing is particularly fascinating.
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
---
|
---
|
||||||
type: "image"
|
type: 'image'
|
||||||
date: "2024-01-17T10:00:00Z"
|
date: '2024-01-17T10:00:00Z'
|
||||||
slug: "minimalist-workspace"
|
slug: 'minimalist-workspace'
|
||||||
published: true
|
published: true
|
||||||
images:
|
images:
|
||||||
- "https://images.unsplash.com/photo-1555212697-194d092e3b8f?w=800&h=600&fit=crop"
|
- 'https://images.unsplash.com/photo-1555212697-194d092e3b8f?w=800&h=600&fit=crop'
|
||||||
---
|
---
|
||||||
|
|
||||||
My workspace this morning. Sometimes less really is more.
|
My workspace this morning. Sometimes less really is more.
|
||||||
99
src/lib/server/api-utils.ts
Normal file
99
src/lib/server/api-utils.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type { RequestEvent } from '@sveltejs/kit'
|
||||||
|
|
||||||
|
// Response helpers
|
||||||
|
export function jsonResponse(data: any, status = 200): Response {
|
||||||
|
return new Response(JSON.stringify(data), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorResponse(message: string, status = 400): Response {
|
||||||
|
return jsonResponse({ error: message }, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination helper
|
||||||
|
export interface PaginationParams {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaginationParams(url: URL): PaginationParams {
|
||||||
|
const page = Math.max(1, parseInt(url.searchParams.get('page') || '1'))
|
||||||
|
const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') || '20')))
|
||||||
|
|
||||||
|
return { page, limit }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPaginationMeta(total: number, page: number, limit: number) {
|
||||||
|
const totalPages = Math.ceil(total / limit)
|
||||||
|
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages,
|
||||||
|
hasNext: page < totalPages,
|
||||||
|
hasPrev: page > 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status validation
|
||||||
|
export const VALID_STATUSES = ['draft', 'published'] as const
|
||||||
|
export type Status = (typeof VALID_STATUSES)[number]
|
||||||
|
|
||||||
|
export function isValidStatus(status: any): status is Status {
|
||||||
|
return VALID_STATUSES.includes(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post type validation
|
||||||
|
export const VALID_POST_TYPES = ['blog', 'microblog', 'link', 'photo', 'album'] as const
|
||||||
|
export type PostType = (typeof VALID_POST_TYPES)[number]
|
||||||
|
|
||||||
|
export function isValidPostType(type: any): type is PostType {
|
||||||
|
return VALID_POST_TYPES.includes(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request body parser with error handling
|
||||||
|
export async function parseRequestBody<T>(request: Request): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
return body as T
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date helpers
|
||||||
|
export function toISOString(date: Date | string | null | undefined): string | null {
|
||||||
|
if (!date) return null
|
||||||
|
return new Date(date).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic auth check (temporary until proper auth is implemented)
|
||||||
|
export function checkAdminAuth(event: RequestEvent): boolean {
|
||||||
|
const authHeader = event.request.headers.get('Authorization')
|
||||||
|
if (!authHeader) return false
|
||||||
|
|
||||||
|
const [type, credentials] = authHeader.split(' ')
|
||||||
|
if (type !== 'Basic') return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = atob(credentials)
|
||||||
|
const [username, password] = decoded.split(':')
|
||||||
|
|
||||||
|
// For now, simple password check
|
||||||
|
// TODO: Implement proper authentication
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD || 'changeme'
|
||||||
|
return username === 'admin' && password === adminPassword
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CORS headers for API routes
|
||||||
|
export const corsHeaders = {
|
||||||
|
'Access-Control-Allow-Origin': '*', // Update this in production
|
||||||
|
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
|
||||||
|
}
|
||||||
226
src/lib/server/cloudinary.ts
Normal file
226
src/lib/server/cloudinary.ts
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
import { v2 as cloudinary } from 'cloudinary'
|
||||||
|
import type { UploadApiResponse, UploadApiErrorResponse } from 'cloudinary'
|
||||||
|
import { logger } from './logger'
|
||||||
|
import { uploadFileLocally } from './local-storage'
|
||||||
|
import { dev } from '$app/environment'
|
||||||
|
|
||||||
|
// Configure Cloudinary
|
||||||
|
cloudinary.config({
|
||||||
|
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
|
||||||
|
api_key: process.env.CLOUDINARY_API_KEY,
|
||||||
|
api_secret: process.env.CLOUDINARY_API_SECRET,
|
||||||
|
secure: true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if Cloudinary is configured
|
||||||
|
export function isCloudinaryConfigured(): boolean {
|
||||||
|
return !!(
|
||||||
|
process.env.CLOUDINARY_CLOUD_NAME &&
|
||||||
|
process.env.CLOUDINARY_API_KEY &&
|
||||||
|
process.env.CLOUDINARY_API_SECRET
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload options for different asset types
|
||||||
|
const uploadPresets = {
|
||||||
|
// For general media uploads (blog posts, project images)
|
||||||
|
media: {
|
||||||
|
folder: 'jedmund/media',
|
||||||
|
resource_type: 'auto' as const,
|
||||||
|
allowed_formats: ['jpg', 'jpeg', 'png', 'webp', 'gif', 'svg'],
|
||||||
|
transformation: [{ quality: 'auto:good' }, { fetch_format: 'auto' }]
|
||||||
|
},
|
||||||
|
|
||||||
|
// For photo albums
|
||||||
|
photos: {
|
||||||
|
folder: 'jedmund/photos',
|
||||||
|
resource_type: 'image' as const,
|
||||||
|
allowed_formats: ['jpg', 'jpeg', 'png', 'webp'],
|
||||||
|
transformation: [{ quality: 'auto:best' }, { fetch_format: 'auto' }]
|
||||||
|
},
|
||||||
|
|
||||||
|
// For project galleries
|
||||||
|
projects: {
|
||||||
|
folder: 'jedmund/projects',
|
||||||
|
resource_type: 'image' as const,
|
||||||
|
allowed_formats: ['jpg', 'jpeg', 'png', 'webp', 'svg'],
|
||||||
|
transformation: [{ quality: 'auto:good' }, { fetch_format: 'auto' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image size variants
|
||||||
|
export const imageSizes = {
|
||||||
|
thumbnail: { width: 300, height: 300, crop: 'fill' as const },
|
||||||
|
small: { width: 600, quality: 'auto:good' as const },
|
||||||
|
medium: { width: 1200, quality: 'auto:good' as const },
|
||||||
|
large: { width: 1920, quality: 'auto:good' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
success: boolean
|
||||||
|
publicId?: string
|
||||||
|
url?: string
|
||||||
|
secureUrl?: string
|
||||||
|
thumbnailUrl?: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
format?: string
|
||||||
|
size?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload a single file
|
||||||
|
export async function uploadFile(
|
||||||
|
file: File,
|
||||||
|
type: 'media' | 'photos' | 'projects' = 'media',
|
||||||
|
customOptions?: any
|
||||||
|
): Promise<UploadResult> {
|
||||||
|
try {
|
||||||
|
// Use local storage in development or when Cloudinary is not configured
|
||||||
|
if (dev || !isCloudinaryConfigured()) {
|
||||||
|
logger.info('Using local storage for file upload')
|
||||||
|
const localResult = await uploadFileLocally(file, type)
|
||||||
|
|
||||||
|
if (!localResult.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: localResult.error || 'Local upload failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
publicId: `local/${localResult.filename}`,
|
||||||
|
url: localResult.url,
|
||||||
|
secureUrl: localResult.url,
|
||||||
|
thumbnailUrl: localResult.thumbnailUrl,
|
||||||
|
width: localResult.width,
|
||||||
|
height: localResult.height,
|
||||||
|
format: file.type.split('/')[1],
|
||||||
|
size: localResult.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert File to buffer
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
const buffer = Buffer.from(arrayBuffer)
|
||||||
|
|
||||||
|
// Upload to Cloudinary
|
||||||
|
const result = await new Promise<UploadApiResponse>((resolve, reject) => {
|
||||||
|
const uploadStream = cloudinary.uploader.upload_stream(
|
||||||
|
{
|
||||||
|
...uploadPresets[type],
|
||||||
|
...customOptions,
|
||||||
|
public_id: `${Date.now()}-${file.name.replace(/\.[^/.]+$/, '')}`
|
||||||
|
},
|
||||||
|
(error, result) => {
|
||||||
|
if (error) reject(error)
|
||||||
|
else if (result) resolve(result)
|
||||||
|
else reject(new Error('Upload failed'))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
uploadStream.end(buffer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Generate thumbnail URL
|
||||||
|
const thumbnailUrl = cloudinary.url(result.public_id, {
|
||||||
|
...imageSizes.thumbnail,
|
||||||
|
secure: true
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.mediaUpload(file.name, file.size, file.type, true)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
publicId: result.public_id,
|
||||||
|
url: result.url,
|
||||||
|
secureUrl: result.secure_url,
|
||||||
|
thumbnailUrl,
|
||||||
|
width: result.width,
|
||||||
|
height: result.height,
|
||||||
|
format: result.format,
|
||||||
|
size: result.bytes
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Cloudinary upload failed', error as Error)
|
||||||
|
logger.mediaUpload(file.name, file.size, file.type, false)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Upload failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload multiple files
|
||||||
|
export async function uploadFiles(
|
||||||
|
files: File[],
|
||||||
|
type: 'media' | 'photos' | 'projects' = 'media'
|
||||||
|
): Promise<UploadResult[]> {
|
||||||
|
const uploadPromises = files.map((file) => uploadFile(file, type))
|
||||||
|
return Promise.all(uploadPromises)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a file from Cloudinary
|
||||||
|
export async function deleteFile(publicId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!isCloudinaryConfigured()) {
|
||||||
|
throw new Error('Cloudinary is not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await cloudinary.uploader.destroy(publicId)
|
||||||
|
return result.result === 'ok'
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Cloudinary delete failed', error as Error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate optimized URL for an image
|
||||||
|
export function getOptimizedUrl(
|
||||||
|
publicId: string,
|
||||||
|
options?: {
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
quality?: string
|
||||||
|
format?: string
|
||||||
|
crop?: string
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
return cloudinary.url(publicId, {
|
||||||
|
secure: true,
|
||||||
|
transformation: [
|
||||||
|
{
|
||||||
|
quality: options?.quality || 'auto:good',
|
||||||
|
fetch_format: options?.format || 'auto',
|
||||||
|
...(options?.width && { width: options.width }),
|
||||||
|
...(options?.height && { height: options.height }),
|
||||||
|
...(options?.crop && { crop: options.crop })
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get responsive image URLs for different screen sizes
|
||||||
|
export function getResponsiveUrls(publicId: string): Record<string, string> {
|
||||||
|
return {
|
||||||
|
thumbnail: getOptimizedUrl(publicId, imageSizes.thumbnail),
|
||||||
|
small: getOptimizedUrl(publicId, { width: imageSizes.small.width }),
|
||||||
|
medium: getOptimizedUrl(publicId, { width: imageSizes.medium.width }),
|
||||||
|
large: getOptimizedUrl(publicId, { width: imageSizes.large.width }),
|
||||||
|
original: cloudinary.url(publicId, { secure: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract public ID from Cloudinary URL
|
||||||
|
export function extractPublicId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
// Cloudinary URLs typically follow this pattern:
|
||||||
|
// https://res.cloudinary.com/{cloud_name}/image/upload/{version}/{public_id}.{format}
|
||||||
|
const match = url.match(/\/v\d+\/(.+)\.[a-zA-Z]+$/)
|
||||||
|
return match ? match[1] : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/lib/server/database.ts
Normal file
95
src/lib/server/database.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import { dev } from '$app/environment'
|
||||||
|
|
||||||
|
// Prevent multiple instances of Prisma Client in development
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: dev ? ['query', 'error', 'warn'] : ['error']
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dev) globalForPrisma.prisma = prisma
|
||||||
|
|
||||||
|
// Utility function to handle database errors
|
||||||
|
export function handleDatabaseError(error: unknown): Response {
|
||||||
|
console.error('Database error:', error)
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
// Check for unique constraint violations
|
||||||
|
if (error.message.includes('Unique constraint')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'A record with this identifier already exists'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 409,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for foreign key violations
|
||||||
|
if (error.message.includes('Foreign key constraint')) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Related record not found'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic error response
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'An unexpected database error occurred'
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility to create slugs from titles
|
||||||
|
export function createSlug(title: string): string {
|
||||||
|
return title
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, '') // Remove special characters
|
||||||
|
.replace(/[\s_-]+/g, '-') // Replace spaces, underscores, hyphens with single hyphen
|
||||||
|
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure unique slug by appending number if needed
|
||||||
|
export async function ensureUniqueSlug(
|
||||||
|
slug: string,
|
||||||
|
model: 'project' | 'post' | 'album' | 'photo',
|
||||||
|
excludeId?: number
|
||||||
|
): Promise<string> {
|
||||||
|
let uniqueSlug = slug
|
||||||
|
let counter = 1
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const existingRecord = await prisma[model].findFirst({
|
||||||
|
where: {
|
||||||
|
slug: uniqueSlug,
|
||||||
|
...(excludeId ? { NOT: { id: excludeId } } : {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingRecord) break
|
||||||
|
|
||||||
|
uniqueSlug = `${slug}-${counter}`
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueSlug
|
||||||
|
}
|
||||||
149
src/lib/server/local-storage.ts
Normal file
149
src/lib/server/local-storage.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
import { writeFile, mkdir } from 'fs/promises'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import sharp from 'sharp'
|
||||||
|
import { logger } from './logger'
|
||||||
|
|
||||||
|
// Base directory for local uploads
|
||||||
|
const UPLOAD_DIR = 'static/local-uploads'
|
||||||
|
const PUBLIC_PATH = '/local-uploads'
|
||||||
|
|
||||||
|
// Ensure upload directory exists
|
||||||
|
async function ensureUploadDir(): Promise<void> {
|
||||||
|
const dirs = [
|
||||||
|
UPLOAD_DIR,
|
||||||
|
path.join(UPLOAD_DIR, 'media'),
|
||||||
|
path.join(UPLOAD_DIR, 'photos'),
|
||||||
|
path.join(UPLOAD_DIR, 'projects'),
|
||||||
|
path.join(UPLOAD_DIR, 'thumbnails')
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const dir of dirs) {
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
function generateFilename(originalName: string): string {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const random = Math.random().toString(36).substring(2, 8)
|
||||||
|
const ext = path.extname(originalName)
|
||||||
|
const name = path.basename(originalName, ext)
|
||||||
|
// Sanitize filename
|
||||||
|
const safeName = name.replace(/[^a-z0-9]/gi, '-').toLowerCase()
|
||||||
|
return `${timestamp}-${random}-${safeName}${ext}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalUploadResult {
|
||||||
|
success: boolean
|
||||||
|
filename?: string
|
||||||
|
url?: string
|
||||||
|
thumbnailUrl?: string
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
size?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload file locally (for development/testing)
|
||||||
|
export async function uploadFileLocally(
|
||||||
|
file: File,
|
||||||
|
type: 'media' | 'photos' | 'projects' = 'media'
|
||||||
|
): Promise<LocalUploadResult> {
|
||||||
|
try {
|
||||||
|
await ensureUploadDir()
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
const filename = generateFilename(file.name)
|
||||||
|
const filepath = path.join(UPLOAD_DIR, type, filename)
|
||||||
|
const thumbnailPath = path.join(UPLOAD_DIR, 'thumbnails', `thumb-${filename}`)
|
||||||
|
|
||||||
|
// Convert File to buffer
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
const buffer = Buffer.from(arrayBuffer)
|
||||||
|
|
||||||
|
// Process image with sharp to get dimensions
|
||||||
|
let width = 0
|
||||||
|
let height = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = sharp(buffer)
|
||||||
|
const metadata = await image.metadata()
|
||||||
|
width = metadata.width || 0
|
||||||
|
height = metadata.height || 0
|
||||||
|
|
||||||
|
// Save original
|
||||||
|
await writeFile(filepath, buffer)
|
||||||
|
|
||||||
|
// Create thumbnail (300x300)
|
||||||
|
await image
|
||||||
|
.resize(300, 300, {
|
||||||
|
fit: 'cover',
|
||||||
|
position: 'center'
|
||||||
|
})
|
||||||
|
.toFile(thumbnailPath)
|
||||||
|
|
||||||
|
} catch (imageError) {
|
||||||
|
// If sharp fails (e.g., for SVG), just save the original
|
||||||
|
logger.warn('Sharp processing failed, saving original only', imageError as Error)
|
||||||
|
await writeFile(filepath, buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct URLs
|
||||||
|
const url = `${PUBLIC_PATH}/${type}/${filename}`
|
||||||
|
const thumbnailUrl = `${PUBLIC_PATH}/thumbnails/thumb-${filename}`
|
||||||
|
|
||||||
|
logger.info('File uploaded locally', {
|
||||||
|
filename,
|
||||||
|
type,
|
||||||
|
size: file.size,
|
||||||
|
dimensions: `${width}x${height}`
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filename,
|
||||||
|
url,
|
||||||
|
thumbnailUrl,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
size: file.size
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Local upload failed', error as Error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Upload failed'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete local file
|
||||||
|
export async function deleteFileLocally(url: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
// Extract path from URL
|
||||||
|
const relativePath = url.replace(PUBLIC_PATH, '')
|
||||||
|
const filepath = path.join(UPLOAD_DIR, relativePath)
|
||||||
|
|
||||||
|
// Check if file exists and delete
|
||||||
|
if (existsSync(filepath)) {
|
||||||
|
const { unlink } = await import('fs/promises')
|
||||||
|
await unlink(filepath)
|
||||||
|
|
||||||
|
// Try to delete thumbnail too
|
||||||
|
const filename = path.basename(filepath)
|
||||||
|
const thumbnailPath = path.join(UPLOAD_DIR, 'thumbnails', `thumb-${filename}`)
|
||||||
|
if (existsSync(thumbnailPath)) {
|
||||||
|
await unlink(thumbnailPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Local delete failed', error as Error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/lib/server/logger.ts
Normal file
135
src/lib/server/logger.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { dev } from '$app/environment'
|
||||||
|
|
||||||
|
export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
|
interface LogEntry {
|
||||||
|
level: LogLevel
|
||||||
|
message: string
|
||||||
|
timestamp: string
|
||||||
|
context?: Record<string, any>
|
||||||
|
error?: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
class Logger {
|
||||||
|
private shouldLog(level: LogLevel): boolean {
|
||||||
|
// In development, log everything
|
||||||
|
if (dev) return true
|
||||||
|
|
||||||
|
// In production, only log warnings and errors
|
||||||
|
return level === 'warn' || level === 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatLog(entry: LogEntry): string {
|
||||||
|
const parts = [`[${entry.timestamp}]`, `[${entry.level.toUpperCase()}]`, entry.message]
|
||||||
|
|
||||||
|
if (entry.context) {
|
||||||
|
parts.push(JSON.stringify(entry.context, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.error) {
|
||||||
|
parts.push(`\nError: ${entry.error.message}`)
|
||||||
|
if (entry.error.stack) {
|
||||||
|
parts.push(`Stack: ${entry.error.stack}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(level: LogLevel, message: string, context?: Record<string, any>, error?: Error) {
|
||||||
|
if (!this.shouldLog(level)) return
|
||||||
|
|
||||||
|
const entry: LogEntry = {
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
context,
|
||||||
|
error
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatted = this.formatLog(entry)
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case 'debug':
|
||||||
|
case 'info':
|
||||||
|
console.log(formatted)
|
||||||
|
break
|
||||||
|
case 'warn':
|
||||||
|
console.warn(formatted)
|
||||||
|
break
|
||||||
|
case 'error':
|
||||||
|
console.error(formatted)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: string, context?: Record<string, any>) {
|
||||||
|
this.log('debug', message, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, context?: Record<string, any>) {
|
||||||
|
this.log('info', message, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, context?: Record<string, any>) {
|
||||||
|
this.log('warn', message, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, error?: Error, context?: Record<string, any>) {
|
||||||
|
this.log('error', message, context, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log API requests
|
||||||
|
apiRequest(method: string, path: string, context?: Record<string, any>) {
|
||||||
|
this.info(`API Request: ${method} ${path}`, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log API responses
|
||||||
|
apiResponse(method: string, path: string, status: number, duration: number) {
|
||||||
|
const level = status >= 400 ? 'error' : 'info'
|
||||||
|
this.log(level, `API Response: ${method} ${path} - ${status} (${duration}ms)`, {
|
||||||
|
status,
|
||||||
|
duration
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log database operations
|
||||||
|
dbQuery(operation: string, model: string, duration?: number, context?: Record<string, any>) {
|
||||||
|
this.debug(`DB Query: ${operation} on ${model}`, {
|
||||||
|
...context,
|
||||||
|
duration: duration ? `${duration}ms` : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log media operations
|
||||||
|
mediaUpload(filename: string, size: number, mimeType: string, success: boolean) {
|
||||||
|
const level = success ? 'info' : 'error'
|
||||||
|
this.log(level, `Media Upload: ${filename}`, {
|
||||||
|
size: `${(size / 1024 / 1024).toFixed(2)} MB`,
|
||||||
|
mimeType,
|
||||||
|
success
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = new Logger()
|
||||||
|
|
||||||
|
// Middleware to log API requests
|
||||||
|
export function createRequestLogger() {
|
||||||
|
return (event: any) => {
|
||||||
|
const start = Date.now()
|
||||||
|
const { method, url } = event.request
|
||||||
|
const path = new URL(url).pathname
|
||||||
|
|
||||||
|
logger.apiRequest(method, path, {
|
||||||
|
headers: Object.fromEntries(event.request.headers),
|
||||||
|
ip: event.getClientAddress()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log response after it's sent
|
||||||
|
event.locals.logResponse = (status: number) => {
|
||||||
|
const duration = Date.now() - start
|
||||||
|
logger.apiResponse(method, path, status, duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/lib/types/editor.ts
Normal file
43
src/lib/types/editor.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
|
export interface EditorData extends JSONContent {}
|
||||||
|
|
||||||
|
export interface EditorProps {
|
||||||
|
data?: EditorData
|
||||||
|
readOnly?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
onChange?: (data: EditorData) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy EditorJS format support (for migration)
|
||||||
|
export interface EditorBlock {
|
||||||
|
id?: string
|
||||||
|
type: string
|
||||||
|
data: {
|
||||||
|
text?: string
|
||||||
|
level?: number
|
||||||
|
style?: string
|
||||||
|
items?: string[]
|
||||||
|
caption?: string
|
||||||
|
url?: string
|
||||||
|
[key: string]: any
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorSaveData {
|
||||||
|
time: number
|
||||||
|
blocks: EditorBlock[]
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tiptap/Edra content nodes
|
||||||
|
export interface TiptapNode {
|
||||||
|
type: string
|
||||||
|
attrs?: Record<string, any>
|
||||||
|
content?: TiptapNode[]
|
||||||
|
marks?: Array<{
|
||||||
|
type: string
|
||||||
|
attrs?: Record<string, any>
|
||||||
|
}>
|
||||||
|
text?: string
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores'
|
||||||
import Header from '$components/Header.svelte'
|
import Header from '$components/Header.svelte'
|
||||||
import Footer from '$components/Footer.svelte'
|
import Footer from '$components/Footer.svelte'
|
||||||
|
|
||||||
|
$: isAdminRoute = $page.url.pathname.startsWith('/admin')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
@ -13,13 +16,17 @@ user-scalable=no"
|
||||||
/>
|
/>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<Header />
|
{#if !isAdminRoute}
|
||||||
|
<Header />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<main>
|
<main class:admin-route={isAdminRoute}>
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
{#if !isAdminRoute}
|
||||||
|
<Footer />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '../assets/styles/reset.css';
|
@import '../assets/styles/reset.css';
|
||||||
|
|
@ -40,6 +47,10 @@ user-scalable=no"
|
||||||
|
|
||||||
main {
|
main {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
|
||||||
|
&.admin-route {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
@include breakpoint('phone') {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@ import type { Album } from '$lib/types/lastfm'
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch }) => {
|
export const load: PageLoad = async ({ fetch }) => {
|
||||||
try {
|
try {
|
||||||
const [albums] = await Promise.all([
|
const [albums] = await Promise.all([fetchRecentAlbums(fetch)])
|
||||||
fetchRecentAlbums(fetch)
|
|
||||||
])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
albums
|
albums
|
||||||
|
|
|
||||||
78
src/routes/admin/+layout.svelte
Normal file
78
src/routes/admin/+layout.svelte
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import AdminSegmentedController from '$lib/components/admin/AdminSegmentedController.svelte'
|
||||||
|
|
||||||
|
let { children } = $props()
|
||||||
|
|
||||||
|
// Check if user is authenticated
|
||||||
|
let isAuthenticated = $state(false)
|
||||||
|
let isLoading = $state(true)
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Check localStorage for auth token
|
||||||
|
const auth = localStorage.getItem('admin_auth')
|
||||||
|
if (auth) {
|
||||||
|
isAuthenticated = true
|
||||||
|
} else if ($page.url.pathname !== '/admin/login') {
|
||||||
|
// Redirect to login if not authenticated
|
||||||
|
goto('/admin/login')
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentPath = $derived($page.url.pathname)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading">Loading...</div>
|
||||||
|
{:else if !isAuthenticated && currentPath !== '/admin/login'}
|
||||||
|
<!-- Not authenticated and not on login page, redirect will happen in onMount -->
|
||||||
|
<div class="loading">Redirecting to login...</div>
|
||||||
|
{:else if currentPath === '/admin/login'}
|
||||||
|
<!-- On login page, show children without layout -->
|
||||||
|
{@render children()}
|
||||||
|
{:else}
|
||||||
|
<!-- Authenticated, show admin layout -->
|
||||||
|
<div class="admin-container">
|
||||||
|
<header class="admin-header">
|
||||||
|
<AdminSegmentedController />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="admin-content">
|
||||||
|
{@render children()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100vh;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: $grey-40;
|
||||||
|
font-family: 'cstd', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $unit-6x 0 $unit-4x;
|
||||||
|
background-color: $bg-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-content {
|
||||||
|
flex: 1;
|
||||||
|
background-color: $bg-color;
|
||||||
|
padding-bottom: $unit-6x;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue