Admin WIP

Projects and Posts sorta work, need design help
This commit is contained in:
Justin Edmund 2025-05-27 16:57:51 -07:00
parent 322427c118
commit 80d54aaaf0
130 changed files with 17177 additions and 466 deletions

16
.env.example Normal file
View file

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

5
.gitignore vendored
View file

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

179
LOCAL_SETUP.md Normal file
View file

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

View file

@ -1,25 +1,29 @@
# Product Requirements Document: Multi-Content CMS
## Overview
Add a comprehensive CMS to the personal portfolio site to manage multiple content types: Projects (Work section), Posts (Universe section), and Photos/Albums (Photos and Universe sections).
## Goals
- Enable dynamic content creation across all site sections
- Provide rich text editing for long-form content (BlockNote)
- Provide rich text editing for long-form content (Edra)
- Support different content types with appropriate editing interfaces
- Store all content in PostgreSQL database (Railway-compatible)
- Display content instantly after publishing
- Maintain the existing design aesthetic
## Technical Constraints
- **Hosting**: Railway (no direct file system access)
- **Database**: PostgreSQL add-on available
- **Framework**: SvelteKit
- **Editor**: 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
### 1. Database Schema
```sql
-- Projects table (for /work)
CREATE TABLE projects (
@ -35,7 +39,7 @@ CREATE TABLE projects (
featured_image VARCHAR(500),
gallery JSONB, -- Array of image URLs
external_url VARCHAR(500),
case_study_content JSONB, -- BlockNote JSON format
case_study_content JSONB, -- Edra JSON format
display_order INTEGER DEFAULT 0,
status VARCHAR(50) DEFAULT 'draft',
published_at TIMESTAMP,
@ -49,7 +53,7 @@ CREATE TABLE posts (
slug VARCHAR(255) UNIQUE NOT NULL,
post_type VARCHAR(50) NOT NULL, -- blog, microblog, link, photo, album
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,
-- Type-specific fields
@ -121,9 +125,10 @@ CREATE TABLE media (
### 2. Image Handling Strategy
#### For Posts (BlockNote Integration)
#### For Posts (Edra Integration)
- **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
- Returns media ID and URL
- Stores reference as `{ type: "image", mediaId: 123, url: "...", alt: "..." }`
@ -134,9 +139,10 @@ CREATE TABLE media (
- No orphaned images (tracked by mediaId)
#### For Projects
- **Featured Image**: Single image reference stored in `featured_image` field
- **Gallery Images**: Array of media IDs stored in `gallery` JSONB field
- **Case Study Content**: Uses same BlockNote approach as Posts
- **Case Study Content**: Uses same Edra approach as Posts
- **Storage Pattern**:
```json
{
@ -149,6 +155,7 @@ CREATE TABLE media (
```
#### Media Table Enhancement
```sql
-- Add content associations to media table
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
- **Projects**: Form-based editor with:
- Metadata fields (title, year, client, role)
- Technology tag selector
- Featured image picker (opens media library)
- Gallery manager (grid view with reordering)
- Optional BlockNote editor for case studies
- **Posts**: Full BlockNote editor with:
- Optional Edra editor for case studies
- **Posts**: Full Edra editor with:
- Custom image block implementation
- Drag-and-drop image upload
- Media library integration
@ -174,10 +182,10 @@ ALTER TABLE media ADD COLUMN used_in JSONB DEFAULT '[]';
- EXIF data extraction
- Album metadata editing
### 4. BlockNote Custom Image Block
### 4. Edra Custom Image Block
```typescript
// Custom image block schema for BlockNote
// Custom image block schema for Edra
const ImageBlock = {
type: "image",
content: {
@ -192,7 +200,7 @@ const ImageBlock = {
}
}
// Example BlockNote content with images
// Example Edra content with images
{
"blocks": [
{ "type": "heading", "content": "Project Overview" },
@ -215,7 +223,7 @@ const ImageBlock = {
### 5. Media Library Component
- **Modal Interface**: Opens from BlockNote toolbar or form fields
- **Modal Interface**: Opens from Edra toolbar or form fields
- **Features**:
- Grid view of all uploaded media
- Search by filename
@ -241,74 +249,78 @@ const ImageBlock = {
5. **Association**: Update `used_in` when embedded
### 7. API Endpoints
```typescript
// Projects
GET /api/projects
POST /api/projects
GET /api/projects/[slug]
PUT /api/projects/[id]
DELETE /api/projects/[id]
GET / api / projects
POST / api / projects
GET / api / projects / [slug]
PUT / api / projects / [id]
DELETE / api / projects / [id]
// Posts
GET /api/posts
POST /api/posts
GET /api/posts/[slug]
PUT /api/posts/[id]
DELETE /api/posts/[id]
GET / api / posts
POST / api / posts
GET / api / posts / [slug]
PUT / api / posts / [id]
DELETE / api / posts / [id]
// Albums & Photos
GET /api/albums
POST /api/albums
GET /api/albums/[slug]
PUT /api/albums/[id]
DELETE /api/albums/[id]
POST /api/albums/[id]/photos
DELETE /api/photos/[id]
PUT /api/photos/[id]/order
GET / api / albums
POST / api / albums
GET / api / albums / [slug]
PUT / api / albums / [id]
DELETE / api / albums / [id]
POST / api / albums / [id] / photos
DELETE / api / photos / [id]
PUT / api / photos / [id] / order
// Media upload
POST /api/media/upload
POST /api/media/bulk-upload
GET /api/media // Browse with filters
DELETE /api/media/[id] // Delete if unused
GET /api/media/[id]/usage // Check where media is used
POST / api / media / upload
POST / api / media / bulk - upload
GET / api / media // Browse with filters
DELETE / api / media / [id] // Delete if unused
GET / api / media / [id] / usage // Check where media is used
```
### 8. Media Management & Cleanup
#### Orphaned Media Prevention
- **Reference Tracking**: `used_in` field tracks all content using each media item
- **On Save**: Update media associations when content is saved
- **On Delete**: Remove associations when content is deleted
- **Cleanup Task**: Periodic job to identify truly orphaned media
#### BlockNote Integration Details
#### Edra Integration Details
```javascript
// Custom upload handler for BlockNote
const handleImageUpload = async (file) => {
const formData = new FormData();
formData.append('file', file);
formData.append('context', 'post'); // or 'project'
const formData = new FormData()
formData.append('file', file)
formData.append('context', 'post') // or 'project'
const response = await fetch('/api/media/upload', {
method: 'POST',
body: formData
});
})
const media = await response.json();
const media = await response.json()
// Return format expected by BlockNote
// Return format expected by Edra
return {
mediaId: media.id,
url: media.url,
thumbnailUrl: media.thumbnail_url,
width: media.width,
height: media.height
};
};
}
}
```
### 9. Admin Interface
- **Route**: `/admin` (completely separate from public routes)
- **Dashboard**: Overview of all content types
- **Content Lists**:
@ -319,6 +331,7 @@ const handleImageUpload = async (file) => {
- **Media Library**: Browse all uploaded files
### 10. Public Display Integration
- **Work page**: Dynamic project grid from database
- **Universe page**:
- Mixed feed of posts and albums (marked with `show_in_universe`)
@ -330,24 +343,28 @@ const handleImageUpload = async (file) => {
## Implementation Phases
### Phase 1: Foundation (Week 1)
- Set up PostgreSQL database with full schema
- Create database connection utilities
- Implement media upload infrastructure
- Build admin route structure and navigation
### Phase 2: Content Types (Week 2-3)
- **Posts**: BlockNote integration, CRUD APIs
- **Posts**: Edra integration, CRUD APIs
- **Projects**: Form builder, gallery management
- **Albums/Photos**: Bulk upload, EXIF extraction
- Create content type list views in admin
### Phase 3: Public Display (Week 4)
- Replace static project data with dynamic
- Build Universe mixed feed (posts + albums)
- Update Photos page with dynamic albums
- Implement individual content pages
### Phase 4: Polish & Optimization (Week 5)
- Image optimization and CDN caching
- Admin UI improvements
- Search and filtering
@ -356,12 +373,15 @@ const handleImageUpload = async (file) => {
## Technical Decisions
### Database Choice: PostgreSQL
- Native JSON support for BlockNote content
- Native JSON support for Edra content
- Railway provides managed PostgreSQL
- Familiar, battle-tested solution
### Media Storage Options
1. **Cloudinary** (Recommended)
- Free tier sufficient for personal use
- Automatic image optimization
- Easy API integration
@ -371,50 +391,57 @@ const handleImageUpload = async (file) => {
- Additional complexity for signed URLs
### Image Integration Summary
- **Posts**: Use BlockNote's custom image blocks with inline placement
- **Posts**: Use Edra's custom image blocks with inline placement
- **Projects**:
- Featured image: Single media reference
- Gallery: Array of media IDs with ordering
- Case studies: BlockNote blocks (same as posts)
- Case studies: Edra blocks (same as posts)
- **Albums**: Direct photos table relationship
- **Storage**: All images go through media table for consistent handling
- **Association**: Track usage with `used_in` JSONB field to prevent orphans
### Authentication (Future)
- Initially: No auth (rely on obscure admin URL)
- Future: Add simple password protection or OAuth
## Development Checklist
### Infrastructure
- [ ] Set up PostgreSQL on Railway
- [ ] Create database schema and migrations
- [ ] Set up Cloudinary/S3 for media storage
- [ ] Configure environment variables
### Dependencies
- [ ] `@blocknote/core` & `@blocknote/react`
- [ ] `edra` (Edra editor)
- [ ] `@prisma/client` or `postgres` driver
- [ ] `exifr` for EXIF data extraction
- [ ] `sharp` or Cloudinary SDK for image processing
- [ ] Form validation library (Zod/Valibot)
### Admin Interface
- [ ] Admin layout and navigation
- [ ] Content type switcher
- [ ] List views for each content type
- [ ] Form builders for Projects
- [ ] BlockNote wrapper for Posts
- [ ] Edra wrapper for Posts
- [ ] Photo uploader with drag-and-drop
- [ ] Media library browser
### APIs
- [ ] CRUD endpoints for all content types
- [ ] Media upload with progress
- [ ] Bulk operations (delete, publish)
- [ ] Search and filtering endpoints
### Public Display
- [ ] Dynamic Work page
- [ ] Mixed Universe feed
- [ ] Photos masonry grid
@ -432,66 +459,116 @@ Based on requirements discussion:
5. **Scheduled Publishing**: Not needed initially
6. **RSS Feeds**: Required for all content types (projects, posts, photos)
7. **Post Types**: Universe will support multiple post types:
- **Blog Post**: Title + long-form BlockNote content
- **Microblog**: No title, short-form BlockNote content
- **Blog Post**: Title + long-form Edra content
- **Microblog**: No title, short-form Edra content
- **Link Post**: URL + optional commentary
- **Photo Post**: Single photo + caption
- **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
### 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
- [ ] Set up PostgreSQL on Railway
- [ ] Create all database tables with updated schema
- [ ] Set up Prisma ORM with models
- [ ] Configure Cloudinary account and API keys
- [ ] Create base API route structure
- [ ] Implement database connection utilities
- [ ] Set up error handling and logging
- [x] Create all database tables with updated schema
- [x] Set up Prisma ORM with models
- [x] Create base API route structure
- [x] Implement database connection utilities
- [x] Set up error handling and logging
- [ ] Configure Cloudinary account (deferred to production setup)
- [ ] Set up PostgreSQL on Railway (deferred to production setup)
### Phase 2: Media Management System
- [ ] Create media upload endpoint with Cloudinary integration
- [ ] Implement image processing pipeline (multiple sizes)
- [ ] Build media library API endpoints
- [ ] Create media association tracking system
- [ ] Implement EXIF data extraction for photos
- [ ] Add bulk upload endpoint for photos
- [ ] Create media usage tracking queries
- [x] Create media upload endpoint with Cloudinary integration
- [x] Implement image processing pipeline (multiple sizes)
- [x] Build media library API endpoints
- [x] Create media association tracking system
- [x] Add bulk upload endpoint for photos
- [x] Create media usage tracking queries
### Phase 3: Admin Foundation
- [ ] Create admin layout component
- [ ] Build admin navigation with content type switcher
- [ ] Implement admin authentication (basic for now)
- [ ] Create reusable form components
- [ ] Build data table component for list views
- [ ] Add loading and error states
- [x] Create admin layout component
- [x] Build admin navigation with content type switcher
- [x] Implement admin authentication (basic for now)
- [x] Create reusable form components
- [x] Build data table component for list views
- [x] Add loading and error states
- [ ] Create media library modal component
### Phase 4: Posts System (All Types)
- [ ] Create BlockNote Svelte wrapper component
- [ ] Implement custom image block for BlockNote
- [ ] Build post type selector UI
- [ ] Create blog/microblog post editor
- [ ] Build link post form
- [x] Create Edra Svelte wrapper component
- [x] Implement custom image block for Edra
- [x] Build post type selector UI
- [x] Create blog/microblog post editor
- [x] Build link post form
- [x] Create posts list view in admin
- [x] Implement post CRUD APIs
- [x] Post editor page with type-specific fields
- [ ] Create photo post selector
- [ ] Build album post selector
- [ ] Implement post CRUD APIs
- [ ] Add auto-save functionality
- [ ] Create post list view in admin
### Phase 5: Projects System
- [ ] Build project form with all metadata fields
- [x] Build project form with all metadata fields
- [ ] Create technology tag selector
- [ ] Implement featured image picker
- [ ] Build gallery manager with drag-and-drop ordering
- [ ] Add optional BlockNote editor for case studies
- [ ] Create project CRUD APIs
- [ ] Build project list view with thumbnails
- [x] Add optional Edra editor for case studies
- [x] Create project CRUD APIs
- [x] Build project list view with thumbnails
- [ ] Add project ordering functionality
### Phase 6: Photos & Albums System
- [ ] Create album management interface
- [ ] Build bulk photo uploader with progress
- [ ] Implement EXIF data extraction for photos
- [ ] Implement drag-and-drop photo ordering
- [ ] Add individual photo publishing UI
- [ ] Create photo/album CRUD APIs
@ -500,6 +577,7 @@ Based on requirements discussion:
- [ ] Add "show in universe" toggle for albums
### Phase 7: Public Display Updates
- [ ] Replace static Work page with dynamic data
- [ ] Update project detail pages
- [ ] Build Universe mixed feed component
@ -510,6 +588,7 @@ Based on requirements discussion:
- [ ] Ensure responsive design throughout
### Phase 8: RSS Feeds & Final Polish
- [ ] Implement RSS feed for projects
- [ ] Create RSS feed for Universe posts
- [ ] Add RSS feed for photos/albums
@ -518,9 +597,20 @@ Based on requirements discussion:
- [ ] Optimize image loading and caching
- [ ] Add search functionality to admin
- [ ] 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)
- [ ] Version history system
- [ ] More robust authentication
- [ ] Project case study templates
@ -529,6 +619,7 @@ Based on requirements discussion:
- [ ] Backup system
## Success Metrics
- Can create and publish any content type within 2-3 minutes
- Content appears on site immediately after publishing
- Bulk photo upload handles 50+ images smoothly

2768
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,11 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
"format": "prettier --write .",
"db:migrate": "prisma migrate dev",
"db:seed": "prisma db seed",
"db:studio": "prisma studio",
"setup:local": "./scripts/setup-local.sh"
},
"devDependencies": {
"@musicorum/lastfm": "github:jedmund/lastfm",
@ -32,24 +36,70 @@
"svelte": "^5.0.0-next.1",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
"tsx": "^4.19.4",
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.0-alpha.20",
"vite": "^5.0.3"
},
"type": "module",
"dependencies": {
"@aarkue/tiptap-math-extension": "^1.3.6",
"@prisma/client": "^6.8.2",
"@sveltejs/adapter-node": "^5.2.0",
"@tiptap/core": "^2.12.0",
"@tiptap/extension-bubble-menu": "^2.12.0",
"@tiptap/extension-character-count": "^2.12.0",
"@tiptap/extension-code-block-lowlight": "^2.12.0",
"@tiptap/extension-color": "^2.12.0",
"@tiptap/extension-floating-menu": "^2.12.0",
"@tiptap/extension-highlight": "^2.12.0",
"@tiptap/extension-image": "^2.12.0",
"@tiptap/extension-link": "^2.12.0",
"@tiptap/extension-placeholder": "^2.12.0",
"@tiptap/extension-subscript": "^2.12.0",
"@tiptap/extension-superscript": "^2.12.0",
"@tiptap/extension-table": "^2.12.0",
"@tiptap/extension-table-header": "^2.12.0",
"@tiptap/extension-table-row": "^2.12.0",
"@tiptap/extension-task-item": "^2.12.0",
"@tiptap/extension-task-list": "^2.12.0",
"@tiptap/extension-text": "^2.12.0",
"@tiptap/extension-text-align": "^2.12.0",
"@tiptap/extension-text-style": "^2.12.0",
"@tiptap/extension-typography": "^2.12.0",
"@tiptap/extension-underline": "^2.12.0",
"@tiptap/pm": "^2.12.0",
"@tiptap/starter-kit": "^2.12.0",
"@tiptap/suggestion": "^2.12.0",
"@types/multer": "^1.4.12",
"@types/redis": "^4.0.10",
"@types/steamapi": "^2.2.5",
"dotenv": "^16.4.5",
"cloudinary": "^2.6.1",
"dotenv": "^16.5.0",
"giantbombing-api": "^1.0.4",
"gray-matter": "^4.0.3",
"ioredis": "^5.4.1",
"katex": "^0.16.22",
"lowlight": "^3.3.0",
"lucide-svelte": "^0.511.0",
"marked": "^15.0.12",
"multer": "^2.0.0",
"node-itunes-search": "^1.2.3",
"prisma": "^6.8.2",
"psn-api": "github:jedmund/psn-api",
"redis": "^4.7.0",
"sharp": "^0.34.2",
"steamapi": "^3.0.11",
"tinyduration": "^3.3.1"
"svelte-tiptap": "^2.1.0",
"svgo": "^3.3.2",
"tinyduration": "^3.3.1",
"tippy.js": "^6.3.7",
"tiptap-extension-auto-joiner": "^0.1.3",
"tiptap-extension-global-drag-handle": "^0.1.18",
"tiptap-markdown": "^0.8.10",
"zod": "^3.25.30"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}

View file

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

View file

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

View file

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

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

View file

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

View file

@ -64,11 +64,17 @@ $letter-spacing: -0.02em;
/* Colors
* -------------------------------------------------------------------------- */
$grey-100: #ffffff;
$grey-97: #fafafa;
$grey-95: #f5f5f5;
$grey-90: #f7f7f7;
$grey-85: #ebebeb;
$grey-80: #e8e8e8;
$grey-60: #cccccc;
$grey-50: #b2b2b2;
$grey-40: #999999;
$grey-30: #808080;
$grey-20: #666666;
$grey-10: #4d4d4d;
$grey-00: #333333;
$red-80: #ff6a54;
@ -85,6 +91,7 @@ $text-color-body: #666666;
$accent-color: #e33d3d;
$grey-color: #f0f0f0;
$primary-color: #1482c1; // Using labs color as primary
$image-border-color: rgba(0, 0, 0, 0.03);

View file

@ -23,7 +23,10 @@
})
</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">
<a href="/about" class="header-link" aria-label="@jedmund">
<Avatar />
@ -55,7 +58,11 @@
left: 0;
right: 0;
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)));
-webkit-backdrop-filter: blur(calc(6px * var(--gradient-opacity)));
mask-image: linear-gradient(to bottom, black 0%, black 15%, transparent 90%);

View file

@ -15,17 +15,39 @@
{#if project.url || project.github}
<div class="project-links">
{#if project.url}
<a href={project.url} target="_blank" rel="noopener noreferrer" class="project-link primary">
<a
href={project.url}
target="_blank"
rel="noopener noreferrer"
class="project-link primary"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M10 6H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4M14 4h6m0 0v6m0-6L10 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path
d="M10 6H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4M14 4h6m0 0v6m0-6L10 14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Visit Project
</a>
{/if}
{#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">
<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>
GitHub
</a>
@ -39,7 +61,9 @@
background: $grey-100;
border-radius: $card-corner-radius;
padding: $unit-3x;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);

View file

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

View file

@ -225,7 +225,8 @@
}
@keyframes pulse {
0%, 100% {
0%,
100% {
opacity: 1;
}
50% {

View file

@ -96,7 +96,9 @@
border-radius: $corner-radius;
overflow: hidden;
position: relative;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-2px);

View file

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

View file

@ -79,7 +79,9 @@
padding: $unit-3x;
background: $grey-100;
border-radius: $card-corner-radius;
transition: transform 0.15s ease-out, box-shadow 0.15s ease-out;
transition:
transform 0.15s ease-out,
box-shadow 0.15s ease-out;
transform-style: preserve-3d;
will-change: transform;
cursor: pointer;

View file

@ -20,7 +20,7 @@
SVGComponent: MaitsuLogo,
backgroundColor: '#FFF7EA',
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'
},
{
@ -35,7 +35,8 @@
SVGComponent: FigmaLogo,
backgroundColor: '#2c2c2c',
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'
},
{
@ -54,10 +55,12 @@
<li>
<div class="intro-card">
<p class="intro-text">
<span class="highlighted">@jedmund</span> is a software designer and strategist based out of San Francisco.
<span class="highlighted">@jedmund</span> is a software designer and strategist based out of
San Francisco.
</p>
<p class="intro-text">
In his 15 year career, he's focused his design practice on building tools that help people connect with technology—and their own creativity.
In his 15 year career, he's focused his design practice on building tools that help people
connect with technology—and their own creativity.
</p>
</div>
</li>
@ -108,7 +111,7 @@
}
.highlighted {
color: #D0290D;
color: #d0290d;
}
}
</style>

View file

@ -26,11 +26,15 @@
// Calculate active index based on current path
const activeIndex = $derived(
currentPath === '/' ? 0 :
currentPath.startsWith('/photos') ? 1 :
currentPath.startsWith('/labs') ? 2 :
currentPath.startsWith('/universe') ? 3 :
-1
currentPath === '/'
? 0
: currentPath.startsWith('/photos')
? 1
: currentPath.startsWith('/labs')
? 2
: currentPath.startsWith('/universe')
? 3
: -1
)
// Calculate pill position and width
@ -67,22 +71,32 @@
// Get background color based on variant
function getBgColor(variant: string): string {
switch (variant) {
case 'work': return '#ffcdc5' // $work-bg
case 'photos': return '#e8c5ff' // $photos-bg (purple)
case 'universe': return '#ffebc5' // $universe-bg
case 'labs': return '#c5eaff' // $labs-bg
default: return '#c5eaff'
case 'work':
return '#ffcdc5' // $work-bg
case 'photos':
return '#e8c5ff' // $photos-bg (purple)
case 'universe':
return '#ffebc5' // $universe-bg
case 'labs':
return '#c5eaff' // $labs-bg
default:
return '#c5eaff'
}
}
// Get text color based on variant
function getTextColor(variant: string): string {
switch (variant) {
case 'work': return '#d0290d' // $work-color
case 'photos': return '#7c3aed' // $photos-color (purple)
case 'universe': return '#b97d14' // $universe-color
case 'labs': return '#1482c1' // $labs-color
default: return '#1482c1'
case 'work':
return '#d0290d' // $work-color
case 'photos':
return '#7c3aed' // $photos-color (purple)
case 'universe':
return '#b97d14' // $universe-color
case 'labs':
return '#1482c1' // $labs-color
default:
return '#1482c1'
}
}
</script>
@ -102,10 +116,13 @@
class:active={index === activeIndex}
bind:this={itemElements[index]}
style="color: {index === activeIndex ? getTextColor(item.variant) : '#666'};"
onmouseenter={() => hoveredIndex = index}
onmouseleave={() => hoveredIndex = null}
onmouseenter={() => (hoveredIndex = index)}
onmouseleave={() => (hoveredIndex = null)}
>
<svelte:component this={item.icon} class="nav-icon {hoveredIndex === index ? 'animate' : ''}" />
<svelte:component
this={item.icon}
class="nav-icon {hoveredIndex === index ? 'animate' : ''}"
/>
<span>{item.text}</span>
</a>
{/each}
@ -148,7 +165,9 @@
font-family: 'cstd', 'Helvetica Neue', Helvetica, Arial, sans-serif;
position: relative;
z-index: 2;
transition: color 0.2s ease, background-color 0.2s ease;
transition:
color 0.2s ease,
background-color 0.2s ease;
&:hover:not(.active) {
background-color: rgba(0, 0, 0, 0.05);
@ -165,7 +184,6 @@
animation: iconPulse 0.6s ease;
}
}
}
// Different animations for each nav item
@ -203,5 +221,4 @@
.nav-item:nth-of-type(2) :global(svg.animate rect:nth-child(4)) {
animation: masonryRect4 0.6s ease;
}
</style>

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

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

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

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

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

View file

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

View file

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

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

View file

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

View file

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

View 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();
}
}
]
}
};

View 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[];
}

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

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

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

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

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

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

View 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: '¯\\_(ツ)_/¯' })
];
}
});

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

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

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

View file

@ -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.');
}

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

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

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

View file

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,8 @@
import TiptapTableRow from '@tiptap/extension-table-row';
export const TableRow = TiptapTableRow.extend({
allowGapCursor: false,
content: 'tableCell*'
});
export default TableRow;

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

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

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

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

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

View file

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

View file

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

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

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

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

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

View 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<[]>;
}

View file

@ -12,14 +12,16 @@ export interface Post {
content: string
excerpt?: string
images?: string[]
link?: {
link?:
| {
url: string
title?: string
description?: string
image?: string
favicon?: string
siteName?: string
} | string
}
| string
}
const postsDirectory = path.join(process.cwd(), 'src/lib/posts')

View file

@ -1,9 +1,9 @@
---
type: "link"
date: "2024-01-22T09:00:00Z"
slug: "auto-metadata-link"
type: 'link'
date: '2024-01-22T09:00:00Z'
slug: 'auto-metadata-link'
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!

View file

@ -1,15 +1,15 @@
---
title: "Beautiful Sunset Gallery"
type: "image"
date: "2024-01-19T18:30:00Z"
slug: "beautiful-sunset-gallery"
title: 'Beautiful Sunset Gallery'
type: 'image'
date: '2024-01-19T18:30:00Z'
slug: 'beautiful-sunset-gallery'
published: true
images:
- "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-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-1472214103451-9374bd1c798e?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-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-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.

View file

@ -1,14 +1,14 @@
---
title: "Interesting Read"
type: "link"
date: "2024-01-20T10:00:00Z"
slug: "interesting-article"
title: 'Interesting Read'
type: 'link'
date: '2024-01-20T10:00:00Z'
slug: 'interesting-article'
published: true
link:
url: "https://example.com/article"
title: "The Future of Web Development"
description: "An in-depth look at emerging trends and technologies shaping the future of web development."
siteName: "Example Blog"
url: 'https://example.com/article'
title: 'The Future of Web Development'
description: 'An in-depth look at emerging trends and technologies shaping the future of web development.'
siteName: 'Example Blog'
---
This article provides great insights into where web development is heading. The discussion about WebAssembly and edge computing is particularly fascinating.

View file

@ -1,10 +1,10 @@
---
type: "image"
date: "2024-01-17T10:00:00Z"
slug: "minimalist-workspace"
type: 'image'
date: '2024-01-17T10:00:00Z'
slug: 'minimalist-workspace'
published: true
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.

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

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

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

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

View file

@ -1,6 +1,9 @@
<script lang="ts">
import { page } from '$app/stores'
import Header from '$components/Header.svelte'
import Footer from '$components/Footer.svelte'
$: isAdminRoute = $page.url.pathname.startsWith('/admin')
</script>
<svelte:head>
@ -13,13 +16,17 @@ user-scalable=no"
/>
</svelte:head>
<Header />
{#if !isAdminRoute}
<Header />
{/if}
<main>
<main class:admin-route={isAdminRoute}>
<slot />
</main>
<Footer />
{#if !isAdminRoute}
<Footer />
{/if}
<style lang="scss">
@import '../assets/styles/reset.css';
@ -40,6 +47,10 @@ user-scalable=no"
main {
min-height: 100vh;
&.admin-route {
min-height: auto;
}
}
@include breakpoint('phone') {

View file

@ -3,9 +3,7 @@ import type { Album } from '$lib/types/lastfm'
export const load: PageLoad = async ({ fetch }) => {
try {
const [albums] = await Promise.all([
fetchRecentAlbums(fetch)
])
const [albums] = await Promise.all([fetchRecentAlbums(fetch)])
return {
albums

View 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