Library support in Edra
This commit is contained in:
parent
c7b4f57ab0
commit
b314be59f4
20 changed files with 3294 additions and 110 deletions
|
|
@ -417,27 +417,31 @@ const handleImageUpload = async (file) => {
|
|||
|
||||
### Dependencies
|
||||
|
||||
- [ ] `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)
|
||||
- [x] `edra` (Edra editor) - Integrated and configured
|
||||
- [x] `@prisma/client` - Set up with complete schema
|
||||
- [x] `cloudinary` - SDK integrated for image processing and storage
|
||||
- [x] Form validation with built-in validation
|
||||
- [ ] `exifr` for EXIF data extraction (needed for photos system)
|
||||
|
||||
### Admin Interface
|
||||
|
||||
- [ ] Admin layout and navigation
|
||||
- [ ] Content type switcher
|
||||
- [ ] List views for each content type
|
||||
- [ ] Form builders for Projects
|
||||
- [ ] Edra wrapper for Posts
|
||||
- [ ] Photo uploader with drag-and-drop
|
||||
- [ ] Media library browser
|
||||
- [x] Admin layout and navigation
|
||||
- [x] Content type switcher (Dashboard, Projects, Universe, Media)
|
||||
- [x] List views for projects and posts
|
||||
- [x] Complete form system for Projects (metadata, branding, styling)
|
||||
- [x] Edra wrapper for Posts with all post types
|
||||
- [x] Comprehensive admin component library
|
||||
- [ ] Photo uploader with drag-and-drop (for albums system)
|
||||
- [ ] Media library browser modal
|
||||
|
||||
### APIs
|
||||
|
||||
- [ ] CRUD endpoints for all content types
|
||||
- [ ] Media upload with progress
|
||||
- [ ] Bulk operations (delete, publish)
|
||||
- [x] CRUD endpoints for projects and posts
|
||||
- [x] Media upload with progress
|
||||
- [x] Bulk upload operations for media
|
||||
- [x] Media usage tracking endpoints
|
||||
- [ ] Albums CRUD endpoints (schema ready, UI needed)
|
||||
- [ ] Bulk operations (delete, publish) for content
|
||||
- [ ] Search and filtering endpoints
|
||||
|
||||
### Public Display
|
||||
|
|
@ -468,36 +472,50 @@ Based on requirements discussion:
|
|||
## 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
|
||||
- ✅ Project CRUD system with metadata fields and enhanced schema
|
||||
- ✅ Project list view in admin with enhanced UI
|
||||
- ✅ Project forms with branding (logo, colors) and styling
|
||||
- ✅ Posts CRUD system with all post types (blog, microblog, link, photo, album)
|
||||
- ✅ Posts list view and editor in admin
|
||||
- ✅ Complete database schema matching PRD requirements
|
||||
- ✅ Media API endpoints with upload, bulk upload, and usage tracking
|
||||
- ✅ Component library for admin interface (buttons, inputs, modals, etc.)
|
||||
- ✅ Test page for verifying upload functionality
|
||||
|
||||
### In Progress
|
||||
- 🔄 Posts System - Core functionality implemented
|
||||
|
||||
- 🔄 Albums/Photos System - Schema implemented, UI components needed
|
||||
|
||||
### 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
|
||||
1. **Media Library System** (Critical dependency for other features)
|
||||
|
||||
4. **Photos & Albums System**
|
||||
- Album creation and management
|
||||
- Bulk photo upload interface
|
||||
- Media library modal component
|
||||
- Integration with existing media APIs
|
||||
- Search and filter functionality within media browser
|
||||
|
||||
2. **Albums & Photos Management Interface**
|
||||
|
||||
- Album creation and management UI
|
||||
- Bulk photo upload interface with progress
|
||||
- Photo ordering within albums
|
||||
- Album cover selection
|
||||
- EXIF data extraction and display
|
||||
|
||||
3. **Enhanced Content Features**
|
||||
|
||||
- Photo/album post selectors using media library
|
||||
- Featured image picker for projects
|
||||
- Technology tag selector for projects
|
||||
- Auto-save functionality for all editors
|
||||
- Gallery manager for project images
|
||||
|
||||
## Phased Implementation Plan
|
||||
|
||||
|
|
@ -534,9 +552,10 @@ Based on requirements discussion:
|
|||
- [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] Create reusable form components (Button, Input, Modal, etc.)
|
||||
- [x] Build data table component for list views
|
||||
- [x] Add loading and error states
|
||||
- [x] Create comprehensive admin UI component library
|
||||
- [ ] Create media library modal component
|
||||
|
||||
### Phase 4: Posts System (All Types)
|
||||
|
|
@ -549,29 +568,34 @@ Based on requirements discussion:
|
|||
- [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
|
||||
- [x] Complete posts database schema with all post types
|
||||
- [x] Posts administration interface
|
||||
- [ ] Create photo post selector (needs media library modal)
|
||||
- [ ] Build album post selector (needs albums system)
|
||||
- [ ] Add auto-save functionality
|
||||
|
||||
### Phase 5: Projects System
|
||||
|
||||
- [x] Build project form with all metadata fields
|
||||
- [ ] Create technology tag selector
|
||||
- [ ] Implement featured image picker
|
||||
- [ ] Build gallery manager with drag-and-drop ordering
|
||||
- [x] Enhanced schema with branding fields (logo, colors)
|
||||
- [x] Project branding and styling forms
|
||||
- [x] Add optional Edra editor for case studies
|
||||
- [x] Create project CRUD APIs
|
||||
- [x] Build project list view with thumbnails
|
||||
- [x] Build project list view with enhanced UI
|
||||
- [ ] Create technology tag selector
|
||||
- [ ] Implement featured image picker (needs media library modal)
|
||||
- [ ] Build gallery manager with drag-and-drop ordering
|
||||
- [ ] Add project ordering functionality
|
||||
|
||||
### Phase 6: Photos & Albums System
|
||||
|
||||
- [x] Complete database schema for albums and photos
|
||||
- [x] Photo/album CRUD API endpoints (albums endpoint exists)
|
||||
- [ ] 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
|
||||
- [ ] Build photo metadata editor
|
||||
- [ ] Implement album cover selection
|
||||
- [ ] Add "show in universe" toggle for albums
|
||||
|
|
|
|||
534
PRD-media-library.md
Normal file
534
PRD-media-library.md
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
# Product Requirements Document: Media Library Modal System
|
||||
|
||||
## Overview
|
||||
|
||||
Implement a comprehensive Media Library modal system that provides a unified interface for browsing, selecting, and managing media across all admin forms. **The primary workflow is direct upload from computer within forms**, with the Media Library serving as a secondary browsing interface and management tool for previously uploaded content.
|
||||
|
||||
## 📋 Updated Approach Summary
|
||||
|
||||
**🎯 Primary Focus**: Direct upload components that allow users to drag-and-drop or browse files directly within project/post/album forms, with immediate preview and alt text capture.
|
||||
|
||||
**🎯 Secondary Feature**: Media Library modal for selecting previously uploaded content when needed.
|
||||
|
||||
**🎯 Key Addition**: Alt text storage and editing capabilities for accessibility compliance and SEO.
|
||||
|
||||
## Goals
|
||||
|
||||
### Primary Goals (Direct Upload Workflow)
|
||||
- **Enable direct file upload within forms** where content will be used (projects, posts, albums)
|
||||
- **Provide immediate upload and preview** without requiring navigation to separate media management
|
||||
- **Store comprehensive metadata** including alt text for accessibility and SEO
|
||||
- **Support drag-and-drop and click-to-browse** for intuitive file selection
|
||||
|
||||
### Secondary Goals (Media Library Browser)
|
||||
- Create a reusable media browser for **selecting previously uploaded content**
|
||||
- Provide **media management interface** showing where files are referenced
|
||||
- Enable **bulk operations** and **metadata editing** (especially alt text)
|
||||
- Support **file organization** and **usage tracking**
|
||||
|
||||
### Technical Goals
|
||||
- Maintain consistent UX across all media interactions
|
||||
- Support different file type filtering based on context
|
||||
- Integrate seamlessly with existing admin components
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### ✅ What We Have
|
||||
- Complete media API (`/api/media`, `/api/media/upload`, `/api/media/bulk-upload`)
|
||||
- Media management page with grid/list views and search/filtering
|
||||
- Modal base component (`Modal.svelte`)
|
||||
- Complete admin UI component library (Button, Input, etc.)
|
||||
- Media upload infrastructure with Cloudinary integration
|
||||
- Pagination and search functionality
|
||||
|
||||
### 🎯 What We Need
|
||||
|
||||
#### High Priority (Direct Upload Focus)
|
||||
- **Enhanced upload components** with immediate preview and metadata capture
|
||||
- **Alt text input fields** for accessibility compliance
|
||||
- **Direct upload integration** in form components (ImagePicker, GalleryManager)
|
||||
- **Metadata management** during upload process
|
||||
|
||||
#### Medium Priority (Media Library Browser)
|
||||
- Reusable MediaLibraryModal component for browsing existing content
|
||||
- Selection state management for previously uploaded files
|
||||
- Usage tracking and reference management
|
||||
|
||||
#### Database Updates Required
|
||||
- Add `alt_text` field to Media table
|
||||
- Add `usage_references` or similar tracking for where media is used
|
||||
|
||||
## Workflow Priorities
|
||||
|
||||
### 🥇 Primary Workflow: Direct Upload in Forms
|
||||
This is the **main workflow** that users will use 90% of the time:
|
||||
|
||||
1. **User creates content** (project, post, album)
|
||||
2. **User uploads files directly** in the form where they'll be used
|
||||
3. **Files are immediately processed** and previewed
|
||||
4. **Alt text and metadata** are captured during upload
|
||||
5. **Content is saved** with proper media references
|
||||
|
||||
**Key Components**:
|
||||
- `ImageUploader` - Direct drag-and-drop/click upload with preview
|
||||
- `GalleryUploader` - Multiple file upload with immediate gallery preview
|
||||
- `MediaMetadataForm` - Alt text and description capture during upload
|
||||
|
||||
### 🥈 Secondary Workflow: Browse Existing Media
|
||||
This workflow is for **reusing previously uploaded content**:
|
||||
|
||||
1. **User needs to select existing media** (rare case)
|
||||
2. **User clicks "Browse Library"** (secondary button)
|
||||
3. **Media Library Modal opens** showing all uploaded files
|
||||
4. **User selects from existing content**
|
||||
5. **Media references are updated**
|
||||
|
||||
**Key Components**:
|
||||
- `MediaLibraryModal` - Browse and select existing media
|
||||
- `MediaSelector` - Grid interface for selection
|
||||
- `MediaManager` - Edit alt text and view usage
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### 1. Enhanced Upload Components (Primary)
|
||||
|
||||
#### ImageUploader Component
|
||||
**Purpose**: Direct image upload with immediate preview and metadata capture
|
||||
|
||||
```typescript
|
||||
interface ImageUploaderProps {
|
||||
label: string
|
||||
value?: Media | null
|
||||
onUpload: (media: Media) => void
|
||||
aspectRatio?: string
|
||||
required?: boolean
|
||||
error?: string
|
||||
allowAltText?: boolean // Enable alt text input
|
||||
maxFileSize?: number // MB limit
|
||||
}
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Drag-and-drop upload zone with visual feedback
|
||||
- Click to browse files from computer
|
||||
- Immediate image preview with proper aspect ratio
|
||||
- Alt text input field (when enabled)
|
||||
- Upload progress indicator
|
||||
- File validation with helpful error messages
|
||||
- Replace/remove functionality
|
||||
|
||||
#### GalleryUploader Component
|
||||
**Purpose**: Multiple file upload with gallery preview and reordering
|
||||
|
||||
```typescript
|
||||
interface GalleryUploaderProps {
|
||||
label: string
|
||||
value?: Media[]
|
||||
onUpload: (media: Media[]) => void
|
||||
onReorder?: (media: Media[]) => void
|
||||
maxItems?: number
|
||||
allowAltText?: boolean
|
||||
required?: boolean
|
||||
error?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Multiple file drag-and-drop
|
||||
- Immediate gallery preview grid
|
||||
- Individual alt text inputs for each image
|
||||
- Drag-and-drop reordering
|
||||
- Individual remove buttons
|
||||
- Bulk upload progress
|
||||
|
||||
### 2. MediaLibraryModal Component (Secondary)
|
||||
|
||||
**Purpose**: Main modal component that wraps the media browser functionality
|
||||
|
||||
**Props Interface**:
|
||||
```typescript
|
||||
interface MediaLibraryModalProps {
|
||||
isOpen: boolean
|
||||
mode: 'single' | 'multiple'
|
||||
fileType?: 'image' | 'video' | 'all'
|
||||
onSelect: (media: Media | Media[]) => void
|
||||
onClose: () => void
|
||||
selectedIds?: number[] // Pre-selected items
|
||||
title?: string // Modal title
|
||||
confirmText?: string // Confirm button text
|
||||
}
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Modal overlay with proper focus management
|
||||
- Header with title and close button
|
||||
- Media browser grid with selection indicators
|
||||
- Search and filter controls
|
||||
- Upload area with drag-and-drop
|
||||
- Footer with selection count and action buttons
|
||||
- Responsive design (desktop and tablet)
|
||||
|
||||
### 2. MediaSelector Component
|
||||
|
||||
**Purpose**: The actual media browsing interface within the modal
|
||||
|
||||
**Features**:
|
||||
- Grid layout with thumbnail previews
|
||||
- Individual item selection with visual feedback
|
||||
- Keyboard navigation support
|
||||
- Loading states and error handling
|
||||
- "Select All" / "Clear Selection" bulk actions (for multiple mode)
|
||||
|
||||
**Item Display**:
|
||||
- Thumbnail image
|
||||
- Filename (truncated)
|
||||
- File size and dimensions
|
||||
- Usage indicator (if used elsewhere)
|
||||
- Selection checkbox/indicator
|
||||
|
||||
### 3. MediaUploader Component
|
||||
|
||||
**Purpose**: Handle file uploads within the modal
|
||||
|
||||
**Features**:
|
||||
- Drag-and-drop upload zone
|
||||
- Click to browse files
|
||||
- Upload progress indicators
|
||||
- Error handling and validation
|
||||
- Multiple file upload support
|
||||
- Automatic refresh of media grid after upload
|
||||
|
||||
**Validation**:
|
||||
- File type restrictions based on context
|
||||
- File size limits (10MB per file)
|
||||
- Maximum number of files for bulk upload
|
||||
|
||||
### 4. Form Integration Components
|
||||
|
||||
#### MediaInput Component
|
||||
**Purpose**: Generic input field that opens media library modal
|
||||
|
||||
```typescript
|
||||
interface MediaInputProps {
|
||||
label: string
|
||||
value?: Media | Media[] | null
|
||||
mode: 'single' | 'multiple'
|
||||
fileType?: 'image' | 'video' | 'all'
|
||||
onSelect: (media: Media | Media[] | null) => void
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
error?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Display**:
|
||||
- Label and optional required indicator
|
||||
- Preview of selected media (thumbnail + filename)
|
||||
- "Browse" button to open modal
|
||||
- "Clear" button to remove selection
|
||||
- Error state display
|
||||
|
||||
#### ImagePicker Component
|
||||
**Purpose**: Specialized single image selector with enhanced preview
|
||||
|
||||
```typescript
|
||||
interface ImagePickerProps {
|
||||
label: string
|
||||
value?: Media | null
|
||||
onSelect: (media: Media | null) => void
|
||||
aspectRatio?: string // e.g., "16:9", "1:1"
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
error?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Display**:
|
||||
- Large preview area with placeholder
|
||||
- Image preview with proper aspect ratio
|
||||
- Overlay with "Change" and "Remove" buttons on hover
|
||||
- Upload progress indicator
|
||||
|
||||
#### GalleryManager Component
|
||||
**Purpose**: Multiple image selection with drag-and-drop reordering
|
||||
|
||||
```typescript
|
||||
interface GalleryManagerProps {
|
||||
label: string
|
||||
value?: Media[]
|
||||
onSelect: (media: Media[]) => void
|
||||
onReorder?: (media: Media[]) => void
|
||||
maxItems?: number
|
||||
required?: boolean
|
||||
error?: string
|
||||
}
|
||||
```
|
||||
|
||||
**Display**:
|
||||
- Grid of selected images with reorder handles
|
||||
- "Add Images" button to open modal
|
||||
- Individual remove buttons on each image
|
||||
- Drag-and-drop reordering with visual feedback
|
||||
|
||||
## User Experience Flows
|
||||
|
||||
### 🥇 Primary Flow: Direct Upload in Forms
|
||||
|
||||
#### 1. Single Image Upload (Project Featured Image)
|
||||
1. **User creates/edits project** and reaches featured image field
|
||||
2. **User drags image file** directly onto ImageUploader component OR clicks to browse
|
||||
3. **File is immediately uploaded** with progress indicator
|
||||
4. **Image preview appears** with proper aspect ratio
|
||||
5. **Alt text input field appears** below preview (if enabled)
|
||||
6. **User enters alt text** for accessibility
|
||||
7. **Form can be saved** with media reference and metadata
|
||||
|
||||
#### 2. Multiple Image Upload (Project Gallery)
|
||||
1. **User reaches gallery section** of project form
|
||||
2. **User drags multiple files** onto GalleryUploader OR clicks to browse multiple
|
||||
3. **Upload progress shown** for each file individually
|
||||
4. **Gallery grid appears** with all uploaded images
|
||||
5. **Alt text inputs available** for each image
|
||||
6. **User can reorder** images with drag-and-drop
|
||||
7. **User can remove** individual images with X button
|
||||
8. **Form saves** with complete gallery and metadata
|
||||
|
||||
#### 3. Media Management and Alt Text Editing
|
||||
1. **User visits Media Library page** to manage uploaded content
|
||||
2. **User clicks on any media item** to open details modal
|
||||
3. **User can edit alt text** and other metadata
|
||||
4. **User can see usage references** (which projects/posts use this media)
|
||||
5. **Changes are saved** and reflected wherever media is used
|
||||
|
||||
### 🥈 Secondary Flow: Browse Existing Media
|
||||
|
||||
#### 1. Selecting Previously Uploaded Image
|
||||
1. **User clicks "Browse Library"** button (secondary option in forms)
|
||||
2. **MediaLibraryModal opens** showing all previously uploaded media
|
||||
3. **User browses or searches** existing content
|
||||
4. **User selects image** and confirms selection
|
||||
5. **Modal closes** and form shows selected media with existing alt text
|
||||
|
||||
#### 2. Managing Media Library
|
||||
1. **User visits dedicated Media Library page**
|
||||
2. **User can view all uploaded media** in grid/list format
|
||||
3. **User can edit metadata** including alt text for any media
|
||||
4. **User can see usage tracking** - which content references each media
|
||||
5. **User can perform bulk operations** like deleting unused media
|
||||
|
||||
## Design Specifications
|
||||
|
||||
### Modal Layout
|
||||
- **Width**: 1200px max, responsive on smaller screens
|
||||
- **Height**: 80vh max with scroll
|
||||
- **Grid**: 4-6 columns depending on screen size
|
||||
- **Item Size**: 180px × 140px thumbnails
|
||||
|
||||
### Visual States
|
||||
- **Default**: Border with subtle background
|
||||
- **Selected**: Blue border and checkmark overlay
|
||||
- **Hover**: Slight scale and shadow effect
|
||||
- **Loading**: Skeleton loader animation
|
||||
- **Upload**: Progress overlay with percentage
|
||||
|
||||
### Colors (Using Existing Variables)
|
||||
- **Selection**: `$blue-60` for selected state
|
||||
- **Hover**: `$grey-10` background
|
||||
- **Upload Progress**: `$green-60` for success, `$red-60` for error
|
||||
|
||||
## API Integration
|
||||
|
||||
### Endpoints Used
|
||||
- `GET /api/media` - Browse media with search/filter/pagination
|
||||
- `POST /api/media/upload` - Single file upload
|
||||
- `POST /api/media/bulk-upload` - Multiple file upload
|
||||
|
||||
### Search and Filtering
|
||||
- **Search**: By filename (case-insensitive)
|
||||
- **Filter by Type**: image/*, video/*, all
|
||||
- **Filter by Usage**: unused only, all
|
||||
- **Sort**: Most recent first
|
||||
|
||||
### Pagination
|
||||
- 24 items per page
|
||||
- Infinite scroll or traditional pagination
|
||||
- Loading states during page changes
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Database Schema Updates (Required First)
|
||||
1. **Add Alt Text Support**
|
||||
```sql
|
||||
ALTER TABLE media ADD COLUMN alt_text TEXT;
|
||||
ALTER TABLE media ADD COLUMN description TEXT;
|
||||
```
|
||||
|
||||
2. **Add Usage Tracking (Optional)**
|
||||
```sql
|
||||
-- Track where media is referenced
|
||||
CREATE TABLE media_usage (
|
||||
id SERIAL PRIMARY KEY,
|
||||
media_id INTEGER REFERENCES media(id),
|
||||
content_type VARCHAR(50), -- 'project', 'post', 'album'
|
||||
content_id INTEGER,
|
||||
field_name VARCHAR(100), -- 'featured_image', 'gallery', etc.
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### Phase 2: Direct Upload Components (High Priority)
|
||||
1. **ImageUploader Component**
|
||||
- Drag-and-drop upload zone with visual feedback
|
||||
- Immediate upload and preview functionality
|
||||
- Alt text input integration
|
||||
- Replace existing ImagePicker with upload-first approach
|
||||
|
||||
2. **GalleryUploader Component**
|
||||
- Multiple file drag-and-drop
|
||||
- Individual alt text inputs per image
|
||||
- Drag-and-drop reordering
|
||||
- Remove individual images functionality
|
||||
|
||||
3. **Upload API Enhancement**
|
||||
- Accept alt text in upload request
|
||||
- Return complete media object with metadata
|
||||
- Handle batch uploads with individual alt text
|
||||
|
||||
### Phase 3: Form Integration (High Priority)
|
||||
1. **Project Forms Enhancement**
|
||||
- Replace logo field with ImageUploader
|
||||
- Add featured image with ImageUploader
|
||||
- Implement gallery section with GalleryUploader
|
||||
- Add secondary "Browse Library" buttons
|
||||
|
||||
2. **Post Forms Enhancement**
|
||||
- Photo post type with GalleryUploader
|
||||
- Album creation with GalleryUploader
|
||||
- Featured image selection for text posts
|
||||
|
||||
### Phase 4: Media Library Management (Medium Priority)
|
||||
1. **Enhanced Media Library Page**
|
||||
- Alt text editing for existing media
|
||||
- Usage tracking display (shows where media is used)
|
||||
- Bulk alt text editing
|
||||
- Search and filter by alt text
|
||||
|
||||
2. **MediaLibraryModal for Selection**
|
||||
- Browse existing media interface
|
||||
- Single and multiple selection modes
|
||||
- Integration as secondary option in forms
|
||||
|
||||
### Phase 5: Polish and Advanced Features (Low Priority)
|
||||
1. **Advanced Upload Features**
|
||||
- Image resizing/optimization options
|
||||
- Automatic alt text suggestions (AI integration)
|
||||
- Bulk upload with CSV metadata import
|
||||
|
||||
2. **Usage Analytics**
|
||||
- Dashboard showing media usage statistics
|
||||
- Unused media cleanup tools
|
||||
- Duplicate detection and management
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
#### Primary Workflow (Direct Upload)
|
||||
- [ ] **Drag-and-drop upload works** in all form components
|
||||
- [ ] **Click-to-browse file selection** works reliably
|
||||
- [ ] **Immediate upload and preview** happens without page navigation
|
||||
- [ ] **Alt text input appears** and saves with uploaded media
|
||||
- [ ] **Upload progress** is clearly indicated with percentage
|
||||
- [ ] **Error handling** provides helpful feedback for failed uploads
|
||||
- [ ] **Multiple file upload** works with individual progress tracking
|
||||
- [ ] **Gallery reordering** works with drag-and-drop after upload
|
||||
|
||||
#### Secondary Workflow (Media Library)
|
||||
- [ ] **Media Library Modal** opens and closes properly with smooth animations
|
||||
- [ ] **Single and multiple selection** modes work correctly
|
||||
- [ ] **Search and filtering** return accurate results
|
||||
- [ ] **Usage tracking** shows where media is referenced
|
||||
- [ ] **Alt text editing** works in Media Library management
|
||||
- [ ] **All components are keyboard accessible**
|
||||
|
||||
### Performance Requirements
|
||||
- [ ] Modal opens in under 200ms
|
||||
- [ ] Media grid loads in under 1 second
|
||||
- [ ] Search results appear in under 500ms
|
||||
- [ ] Upload progress updates in real-time
|
||||
- [ ] No memory leaks when opening/closing modal multiple times
|
||||
|
||||
### UX Requirements
|
||||
- [ ] Interface is intuitive without instruction
|
||||
- [ ] Visual feedback is clear for all interactions
|
||||
- [ ] Error messages are helpful and actionable
|
||||
- [ ] Mobile/tablet interface is fully functional
|
||||
- [ ] Loading states prevent user confusion
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### State Management
|
||||
- Use Svelte runes for reactive state
|
||||
- Maintain selection state during modal lifecycle
|
||||
- Handle API loading and error states properly
|
||||
|
||||
### Accessibility
|
||||
- Proper ARIA labels and roles
|
||||
- Keyboard navigation support
|
||||
- Focus management when modal opens/closes
|
||||
- Screen reader announcements for state changes
|
||||
|
||||
### Performance
|
||||
- Lazy load thumbnails as they come into view
|
||||
- Debounce search input to prevent excessive API calls
|
||||
- Efficient reordering without full re-renders
|
||||
- Memory cleanup when modal is closed
|
||||
|
||||
### Error Handling
|
||||
- Network failure recovery
|
||||
- Upload failure feedback
|
||||
- File validation error messages
|
||||
- Graceful degradation for missing thumbnails
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Nice-to-Have Features
|
||||
- **Bulk Operations**: Delete multiple files, bulk tag editing
|
||||
- **Advanced Search**: Search by tags, date range, file size
|
||||
- **Preview Mode**: Full-size preview with navigation
|
||||
- **Folder Organization**: Create folders/categories for organization
|
||||
- **Smart Suggestions**: Recently used, similar images
|
||||
- **Crop Tool**: Basic cropping interface within modal
|
||||
- **Alt Text Editor**: Quick alt text editing for accessibility
|
||||
|
||||
### Integration Opportunities
|
||||
- **CDN Optimization**: Automatic image optimization settings
|
||||
- **AI Tagging**: Automatic tag generation for uploaded images
|
||||
- **Duplicate Detection**: Warn about similar/duplicate uploads
|
||||
- **Usage Analytics**: Track which media is used most frequently
|
||||
|
||||
## Development Checklist
|
||||
|
||||
### Core Components
|
||||
- [ ] MediaLibraryModal base structure
|
||||
- [ ] MediaSelector with grid layout
|
||||
- [ ] MediaUploader with drag-and-drop
|
||||
- [ ] Search and filter interface
|
||||
- [ ] Pagination implementation
|
||||
|
||||
### Form Integration
|
||||
- [ ] MediaInput generic component
|
||||
- [ ] ImagePicker specialized component
|
||||
- [ ] GalleryManager with reordering
|
||||
- [ ] Integration with existing project forms
|
||||
- [ ] Integration with post forms
|
||||
|
||||
### Polish and Testing
|
||||
- [ ] Responsive design implementation
|
||||
- [ ] Accessibility testing and fixes
|
||||
- [ ] Performance optimization
|
||||
- [ ] Error state handling
|
||||
- [ ] Cross-browser testing
|
||||
- [ ] Mobile device testing
|
||||
|
||||
This Media Library system will serve as the foundation for all media-related functionality in the CMS, enabling rich content creation across projects, posts, and albums.
|
||||
397
PRD-storybook-integration.md
Normal file
397
PRD-storybook-integration.md
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
# Product Requirements Document: Storybook Integration
|
||||
|
||||
## Overview
|
||||
|
||||
Implement Storybook as our component development and documentation platform to improve development workflow, component testing, and design system consistency across the jedmund-svelte project.
|
||||
|
||||
## Goals
|
||||
|
||||
- **Isolated Component Development**: Build and test components in isolation from business logic
|
||||
- **Visual Documentation**: Create a living style guide for all UI components
|
||||
- **Design System Consistency**: Ensure consistent component behavior across different states
|
||||
- **Developer Experience**: Improve development workflow with hot reloading and component playground
|
||||
- **Quality Assurance**: Test component edge cases and various prop combinations
|
||||
- **Team Collaboration**: Provide a central place for designers and developers to review components
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### ✅ What We Have
|
||||
- Comprehensive admin UI component library (Button, Input, Modal, etc.)
|
||||
- Media Library components (MediaLibraryModal, ImagePicker, GalleryManager, etc.)
|
||||
- SCSS-based styling system with global variables
|
||||
- SvelteKit project with Svelte 5 runes mode
|
||||
- TypeScript configuration
|
||||
- Vite build system
|
||||
|
||||
### 🎯 What We Need
|
||||
- Storybook installation and configuration
|
||||
- Stories for existing components
|
||||
- Visual regression testing setup
|
||||
- Component documentation standards
|
||||
- Integration with existing SCSS variables and themes
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### 1. Storybook Installation
|
||||
|
||||
**Installation Method**: Manual setup (not template-based since we have an existing project)
|
||||
|
||||
```bash
|
||||
# Install Storybook CLI and initialize
|
||||
npx storybook@latest init
|
||||
|
||||
# Or manual installation for better control
|
||||
npm install --save-dev @storybook/svelte-vite @storybook/addon-essentials
|
||||
```
|
||||
|
||||
**Expected File Structure**:
|
||||
```
|
||||
.storybook/
|
||||
├── main.js # Storybook configuration
|
||||
├── preview.js # Global decorators and parameters
|
||||
└── manager.js # Storybook UI customization
|
||||
|
||||
src/
|
||||
├── stories/ # Component stories
|
||||
│ ├── Button.stories.js
|
||||
│ ├── Input.stories.js
|
||||
│ └── ...
|
||||
└── components/ # Existing components
|
||||
```
|
||||
|
||||
### 2. Configuration Requirements
|
||||
|
||||
#### Main Configuration (.storybook/main.js)
|
||||
```javascript
|
||||
export default {
|
||||
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|svelte)'],
|
||||
addons: [
|
||||
'@storybook/addon-essentials', // Controls, actions, viewport, etc.
|
||||
'@storybook/addon-svelte-csf', // Svelte Component Story Format
|
||||
'@storybook/addon-a11y', // Accessibility testing
|
||||
'@storybook/addon-design-tokens', // Design system tokens
|
||||
],
|
||||
framework: {
|
||||
name: '@storybook/svelte-vite',
|
||||
options: {}
|
||||
},
|
||||
viteFinal: async (config) => {
|
||||
// Integrate with existing Vite config
|
||||
// Import SCSS variables and aliases
|
||||
return mergeConfig(config, {
|
||||
resolve: {
|
||||
alias: {
|
||||
'$lib': path.resolve('./src/lib'),
|
||||
'$components': path.resolve('./src/lib/components'),
|
||||
'$icons': path.resolve('./src/assets/icons'),
|
||||
'$illos': path.resolve('./src/assets/illos'),
|
||||
}
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `
|
||||
@import './src/assets/styles/variables.scss';
|
||||
@import './src/assets/styles/fonts.scss';
|
||||
@import './src/assets/styles/themes.scss';
|
||||
@import './src/assets/styles/globals.scss';
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### Preview Configuration (.storybook/preview.js)
|
||||
```javascript
|
||||
import '../src/assets/styles/reset.css';
|
||||
import '../src/assets/styles/globals.scss';
|
||||
|
||||
export const parameters = {
|
||||
actions: { argTypesRegex: '^on[A-Z].*' },
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/,
|
||||
},
|
||||
},
|
||||
backgrounds: {
|
||||
default: 'light',
|
||||
values: [
|
||||
{ name: 'light', value: '#ffffff' },
|
||||
{ name: 'dark', value: '#333333' },
|
||||
{ name: 'admin', value: '#f5f5f5' },
|
||||
],
|
||||
},
|
||||
viewport: {
|
||||
viewports: {
|
||||
mobile: { name: 'Mobile', styles: { width: '375px', height: '667px' } },
|
||||
tablet: { name: 'Tablet', styles: { width: '768px', height: '1024px' } },
|
||||
desktop: { name: 'Desktop', styles: { width: '1440px', height: '900px' } },
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Component Story Standards
|
||||
|
||||
#### Story File Format
|
||||
Each component should have a corresponding `.stories.js` file following this structure:
|
||||
|
||||
```javascript
|
||||
// Button.stories.js
|
||||
import Button from '../lib/components/admin/Button.svelte';
|
||||
|
||||
export default {
|
||||
title: 'Admin/Button',
|
||||
component: Button,
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: 'select' },
|
||||
options: ['primary', 'secondary', 'ghost', 'danger']
|
||||
},
|
||||
size: {
|
||||
control: { type: 'select' },
|
||||
options: ['small', 'medium', 'large']
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean'
|
||||
},
|
||||
onclick: { action: 'clicked' }
|
||||
}
|
||||
};
|
||||
|
||||
export const Primary = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
children: 'Primary Button'
|
||||
}
|
||||
};
|
||||
|
||||
export const Secondary = {
|
||||
args: {
|
||||
variant: 'secondary',
|
||||
children: 'Secondary Button'
|
||||
}
|
||||
};
|
||||
|
||||
export const AllVariants = {
|
||||
render: () => ({
|
||||
Component: ButtonShowcase,
|
||||
props: {}
|
||||
})
|
||||
};
|
||||
```
|
||||
|
||||
#### Story Organization
|
||||
```
|
||||
src/stories/
|
||||
├── admin/ # Admin interface components
|
||||
│ ├── Button.stories.js
|
||||
│ ├── Input.stories.js
|
||||
│ ├── Modal.stories.js
|
||||
│ └── forms/ # Form-specific components
|
||||
│ ├── MediaInput.stories.js
|
||||
│ ├── ImagePicker.stories.js
|
||||
│ └── GalleryManager.stories.js
|
||||
├── public/ # Public-facing components
|
||||
│ ├── Header.stories.js
|
||||
│ └── Footer.stories.js
|
||||
└── examples/ # Complex examples and compositions
|
||||
├── AdminDashboard.stories.js
|
||||
└── MediaLibraryFlow.stories.js
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Initial Setup (1-2 days)
|
||||
1. **Install and Configure Storybook**
|
||||
- Run `npx storybook@latest init`
|
||||
- Configure Vite integration for SCSS and aliases
|
||||
- Set up TypeScript support
|
||||
- Configure preview with global styles
|
||||
|
||||
2. **Test Basic Setup**
|
||||
- Create simple Button story
|
||||
- Verify SCSS variables work
|
||||
- Test hot reloading
|
||||
|
||||
### Phase 2: Core Component Stories (3-4 days)
|
||||
1. **Basic UI Components**
|
||||
- Button (all variants, states, sizes)
|
||||
- Input (text, textarea, validation states)
|
||||
- Modal (different sizes, content types)
|
||||
- LoadingSpinner (different sizes)
|
||||
|
||||
2. **Form Components**
|
||||
- MediaInput (single/multiple modes)
|
||||
- ImagePicker (different aspect ratios)
|
||||
- GalleryManager (with/without items)
|
||||
|
||||
3. **Complex Components**
|
||||
- MediaLibraryModal (with mock data)
|
||||
- DataTable (with sample data)
|
||||
- AdminNavBar (active states)
|
||||
|
||||
### Phase 3: Advanced Features (2-3 days)
|
||||
1. **Mock Data Setup**
|
||||
- Create mock Media objects
|
||||
- Set up API mocking for components that need data
|
||||
- Create realistic test scenarios
|
||||
|
||||
2. **Accessibility Testing**
|
||||
- Add @storybook/addon-a11y
|
||||
- Test keyboard navigation
|
||||
- Verify screen reader compatibility
|
||||
|
||||
3. **Visual Regression Testing**
|
||||
- Set up Chromatic (optional)
|
||||
- Create baseline screenshots
|
||||
- Configure CI integration
|
||||
|
||||
### Phase 4: Documentation and Polish (1-2 days)
|
||||
1. **Component Documentation**
|
||||
- Add JSDoc comments to components
|
||||
- Create usage examples
|
||||
- Document props and events
|
||||
|
||||
2. **Design System Documentation**
|
||||
- Color palette showcase
|
||||
- Typography scale
|
||||
- Spacing system
|
||||
- Icon library
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Functional Requirements
|
||||
- [ ] Storybook runs successfully with `npm run storybook`
|
||||
- [ ] All existing components have basic stories
|
||||
- [ ] SCSS variables and global styles work correctly
|
||||
- [ ] Components render properly in isolation
|
||||
- [ ] Hot reloading works for both component and story changes
|
||||
- [ ] TypeScript support is fully functional
|
||||
|
||||
### Quality Requirements
|
||||
- [ ] Stories cover all major component variants
|
||||
- [ ] Interactive controls work for all props
|
||||
- [ ] Actions are properly logged for events
|
||||
- [ ] Accessibility addon reports no critical issues
|
||||
- [ ] Components are responsive across viewport sizes
|
||||
|
||||
### Developer Experience Requirements
|
||||
- [ ] Story creation is straightforward and documented
|
||||
- [ ] Mock data is easily accessible and realistic
|
||||
- [ ] Component API is clearly documented
|
||||
- [ ] Common patterns have reusable templates
|
||||
|
||||
## Integration with Existing Workflow
|
||||
|
||||
### Development Workflow
|
||||
1. **Component Development**: Start new components in Storybook
|
||||
2. **Testing**: Test all states and edge cases in stories
|
||||
3. **Documentation**: Stories serve as living documentation
|
||||
4. **Review**: Use Storybook for design/code reviews
|
||||
|
||||
### Project Structure Integration
|
||||
```
|
||||
package.json # Add storybook scripts
|
||||
├── "storybook": "storybook dev -p 6006"
|
||||
├── "build-storybook": "storybook build"
|
||||
|
||||
.storybook/ # Storybook configuration
|
||||
src/
|
||||
├── lib/components/ # Existing components (unchanged)
|
||||
├── stories/ # New: component stories
|
||||
└── assets/styles/ # Existing styles (used by Storybook)
|
||||
```
|
||||
|
||||
### Scripts and Commands
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
"storybook:test": "test-storybook"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### SCSS Integration
|
||||
- Import global variables in Storybook preview
|
||||
- Ensure component styles render correctly
|
||||
- Test responsive breakpoints
|
||||
|
||||
### SvelteKit Compatibility
|
||||
- Handle SvelteKit-specific imports (like `$app/stores`)
|
||||
- Mock SvelteKit modules when needed
|
||||
- Ensure aliases work in Storybook context
|
||||
|
||||
### TypeScript Support
|
||||
- Configure proper type checking
|
||||
- Use TypeScript for story definitions where beneficial
|
||||
- Ensure IntelliSense works for story arguments
|
||||
|
||||
### Performance
|
||||
- Optimize bundle size for faster story loading
|
||||
- Use lazy loading for large story collections
|
||||
- Configure appropriate caching
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Advanced Testing
|
||||
- **Visual Regression Testing**: Use Chromatic for automated visual testing
|
||||
- **Interaction Testing**: Add @storybook/addon-interactions for user flow testing
|
||||
- **Accessibility Automation**: Automated a11y testing in CI/CD
|
||||
|
||||
### Design System Evolution
|
||||
- **Design Tokens**: Implement design tokens addon
|
||||
- **Figma Integration**: Connect with Figma designs
|
||||
- **Component Status**: Track component implementation status
|
||||
|
||||
### Collaboration Features
|
||||
- **Published Storybook**: Deploy Storybook for team access
|
||||
- **Design Review Process**: Use Storybook for design approvals
|
||||
- **Documentation Site**: Evolve into full design system documentation
|
||||
|
||||
## Risks and Mitigation
|
||||
|
||||
### Technical Risks
|
||||
- **Build Conflicts**: Vite configuration conflicts
|
||||
- *Mitigation*: Careful configuration merging and testing
|
||||
- **SCSS Import Issues**: Global styles not loading
|
||||
- *Mitigation*: Test SCSS integration early in setup
|
||||
|
||||
### Workflow Risks
|
||||
- **Adoption Resistance**: Team not using Storybook
|
||||
- *Mitigation*: Start with high-value components, show immediate benefits
|
||||
- **Maintenance Overhead**: Stories become outdated
|
||||
- *Mitigation*: Include story updates in component change process
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Development Efficiency
|
||||
- Reduced time to develop new components
|
||||
- Faster iteration on component designs
|
||||
- Fewer bugs in component edge cases
|
||||
|
||||
### Code Quality
|
||||
- Better component API consistency
|
||||
- Improved accessibility compliance
|
||||
- More comprehensive component testing
|
||||
|
||||
### Team Collaboration
|
||||
- Faster design review cycles
|
||||
- Better communication between design and development
|
||||
- More consistent component usage across the application
|
||||
|
||||
## Conclusion
|
||||
|
||||
Implementing Storybook will significantly improve our component development workflow, provide better documentation, and create a foundation for a mature design system. The investment in setup and story creation will pay dividends in development speed, component quality, and team collaboration.
|
||||
|
||||
The implementation should be done incrementally, starting with the most commonly used components and gradually expanding coverage. This approach minimizes risk while providing immediate value to the development process.
|
||||
367
src/lib/components/admin/AlbumForm.svelte
Normal file
367
src/lib/components/admin/AlbumForm.svelte
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import AdminPage from './AdminPage.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import GalleryUploader from './GalleryUploader.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
postId?: number
|
||||
initialData?: {
|
||||
title?: string
|
||||
content?: JSONContent
|
||||
gallery?: Media[]
|
||||
status: 'draft' | 'published'
|
||||
tags?: string[]
|
||||
}
|
||||
mode: 'create' | 'edit'
|
||||
}
|
||||
|
||||
let { postId, initialData, mode }: Props = $props()
|
||||
|
||||
// State
|
||||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
|
||||
|
||||
// Form data
|
||||
let title = $state(initialData?.title || '')
|
||||
let content = $state<JSONContent>({ type: 'doc', content: [] })
|
||||
let gallery = $state<Media[]>([])
|
||||
let tags = $state(initialData?.tags?.join(', ') || '')
|
||||
|
||||
// Editor ref
|
||||
let editorRef: any
|
||||
|
||||
// Initialize data for edit mode
|
||||
$effect(() => {
|
||||
if (initialData && mode === 'edit') {
|
||||
// Parse album content structure
|
||||
if (initialData.content && typeof initialData.content === 'object' && 'type' in initialData.content) {
|
||||
const albumContent = initialData.content as any
|
||||
if (albumContent.type === 'album') {
|
||||
// Album content structure: { type: 'album', gallery: [mediaIds], description: JSONContent }
|
||||
if (albumContent.gallery) {
|
||||
// Load media objects from IDs (we'll need to fetch these)
|
||||
loadGalleryMedia(albumContent.gallery)
|
||||
}
|
||||
if (albumContent.description) {
|
||||
content = albumContent.description
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to regular content
|
||||
content = initialData.content || { type: 'doc', content: [] }
|
||||
}
|
||||
|
||||
// Load gallery from initialData if provided directly
|
||||
if (initialData.gallery) {
|
||||
gallery = initialData.gallery
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function loadGalleryMedia(mediaIds: number[]) {
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) return
|
||||
|
||||
const mediaPromises = mediaIds.map(async (id) => {
|
||||
const response = await fetch(`/api/media/${id}`, {
|
||||
headers: { Authorization: `Basic ${auth}` }
|
||||
})
|
||||
if (response.ok) {
|
||||
return await response.json()
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const mediaResults = await Promise.all(mediaPromises)
|
||||
gallery = mediaResults.filter(media => media !== null)
|
||||
} catch (error) {
|
||||
console.error('Failed to load gallery media:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Validation
|
||||
let isValid = $derived(title.trim().length > 0 && gallery.length > 0)
|
||||
|
||||
function handleGalleryUpload(newMedia: Media[]) {
|
||||
gallery = [...gallery, ...newMedia]
|
||||
}
|
||||
|
||||
function handleGalleryReorder(reorderedMedia: Media[]) {
|
||||
gallery = reorderedMedia
|
||||
}
|
||||
|
||||
function handleEditorChange(newContent: JSONContent) {
|
||||
content = newContent
|
||||
}
|
||||
|
||||
async function handleSave(newStatus: 'draft' | 'published' = status) {
|
||||
if (!isValid) return
|
||||
|
||||
isSaving = true
|
||||
error = ''
|
||||
|
||||
try {
|
||||
const postData = {
|
||||
title: title.trim(),
|
||||
slug: generateSlug(title),
|
||||
postType: 'album',
|
||||
status: newStatus,
|
||||
content,
|
||||
gallery: gallery.map(media => media.id),
|
||||
featuredImage: gallery.length > 0 ? gallery[0].id : undefined,
|
||||
tags: tags.trim() ? tags.split(',').map(tag => tag.trim()) : []
|
||||
}
|
||||
|
||||
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
|
||||
const method = mode === 'edit' ? 'PUT' : 'POST'
|
||||
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${auth}`
|
||||
},
|
||||
body: JSON.stringify(postData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text()
|
||||
throw new Error(`Failed to save album: ${errorData}`)
|
||||
}
|
||||
|
||||
status = newStatus
|
||||
goto('/admin/posts')
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Failed to save album'
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (hasChanges() && !confirm('Are you sure you want to cancel? Your changes will be lost.')) {
|
||||
return
|
||||
}
|
||||
goto('/admin/posts')
|
||||
}
|
||||
|
||||
function hasChanges(): boolean {
|
||||
if (mode === 'create') {
|
||||
return title.trim().length > 0 || gallery.length > 0 || tags.trim().length > 0
|
||||
}
|
||||
|
||||
// For edit mode, compare with initial data
|
||||
return (
|
||||
title !== (initialData?.title || '') ||
|
||||
gallery !== (initialData?.gallery || []) ||
|
||||
tags !== (initialData?.tags?.join(', ') || '')
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<div class="header-left">
|
||||
<button class="btn-icon" onclick={handleCancel}>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path
|
||||
d="M12.5 15L7.5 10L12.5 5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<h1>📸 {mode === 'create' ? 'New Album' : 'Edit Album'}</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{#if mode === 'create'}
|
||||
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="secondary" onclick={() => handleSave('draft')} disabled={!isValid || isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Draft'}
|
||||
</Button>
|
||||
<Button variant="primary" onclick={() => handleSave('published')} disabled={!isValid || isSaving}>
|
||||
{isSaving ? 'Publishing...' : 'Publish Album'}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button variant="secondary" onclick={handleCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onclick={() => handleSave()} disabled={!isValid || isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="album-form">
|
||||
{#if error}
|
||||
<div class="error-message">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-content">
|
||||
<div class="form-section">
|
||||
<Input
|
||||
label="Album Title"
|
||||
bind:value={title}
|
||||
placeholder="Enter album title"
|
||||
required={true}
|
||||
error={title.trim().length === 0 ? 'Title is required' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<GalleryUploader
|
||||
label="Album Photos"
|
||||
bind:value={gallery}
|
||||
onUpload={handleGalleryUpload}
|
||||
onReorder={handleGalleryReorder}
|
||||
required={true}
|
||||
showBrowseLibrary={true}
|
||||
maxItems={50}
|
||||
placeholder="Add photos to your album"
|
||||
helpText="First photo will be used as the album cover"
|
||||
error={gallery.length === 0 ? 'At least one photo is required' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<div class="editor-wrapper">
|
||||
<label class="form-label">Description</label>
|
||||
<Editor
|
||||
bind:this={editorRef}
|
||||
bind:data={content}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="Write a description for your album..."
|
||||
simpleMode={false}
|
||||
minHeight={200}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<Input
|
||||
label="Tags"
|
||||
bind:value={tags}
|
||||
placeholder="travel, photography, nature"
|
||||
helpText="Separate tags with commas"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $unit-2x;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: $grey-40;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-90;
|
||||
color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
.album-form {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: $unit-3x;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 8px;
|
||||
padding: $unit-2x;
|
||||
margin-bottom: $unit-3x;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-4x;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: $grey-20;
|
||||
margin-bottom: $unit;
|
||||
}
|
||||
}
|
||||
|
||||
@include breakpoint('phone') {
|
||||
.album-form {
|
||||
padding: $unit-2x;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-wrap: wrap;
|
||||
gap: $unit;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -34,6 +34,10 @@
|
|||
import { IFramePlaceholder } from '$lib/components/edra/extensions/iframe/IFramePlaceholder.js'
|
||||
import { IFrameExtended } from '$lib/components/edra/extensions/iframe/IFrameExtended.js'
|
||||
import IFrameExtendedComponent from '$lib/components/edra/headless/components/IFrameExtended.svelte'
|
||||
import { GalleryPlaceholder } from '$lib/components/edra/extensions/gallery/GalleryPlaceholder.js'
|
||||
import GalleryPlaceholderComponent from '$lib/components/edra/headless/components/GalleryPlaceholder.svelte'
|
||||
import { GalleryExtended } from '$lib/components/edra/extensions/gallery/GalleryExtended.js'
|
||||
import GalleryExtendedComponent from '$lib/components/edra/headless/components/GalleryExtended.svelte'
|
||||
|
||||
// Import Edra styles
|
||||
import '$lib/components/edra/headless/style.css'
|
||||
|
|
@ -296,11 +300,13 @@
|
|||
}),
|
||||
AudioPlaceholder(AudioPlaceholderComponent),
|
||||
ImagePlaceholder(ImageUploadPlaceholder), // Use our custom component
|
||||
GalleryPlaceholder(GalleryPlaceholderComponent),
|
||||
IFramePlaceholder(IFramePlaceholderComponent),
|
||||
IFrameExtended(IFrameExtendedComponent),
|
||||
VideoPlaceholder(VideoPlaceholderComponent),
|
||||
AudioExtended(AudioExtendedComponent),
|
||||
ImageExtended(ImageExtendedComponent),
|
||||
GalleryExtended(GalleryExtendedComponent),
|
||||
VideoExtended(VideoExtendedComponent),
|
||||
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
|
||||
],
|
||||
|
|
@ -500,6 +506,46 @@
|
|||
</svg>
|
||||
<span>Image</span>
|
||||
</button>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
editor?.chain().focus().insertGalleryPlaceholder().run()
|
||||
showMediaDropdown = false
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
|
||||
<rect
|
||||
x="2"
|
||||
y="4"
|
||||
width="12"
|
||||
height="9"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
<rect
|
||||
x="6"
|
||||
y="7"
|
||||
width="12"
|
||||
height="9"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
rx="1"
|
||||
/>
|
||||
<circle cx="6.5" cy="9.5" r="1" stroke="currentColor" stroke-width="1.5" fill="none" />
|
||||
<path
|
||||
d="M6 12L8 10L10 12L12 10L15 13"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
<span>Gallery</span>
|
||||
</button>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
onclick={() => {
|
||||
|
|
|
|||
|
|
@ -1,32 +1,55 @@
|
|||
<script lang="ts">
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
import type { Media } from '@prisma/client'
|
||||
import Image from 'lucide-svelte/icons/image'
|
||||
import Upload from 'lucide-svelte/icons/upload'
|
||||
import Link from 'lucide-svelte/icons/link'
|
||||
import Grid from 'lucide-svelte/icons/grid-3x3'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||
|
||||
const { editor }: NodeViewProps = $props()
|
||||
const { editor, deleteNode }: NodeViewProps = $props()
|
||||
|
||||
let fileInput: HTMLInputElement
|
||||
let isDragging = $state(false)
|
||||
let isMediaLibraryOpen = $state(false)
|
||||
let isUploading = $state(false)
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
function handleBrowseLibrary(e: MouseEvent) {
|
||||
if (!editor.isEditable) return
|
||||
e.preventDefault()
|
||||
isMediaLibraryOpen = true
|
||||
}
|
||||
|
||||
// Show options: upload file or enter URL
|
||||
const choice = confirm('Click OK to upload a file, or Cancel to enter a URL')
|
||||
function handleDirectUpload(e: MouseEvent) {
|
||||
if (!editor.isEditable) return
|
||||
e.preventDefault()
|
||||
fileInput.click()
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
function handleMediaSelect(media: Media | Media[]) {
|
||||
const selectedMedia = Array.isArray(media) ? media[0] : media
|
||||
if (selectedMedia) {
|
||||
// Set a reasonable default width (max 600px)
|
||||
const displayWidth = selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: selectedMedia.url,
|
||||
alt: selectedMedia.altText || '',
|
||||
width: displayWidth,
|
||||
height: selectedMedia.height,
|
||||
align: 'center'
|
||||
})
|
||||
.run()
|
||||
}
|
||||
isMediaLibraryOpen = false
|
||||
}
|
||||
|
||||
function handleMediaLibraryClose() {
|
||||
isMediaLibraryOpen = false
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: Event) {
|
||||
|
|
@ -53,6 +76,8 @@
|
|||
return
|
||||
}
|
||||
|
||||
isUploading = true
|
||||
|
||||
try {
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
|
|
@ -61,6 +86,7 @@
|
|||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', 'image')
|
||||
|
||||
const response = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
|
|
@ -84,7 +110,7 @@
|
|||
.focus()
|
||||
.setImage({
|
||||
src: media.url,
|
||||
alt: media.filename || '',
|
||||
alt: media.altText || '',
|
||||
width: displayWidth,
|
||||
height: media.height,
|
||||
align: 'center'
|
||||
|
|
@ -93,6 +119,8 @@
|
|||
} catch (error) {
|
||||
console.error('Image upload failed:', error)
|
||||
alert('Failed to upload image. Please try again.')
|
||||
} finally {
|
||||
isUploading = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +147,16 @@
|
|||
await uploadFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle keyboard navigation
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleBrowseLibrary(e as any)
|
||||
} else if (e.key === 'Escape') {
|
||||
deleteNode()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
|
||||
|
|
@ -130,38 +168,124 @@
|
|||
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>
|
||||
<div class="edra-image-placeholder-container">
|
||||
{#if isUploading}
|
||||
<div class="edra-image-placeholder-uploading">
|
||||
<div class="spinner"></div>
|
||||
<span>Uploading image...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="edra-image-placeholder-option"
|
||||
onclick={handleDirectUpload}
|
||||
onkeydown={handleKeyDown}
|
||||
tabindex="0"
|
||||
aria-label="Upload Image"
|
||||
title="Upload from device"
|
||||
>
|
||||
<Upload class="edra-image-placeholder-icon" />
|
||||
<span class="edra-image-placeholder-text">Upload Image</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="edra-image-placeholder-option"
|
||||
onclick={handleBrowseLibrary}
|
||||
onkeydown={handleKeyDown}
|
||||
tabindex="0"
|
||||
aria-label="Browse Media Library"
|
||||
title="Choose from library"
|
||||
>
|
||||
<Grid class="edra-image-placeholder-icon" />
|
||||
<span class="edra-image-placeholder-text">Browse Library</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
bind:isOpen={isMediaLibraryOpen}
|
||||
mode="single"
|
||||
fileType="image"
|
||||
onSelect={handleMediaSelect}
|
||||
onClose={handleMediaLibraryClose}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
|
||||
<style>
|
||||
.edra-media-placeholder-content {
|
||||
.edra-image-placeholder-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
border: 2px dashed #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
transition: all 0.2s ease;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.edra-media-placeholder-content.dragging {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border-color: rgb(59, 130, 246);
|
||||
.edra-image-placeholder-container:hover {
|
||||
border-color: #d1d5db;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.edra-media-placeholder-subtext {
|
||||
font-size: 0.875em;
|
||||
opacity: 0.7;
|
||||
margin-top: 0.25rem;
|
||||
.edra-image-placeholder-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.edra-image-placeholder-option:hover {
|
||||
border-color: #d1d5db;
|
||||
background: #f9fafb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.edra-image-placeholder-option:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.edra-image-placeholder-uploading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #f3f4f6;
|
||||
border-top: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
:global(.edra-image-placeholder-icon) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.edra-image-placeholder-text {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
355
src/lib/components/admin/PhotoPostForm.svelte
Normal file
355
src/lib/components/admin/PhotoPostForm.svelte
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import AdminPage from './AdminPage.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import ImageUploader from './ImageUploader.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
interface Props {
|
||||
postId?: number
|
||||
initialData?: {
|
||||
title?: string
|
||||
content?: JSONContent
|
||||
featuredImage?: string
|
||||
status: 'draft' | 'published'
|
||||
tags?: string[]
|
||||
}
|
||||
mode: 'create' | 'edit'
|
||||
}
|
||||
|
||||
let { postId, initialData, mode }: Props = $props()
|
||||
|
||||
// State
|
||||
let isSaving = $state(false)
|
||||
let error = $state('')
|
||||
let status = $state<'draft' | 'published'>(initialData?.status || 'draft')
|
||||
|
||||
// Form data
|
||||
let title = $state(initialData?.title || '')
|
||||
let content = $state<JSONContent>(initialData?.content || { type: 'doc', content: [] })
|
||||
let featuredImage = $state<Media | null>(null)
|
||||
let tags = $state(initialData?.tags?.join(', ') || '')
|
||||
|
||||
// Editor ref
|
||||
let editorRef: any
|
||||
|
||||
// Initialize featured image if editing
|
||||
$effect(() => {
|
||||
if (initialData?.featuredImage && mode === 'edit') {
|
||||
// Create a minimal Media object for display
|
||||
featuredImage = {
|
||||
id: -1,
|
||||
filename: 'photo.jpg',
|
||||
originalName: 'photo.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 0,
|
||||
url: initialData.featuredImage,
|
||||
thumbnailUrl: initialData.featuredImage,
|
||||
width: null,
|
||||
height: null,
|
||||
altText: null,
|
||||
description: null,
|
||||
usedIn: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function handleFeaturedImageUpload(media: Media) {
|
||||
featuredImage = media
|
||||
|
||||
// If no title is set, use the media filename as a starting point
|
||||
if (!title.trim() && media.originalName) {
|
||||
title = media.originalName.replace(/\.[^/.]+$/, '') // Remove file extension
|
||||
}
|
||||
}
|
||||
|
||||
function createSlug(title: string): string {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
||||
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// Validate required fields
|
||||
if (!featuredImage) {
|
||||
error = 'Please upload a photo for this post'
|
||||
return
|
||||
}
|
||||
|
||||
if (!title.trim()) {
|
||||
error = 'Please enter a title for this post'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isSaving = true
|
||||
error = ''
|
||||
|
||||
// Get editor content
|
||||
let editorContent = content
|
||||
if (editorRef) {
|
||||
const editorData = await editorRef.save()
|
||||
if (editorData) {
|
||||
editorContent = editorData
|
||||
}
|
||||
}
|
||||
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
if (!auth) {
|
||||
goto('/admin/login')
|
||||
return
|
||||
}
|
||||
|
||||
// Generate slug from title
|
||||
const slug = createSlug(title)
|
||||
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
slug,
|
||||
type: 'photo',
|
||||
status,
|
||||
content: editorContent,
|
||||
featuredImage: featuredImage.url,
|
||||
tags: tags ? tags.split(',').map(tag => tag.trim()).filter(Boolean) : [],
|
||||
excerpt: generateExcerpt(editorContent)
|
||||
}
|
||||
|
||||
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
|
||||
const method = mode === 'edit' ? 'PUT' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to ${mode === 'edit' ? 'update' : 'create'} photo post`)
|
||||
}
|
||||
|
||||
const savedPost = await response.json()
|
||||
|
||||
// Redirect to posts list or edit page
|
||||
if (mode === 'create') {
|
||||
goto(`/admin/posts/${savedPost.id}/edit`)
|
||||
} else {
|
||||
goto('/admin/posts')
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
error = `Failed to ${mode === 'edit' ? 'update' : 'create'} photo post`
|
||||
console.error(err)
|
||||
} finally {
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
|
||||
function generateExcerpt(content: JSONContent): string {
|
||||
// Extract plain text from editor content for excerpt
|
||||
if (!content?.content) return ''
|
||||
|
||||
let text = ''
|
||||
const extractText = (node: any) => {
|
||||
if (node.type === 'text') {
|
||||
text += node.text
|
||||
} else if (node.content) {
|
||||
node.content.forEach(extractText)
|
||||
}
|
||||
}
|
||||
|
||||
content.content.forEach(extractText)
|
||||
return text.substring(0, 200) + (text.length > 200 ? '...' : '')
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
status = 'published'
|
||||
await handleSave()
|
||||
}
|
||||
|
||||
async function handleDraft() {
|
||||
status = 'draft'
|
||||
await handleSave()
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminPage>
|
||||
<header slot="header">
|
||||
<div class="header-content">
|
||||
<h1>{mode === 'edit' ? 'Edit Photo Post' : 'New Photo Post'}</h1>
|
||||
<p class="subtitle">Share a photo with a caption and description</p>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
{#if !isSaving}
|
||||
<Button variant="ghost" onclick={() => goto('/admin/posts')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="secondary" onclick={handleDraft} disabled={!featuredImage || !title.trim()}>
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button variant="primary" onclick={handlePublish} disabled={!featuredImage || !title.trim()}>
|
||||
{isSaving ? 'Publishing...' : 'Publish'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="form-container">
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-content">
|
||||
<!-- Featured Photo Upload -->
|
||||
<div class="form-section">
|
||||
<ImageUploader
|
||||
label="Photo"
|
||||
value={featuredImage}
|
||||
onUpload={handleFeaturedImageUpload}
|
||||
placeholder="Upload the main photo for this post"
|
||||
helpText="This photo will be displayed prominently in the post"
|
||||
showBrowseLibrary={true}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="form-section">
|
||||
<Input
|
||||
type="text"
|
||||
label="Title"
|
||||
bind:value={title}
|
||||
placeholder="Enter a title for this photo post"
|
||||
required={true}
|
||||
fullWidth={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Caption/Content -->
|
||||
<div class="form-section">
|
||||
<label class="editor-label">Caption & Description</label>
|
||||
<p class="editor-help">Add a caption or tell the story behind this photo</p>
|
||||
<div class="editor-container">
|
||||
<Editor
|
||||
bind:this={editorRef}
|
||||
bind:data={content}
|
||||
placeholder="Write a caption or description for this photo..."
|
||||
minHeight={200}
|
||||
autofocus={false}
|
||||
class="photo-post-editor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="form-section">
|
||||
<Input
|
||||
type="text"
|
||||
label="Tags (Optional)"
|
||||
bind:value={tags}
|
||||
placeholder="nature, photography, travel (comma-separated)"
|
||||
helpText="Add tags to help categorize this photo post"
|
||||
fullWidth={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
||||
<style lang="scss">
|
||||
.header-content {
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 $unit-half 0;
|
||||
color: $grey-10;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: $grey-40;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: $unit-2x;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: $unit-4x;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-3x;
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #fee;
|
||||
color: #d33;
|
||||
padding: $unit-3x;
|
||||
border-radius: $unit;
|
||||
margin-bottom: $unit-4x;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
background: white;
|
||||
border-radius: $unit-2x;
|
||||
padding: $unit-6x;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@include breakpoint('phone') {
|
||||
padding: $unit-4x;
|
||||
}
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: $unit-6x;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: $grey-20;
|
||||
margin-bottom: $unit-half;
|
||||
}
|
||||
|
||||
.editor-help {
|
||||
font-size: 0.8rem;
|
||||
color: $grey-40;
|
||||
margin: 0 0 $unit-2x 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
border: 1px solid $grey-80;
|
||||
border-radius: $unit;
|
||||
overflow: hidden;
|
||||
|
||||
:global(.photo-post-editor) {
|
||||
min-height: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,7 +7,11 @@
|
|||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import Input from './Input.svelte'
|
||||
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||
import MediaDetailsModal from './MediaDetailsModal.svelte'
|
||||
import SmartImage from '../SmartImage.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
export let isOpen = false
|
||||
export let initialMode: 'modal' | 'page' = 'modal'
|
||||
|
|
@ -37,6 +41,15 @@
|
|||
let essayTags = ''
|
||||
let essayTab = 0
|
||||
|
||||
// Photo attachment state
|
||||
let attachedPhotos: Media[] = []
|
||||
let isMediaLibraryOpen = false
|
||||
let fileInput: HTMLInputElement
|
||||
|
||||
// Media details modal state
|
||||
let selectedMedia: Media | null = null
|
||||
let isMediaDetailsOpen = false
|
||||
|
||||
const CHARACTER_LIMIT = 280
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
|
|
@ -50,7 +63,7 @@
|
|||
}
|
||||
|
||||
function hasContent(): boolean {
|
||||
return characterCount > 0 || linkUrl.length > 0
|
||||
return characterCount > 0 || linkUrl.length > 0 || attachedPhotos.length > 0
|
||||
}
|
||||
|
||||
function resetComposer() {
|
||||
|
|
@ -65,6 +78,7 @@
|
|||
linkDescription = ''
|
||||
showLinkFields = false
|
||||
characterCount = 0
|
||||
attachedPhotos = []
|
||||
if (editorInstance) {
|
||||
editorInstance.clear()
|
||||
}
|
||||
|
|
@ -90,6 +104,83 @@
|
|||
showLinkFields = !showLinkFields
|
||||
}
|
||||
|
||||
function handlePhotoUpload() {
|
||||
fileInput.click()
|
||||
}
|
||||
|
||||
async function handleFileUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) continue
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', 'image')
|
||||
|
||||
// Add auth header if needed
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
const headers: Record<string, string> = {}
|
||||
if (auth) {
|
||||
headers.Authorization = `Basic ${auth}`
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const media = await response.json()
|
||||
attachedPhotos = [...attachedPhotos, media]
|
||||
} else {
|
||||
console.error('Failed to upload image:', response.status)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the input
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function handleMediaSelect(media: Media | Media[]) {
|
||||
const mediaArray = Array.isArray(media) ? media : [media]
|
||||
const currentIds = attachedPhotos.map(p => p.id)
|
||||
const newMedia = mediaArray.filter(m => !currentIds.includes(m.id))
|
||||
attachedPhotos = [...attachedPhotos, ...newMedia]
|
||||
}
|
||||
|
||||
function handleMediaLibraryClose() {
|
||||
isMediaLibraryOpen = false
|
||||
}
|
||||
|
||||
function removePhoto(photoId: number) {
|
||||
attachedPhotos = attachedPhotos.filter(p => p.id !== photoId)
|
||||
}
|
||||
|
||||
function handlePhotoClick(photo: Media) {
|
||||
selectedMedia = photo
|
||||
isMediaDetailsOpen = true
|
||||
}
|
||||
|
||||
function handleMediaDetailsClose() {
|
||||
isMediaDetailsOpen = false
|
||||
selectedMedia = null
|
||||
}
|
||||
|
||||
function handleMediaUpdate(updatedMedia: Media) {
|
||||
// Update the photo in the attachedPhotos array
|
||||
attachedPhotos = attachedPhotos.map(photo =>
|
||||
photo.id === updatedMedia.id ? updatedMedia : photo
|
||||
)
|
||||
}
|
||||
|
||||
function getTextFromContent(json: JSONContent): number {
|
||||
if (!json || !json.content) return 0
|
||||
|
||||
|
|
@ -114,7 +205,8 @@
|
|||
|
||||
let postData: any = {
|
||||
content,
|
||||
status: 'published'
|
||||
status: 'published',
|
||||
attachedPhotos: attachedPhotos.map(photo => photo.id)
|
||||
}
|
||||
|
||||
if (postType === 'essay') {
|
||||
|
|
@ -137,7 +229,7 @@
|
|||
} else {
|
||||
postData = {
|
||||
...postData,
|
||||
type: 'microblog'
|
||||
type: attachedPhotos.length > 0 ? 'photo' : 'microblog'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +257,7 @@
|
|||
|
||||
$: isOverLimit = characterCount > CHARACTER_LIMIT
|
||||
$: canSave =
|
||||
(postType === 'post' && characterCount > 0 && !isOverLimit) ||
|
||||
(postType === 'post' && (characterCount > 0 || attachedPhotos.length > 0) && !isOverLimit) ||
|
||||
(showLinkFields && linkUrl.length > 0) ||
|
||||
(postType === 'essay' && essayTitle.length > 0 && content)
|
||||
</script>
|
||||
|
|
@ -238,6 +330,42 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if attachedPhotos.length > 0}
|
||||
<div class="attached-photos">
|
||||
{#each attachedPhotos as photo}
|
||||
<div class="photo-item">
|
||||
<button
|
||||
class="photo-button"
|
||||
onclick={() => handlePhotoClick(photo)}
|
||||
title="View media details"
|
||||
>
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.altText || ''}
|
||||
class="photo-preview"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="remove-photo"
|
||||
onclick={() => removePhoto(photo.id)}
|
||||
title="Remove photo"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M4 4L12 12M4 12L12 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="composer-footer">
|
||||
<div class="footer-left">
|
||||
<Button
|
||||
|
|
@ -275,7 +403,7 @@
|
|||
variant="ghost"
|
||||
iconOnly
|
||||
size="icon"
|
||||
onclick={() => alert('Image upload coming soon!')}
|
||||
onclick={handlePhotoUpload}
|
||||
title="Add image"
|
||||
class="tool-button"
|
||||
>
|
||||
|
|
@ -299,6 +427,25 @@
|
|||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
size="icon"
|
||||
onclick={() => (isMediaLibraryOpen = true)}
|
||||
title="Browse library"
|
||||
class="tool-button"
|
||||
>
|
||||
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path
|
||||
d="M2 5L9 12L16 5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="footer-right">
|
||||
|
|
@ -433,6 +580,42 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
{#if attachedPhotos.length > 0}
|
||||
<div class="attached-photos">
|
||||
{#each attachedPhotos as photo}
|
||||
<div class="photo-item">
|
||||
<button
|
||||
class="photo-button"
|
||||
onclick={() => handlePhotoClick(photo)}
|
||||
title="View media details"
|
||||
>
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.altText || ''}
|
||||
class="photo-preview"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="remove-photo"
|
||||
onclick={() => removePhoto(photo.id)}
|
||||
title="Remove photo"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M4 4L12 12M4 12L12 4"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="composer-footer">
|
||||
<div class="footer-left">
|
||||
<Button
|
||||
|
|
@ -470,7 +653,7 @@
|
|||
variant="ghost"
|
||||
iconOnly
|
||||
size="icon"
|
||||
onclick={() => alert('Image upload coming soon!')}
|
||||
onclick={handlePhotoUpload}
|
||||
title="Add image"
|
||||
class="tool-button"
|
||||
>
|
||||
|
|
@ -494,6 +677,25 @@
|
|||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
size="icon"
|
||||
onclick={() => (isMediaLibraryOpen = true)}
|
||||
title="Browse library"
|
||||
class="tool-button"
|
||||
>
|
||||
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path
|
||||
d="M2 5L9 12L16 5"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="footer-right">
|
||||
|
|
@ -514,6 +716,35 @@
|
|||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Hidden file input for photo upload -->
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onchange={handleFileUpload}
|
||||
style="display: none;"
|
||||
/>
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
bind:isOpen={isMediaLibraryOpen}
|
||||
mode="multiple"
|
||||
fileType="image"
|
||||
onSelect={handleMediaSelect}
|
||||
onClose={handleMediaLibraryClose}
|
||||
/>
|
||||
|
||||
<!-- Media Details Modal -->
|
||||
{#if selectedMedia}
|
||||
<MediaDetailsModal
|
||||
bind:isOpen={isMediaDetailsOpen}
|
||||
media={selectedMedia}
|
||||
onClose={handleMediaDetailsClose}
|
||||
onUpdate={handleMediaUpdate}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
|
|
@ -756,4 +987,71 @@
|
|||
border-top: 1px solid $grey-80;
|
||||
background-color: $grey-90;
|
||||
}
|
||||
|
||||
.attached-photos {
|
||||
padding: 0 $unit-3x $unit-2x;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $unit;
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
position: relative;
|
||||
|
||||
.photo-button {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
:global(.photo-preview) {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.remove-photo {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .remove-photo {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-composer .attached-photos {
|
||||
padding: 0 $unit-3x $unit-2x;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -258,6 +258,14 @@ export const commands: Record<string, EdraCommandGroup> = {
|
|||
editor.chain().focus().insertImagePlaceholder().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
iconName: 'Images',
|
||||
name: 'gallery-placeholder',
|
||||
label: 'Gallery',
|
||||
action: (editor) => {
|
||||
editor.chain().focus().insertGalleryPlaceholder().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
iconName: 'Video',
|
||||
name: 'video-placeholder',
|
||||
|
|
|
|||
|
|
@ -53,7 +53,8 @@ export const initiateEditor = (
|
|||
}
|
||||
},
|
||||
codeBlock: false,
|
||||
text: false
|
||||
text: false,
|
||||
image: false
|
||||
}),
|
||||
SmilieReplacer,
|
||||
ColorHighlighter,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
import { Node, mergeAttributes } from '@tiptap/core'
|
||||
import type { Component } from 'svelte'
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
|
||||
export interface GalleryOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
gallery: {
|
||||
/**
|
||||
* Insert a gallery
|
||||
*/
|
||||
setGallery: (options: { images: Array<{ id: number; url: string; alt?: string; title?: string }> }) => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GalleryExtended = (component: Component<NodeViewProps>): Node<GalleryOptions, unknown> => {
|
||||
return Node.create<GalleryOptions>({
|
||||
name: 'gallery',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
images: {
|
||||
default: []
|
||||
},
|
||||
layout: {
|
||||
default: 'grid' // grid, masonry, carousel
|
||||
},
|
||||
columns: {
|
||||
default: 3
|
||||
},
|
||||
gap: {
|
||||
default: '16px'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{ tag: `div[data-type="${this.name}"]` }
|
||||
]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['div', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
'data-type': this.name
|
||||
})]
|
||||
},
|
||||
|
||||
group: 'block',
|
||||
draggable: true,
|
||||
atom: true,
|
||||
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(component)
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setGallery: (options) => ({ commands }) => {
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: {
|
||||
images: options.images
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core'
|
||||
import type { Component } from 'svelte'
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap'
|
||||
|
||||
export interface GalleryPlaceholderOptions {
|
||||
HTMLAttributes: Record<string, object>
|
||||
onSelectImages: (images: any[], editor: Editor) => void
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
galleryPlaceholder: {
|
||||
/**
|
||||
* Inserts a gallery placeholder
|
||||
*/
|
||||
insertGalleryPlaceholder: () => ReturnType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const GalleryPlaceholder = (
|
||||
component: Component<NodeViewProps>
|
||||
): Node<GalleryPlaceholderOptions> =>
|
||||
Node.create<GalleryPlaceholderOptions>({
|
||||
name: 'gallery-placeholder',
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
onSelectImages: () => {}
|
||||
}
|
||||
},
|
||||
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 {
|
||||
insertGalleryPlaceholder: () => (props: CommandProps) => {
|
||||
return props.commands.insertContent({
|
||||
type: 'gallery-placeholder'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
<script lang="ts">
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
import Grid from 'lucide-svelte/icons/grid-3x3'
|
||||
import Columns from 'lucide-svelte/icons/columns'
|
||||
import Trash from 'lucide-svelte/icons/trash'
|
||||
import Edit from 'lucide-svelte/icons/edit'
|
||||
import Plus from 'lucide-svelte/icons/plus'
|
||||
import MediaLibraryModal from '../../../admin/MediaLibraryModal.svelte'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
const { node, editor, selected, deleteNode, updateAttributes }: NodeViewProps = $props()
|
||||
|
||||
let isMediaLibraryOpen = $state(false)
|
||||
let editingMode = $state(false)
|
||||
|
||||
function handleEditGallery() {
|
||||
editingMode = true
|
||||
isMediaLibraryOpen = true
|
||||
}
|
||||
|
||||
function handleAddImages() {
|
||||
editingMode = false
|
||||
isMediaLibraryOpen = true
|
||||
}
|
||||
|
||||
function handleMediaSelect(media: Media | Media[]) {
|
||||
const mediaArray = Array.isArray(media) ? media : [media]
|
||||
const newImages = mediaArray.map(m => ({
|
||||
id: m.id,
|
||||
url: m.url,
|
||||
alt: m.altText || '',
|
||||
title: m.description || ''
|
||||
}))
|
||||
|
||||
if (editingMode) {
|
||||
// Replace all images
|
||||
updateAttributes({ images: newImages })
|
||||
} else {
|
||||
// Add to existing images
|
||||
const existingImages = node.attrs.images || []
|
||||
const currentIds = existingImages.map((img: any) => img.id)
|
||||
const uniqueNewImages = newImages.filter(img => !currentIds.includes(img.id))
|
||||
updateAttributes({ images: [...existingImages, ...uniqueNewImages] })
|
||||
}
|
||||
|
||||
isMediaLibraryOpen = false
|
||||
editingMode = false
|
||||
}
|
||||
|
||||
function handleMediaLibraryClose() {
|
||||
isMediaLibraryOpen = false
|
||||
editingMode = false
|
||||
}
|
||||
|
||||
function removeImage(imageId: number) {
|
||||
const currentImages = node.attrs.images || []
|
||||
const updatedImages = currentImages.filter((img: any) => img.id !== imageId)
|
||||
updateAttributes({ images: updatedImages })
|
||||
}
|
||||
|
||||
function changeLayout(layout: 'grid' | 'masonry') {
|
||||
updateAttributes({ layout })
|
||||
}
|
||||
|
||||
function changeColumns(columns: number) {
|
||||
updateAttributes({ columns })
|
||||
}
|
||||
|
||||
const images = $derived(node.attrs.images || [])
|
||||
const layout = $derived(node.attrs.layout || 'grid')
|
||||
const columns = $derived(node.attrs.columns || 3)
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper
|
||||
class={`edra-gallery-container ${selected ? 'selected' : ''}`}
|
||||
data-layout={layout}
|
||||
style={`--columns: ${columns}`}
|
||||
>
|
||||
<div class="edra-gallery-content">
|
||||
{#if images.length === 0}
|
||||
<div class="edra-gallery-empty">
|
||||
<Grid class="edra-gallery-empty-icon" />
|
||||
<span>Gallery is empty</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class={`edra-gallery-grid ${layout === 'masonry' ? 'masonry' : 'grid'}`}>
|
||||
{#each images as image}
|
||||
<div class="edra-gallery-item">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt}
|
||||
title={image.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
{#if editor?.isEditable}
|
||||
<button
|
||||
class="edra-gallery-item-remove"
|
||||
onclick={() => removeImage(image.id)}
|
||||
title="Remove image"
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editor?.isEditable}
|
||||
<div class="edra-gallery-toolbar">
|
||||
<div class="edra-gallery-toolbar-section">
|
||||
<button
|
||||
class={`edra-toolbar-button ${layout === 'grid' ? 'active' : ''}`}
|
||||
onclick={() => changeLayout('grid')}
|
||||
title="Grid Layout"
|
||||
>
|
||||
<Grid />
|
||||
</button>
|
||||
<button
|
||||
class={`edra-toolbar-button ${layout === 'masonry' ? 'active' : ''}`}
|
||||
onclick={() => changeLayout('masonry')}
|
||||
title="Masonry Layout"
|
||||
>
|
||||
<Columns />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="edra-gallery-toolbar-section">
|
||||
<select
|
||||
class="edra-gallery-columns-select"
|
||||
value={columns}
|
||||
onchange={(e) => changeColumns(parseInt(e.currentTarget.value))}
|
||||
title="Columns"
|
||||
>
|
||||
<option value="2">2 cols</option>
|
||||
<option value="3">3 cols</option>
|
||||
<option value="4">4 cols</option>
|
||||
<option value="5">5 cols</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="edra-gallery-toolbar-section">
|
||||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={handleAddImages}
|
||||
title="Add Images"
|
||||
>
|
||||
<Plus />
|
||||
</button>
|
||||
<button
|
||||
class="edra-toolbar-button"
|
||||
onclick={handleEditGallery}
|
||||
title="Edit Gallery"
|
||||
>
|
||||
<Edit />
|
||||
</button>
|
||||
<button
|
||||
class="edra-toolbar-button edra-destructive"
|
||||
onclick={() => deleteNode()}
|
||||
title="Delete Gallery"
|
||||
>
|
||||
<Trash />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
bind:isOpen={isMediaLibraryOpen}
|
||||
mode="multiple"
|
||||
fileType="image"
|
||||
onSelect={handleMediaSelect}
|
||||
onClose={handleMediaLibraryClose}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
|
||||
<style>
|
||||
.edra-gallery-container {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.edra-gallery-container.selected {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.edra-gallery-content {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.edra-gallery-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 32px;
|
||||
color: #6b7280;
|
||||
background: #f9fafb;
|
||||
border: 2px dashed #e5e7eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:global(.edra-gallery-empty-icon) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.edra-gallery-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.edra-gallery-grid.grid {
|
||||
grid-template-columns: repeat(var(--columns), 1fr);
|
||||
}
|
||||
|
||||
.edra-gallery-grid.masonry {
|
||||
column-count: var(--columns);
|
||||
column-gap: 8px;
|
||||
}
|
||||
|
||||
.edra-gallery-item {
|
||||
position: relative;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
break-inside: avoid;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.edra-gallery-item img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.edra-gallery-item:hover img {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.edra-gallery-item-remove {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.edra-gallery-item:hover .edra-gallery-item-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.edra-gallery-item-remove:hover {
|
||||
background: rgba(239, 68, 68, 0.8);
|
||||
}
|
||||
|
||||
:global(.edra-gallery-item-remove svg) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.edra-gallery-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.edra-gallery-toolbar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.edra-gallery-columns-select {
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edra-gallery-columns-select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
:global(.edra-toolbar-button) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.edra-toolbar-button:hover) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
:global(.edra-toolbar-button.active) {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
:global(.edra-toolbar-button.edra-destructive:hover) {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
:global(.edra-toolbar-button svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.edra-gallery-grid.grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.edra-gallery-grid.masonry {
|
||||
column-count: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
<script lang="ts">
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
import type { Media } from '@prisma/client'
|
||||
import Grid from 'lucide-svelte/icons/grid-3x3'
|
||||
import Upload from 'lucide-svelte/icons/upload'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
import MediaLibraryModal from '../../../admin/MediaLibraryModal.svelte'
|
||||
|
||||
const { editor, deleteNode }: NodeViewProps = $props()
|
||||
|
||||
let isMediaLibraryOpen = $state(false)
|
||||
let fileInput: HTMLInputElement
|
||||
let isUploading = $state(false)
|
||||
|
||||
function handleBrowseLibrary(e: MouseEvent) {
|
||||
if (!editor.isEditable) return
|
||||
e.preventDefault()
|
||||
isMediaLibraryOpen = true
|
||||
}
|
||||
|
||||
function handleDirectUpload(e: MouseEvent) {
|
||||
if (!editor.isEditable) return
|
||||
e.preventDefault()
|
||||
fileInput.click()
|
||||
}
|
||||
|
||||
function handleMediaSelect(media: Media | Media[]) {
|
||||
const mediaArray = Array.isArray(media) ? media : [media]
|
||||
if (mediaArray.length > 0) {
|
||||
const galleryImages = mediaArray.map(m => ({
|
||||
id: m.id,
|
||||
url: m.url,
|
||||
alt: m.altText || '',
|
||||
title: m.description || ''
|
||||
}))
|
||||
|
||||
editor.chain().focus().setGallery({ images: galleryImages }).run()
|
||||
}
|
||||
isMediaLibraryOpen = false
|
||||
}
|
||||
|
||||
function handleMediaLibraryClose() {
|
||||
isMediaLibraryOpen = false
|
||||
}
|
||||
|
||||
async function handleFileUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
isUploading = true
|
||||
const uploadedImages = []
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith('image/')) continue
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', 'image')
|
||||
|
||||
// Add auth header if needed
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
const headers: Record<string, string> = {}
|
||||
if (auth) {
|
||||
headers.Authorization = `Basic ${auth}`
|
||||
}
|
||||
|
||||
const response = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const media = await response.json()
|
||||
uploadedImages.push({
|
||||
id: media.id,
|
||||
url: media.url,
|
||||
alt: media.altText || '',
|
||||
title: media.description || ''
|
||||
})
|
||||
} else {
|
||||
console.error('Failed to upload image:', response.status)
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedImages.length > 0) {
|
||||
editor.chain().focus().setGallery({ images: uploadedImages }).run()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading images:', error)
|
||||
alert('Failed to upload some images. Please try again.')
|
||||
} finally {
|
||||
isUploading = false
|
||||
// Clear the input
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Handle keyboard navigation
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleBrowseLibrary(e as any)
|
||||
} else if (e.key === 'Escape') {
|
||||
deleteNode()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper class="edra-gallery-placeholder-wrapper" contenteditable="false">
|
||||
<div class="edra-gallery-placeholder-container">
|
||||
{#if isUploading}
|
||||
<div class="edra-gallery-placeholder-uploading">
|
||||
<div class="spinner"></div>
|
||||
<span>Uploading images...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="edra-gallery-placeholder-option"
|
||||
onclick={handleDirectUpload}
|
||||
onkeydown={handleKeyDown}
|
||||
tabindex="0"
|
||||
aria-label="Upload Images"
|
||||
title="Upload from device"
|
||||
>
|
||||
<Upload class="edra-gallery-placeholder-icon" />
|
||||
<span class="edra-gallery-placeholder-text">Upload Images</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="edra-gallery-placeholder-option"
|
||||
onclick={handleBrowseLibrary}
|
||||
onkeydown={handleKeyDown}
|
||||
tabindex="0"
|
||||
aria-label="Browse Media Library"
|
||||
title="Choose from library"
|
||||
>
|
||||
<Grid class="edra-gallery-placeholder-icon" />
|
||||
<span class="edra-gallery-placeholder-text">Browse Library</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onchange={handleFileUpload}
|
||||
style="display: none;"
|
||||
/>
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
bind:isOpen={isMediaLibraryOpen}
|
||||
mode="multiple"
|
||||
fileType="image"
|
||||
onSelect={handleMediaSelect}
|
||||
onClose={handleMediaLibraryClose}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
|
||||
<style>
|
||||
.edra-gallery-placeholder-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
border: 2px dashed #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
transition: all 0.2s ease;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.edra-gallery-placeholder-container:hover {
|
||||
border-color: #d1d5db;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.edra-gallery-placeholder-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.edra-gallery-placeholder-option:hover {
|
||||
border-color: #d1d5db;
|
||||
background: #f9fafb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.edra-gallery-placeholder-option:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.edra-gallery-placeholder-uploading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #f3f4f6;
|
||||
border-top: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
:global(.edra-gallery-placeholder-icon) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.edra-gallery-placeholder-text {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,30 +1,237 @@
|
|||
<script lang="ts">
|
||||
import type { NodeViewProps } from '@tiptap/core'
|
||||
import type { Media } from '@prisma/client'
|
||||
import Image from 'lucide-svelte/icons/image'
|
||||
import Upload from 'lucide-svelte/icons/upload'
|
||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||
const { editor }: NodeViewProps = $props()
|
||||
import MediaLibraryModal from '../../../admin/MediaLibraryModal.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
const { editor, deleteNode }: NodeViewProps = $props()
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
let isMediaLibraryOpen = $state(false)
|
||||
let fileInput: HTMLInputElement
|
||||
let isUploading = $state(false)
|
||||
|
||||
function handleBrowseLibrary(e: MouseEvent) {
|
||||
if (!editor.isEditable) return
|
||||
e.preventDefault()
|
||||
const imageUrl = prompt('Enter the URL of an image:')
|
||||
if (!imageUrl) {
|
||||
isMediaLibraryOpen = true
|
||||
}
|
||||
|
||||
function handleDirectUpload(e: MouseEvent) {
|
||||
if (!editor.isEditable) return
|
||||
e.preventDefault()
|
||||
fileInput.click()
|
||||
}
|
||||
|
||||
function handleMediaSelect(media: Media | Media[]) {
|
||||
const selectedMedia = Array.isArray(media) ? media[0] : media
|
||||
if (selectedMedia) {
|
||||
editor.chain().focus().setImage({
|
||||
src: selectedMedia.url,
|
||||
alt: selectedMedia.altText || '',
|
||||
title: selectedMedia.description || ''
|
||||
}).run()
|
||||
}
|
||||
isMediaLibraryOpen = false
|
||||
}
|
||||
|
||||
function handleMediaLibraryClose() {
|
||||
isMediaLibraryOpen = false
|
||||
}
|
||||
|
||||
async function handleFileUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = input.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const file = files[0]
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('Please select an image file.')
|
||||
return
|
||||
}
|
||||
editor.chain().focus().setImage({ src: imageUrl }).run()
|
||||
|
||||
isUploading = true
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('type', 'image')
|
||||
|
||||
// Add auth header if needed
|
||||
const auth = localStorage.getItem('admin_auth')
|
||||
const headers: Record<string, string> = {}
|
||||
if (auth) {
|
||||
headers.Authorization = `Basic ${auth}`
|
||||
}
|
||||
|
||||
const response = await fetch('/api/media/upload', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const media = await response.json()
|
||||
editor.chain().focus().setImage({
|
||||
src: media.url,
|
||||
alt: media.altText || '',
|
||||
title: media.description || ''
|
||||
}).run()
|
||||
} else {
|
||||
console.error('Failed to upload image:', response.status)
|
||||
alert('Failed to upload image. Please try again.')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error)
|
||||
alert('Failed to upload image. Please try again.')
|
||||
} finally {
|
||||
isUploading = false
|
||||
// Clear the input
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Handle keyboard navigation
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleBrowseLibrary(e as any)
|
||||
} else if (e.key === 'Escape') {
|
||||
deleteNode()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
|
||||
<!-- 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>
|
||||
<div class="edra-media-placeholder-container">
|
||||
{#if isUploading}
|
||||
<div class="edra-media-placeholder-uploading">
|
||||
<div class="spinner"></div>
|
||||
<span>Uploading...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
class="edra-media-placeholder-option"
|
||||
onclick={handleDirectUpload}
|
||||
onkeydown={handleKeyDown}
|
||||
tabindex="0"
|
||||
aria-label="Upload Image"
|
||||
title="Upload from device"
|
||||
>
|
||||
<Upload class="edra-media-placeholder-icon" />
|
||||
<span class="edra-media-placeholder-text">Upload Image</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="edra-media-placeholder-option"
|
||||
onclick={handleBrowseLibrary}
|
||||
onkeydown={handleKeyDown}
|
||||
tabindex="0"
|
||||
aria-label="Browse Media Library"
|
||||
title="Choose from library"
|
||||
>
|
||||
<Image class="edra-media-placeholder-icon" />
|
||||
<span class="edra-media-placeholder-text">Browse Library</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onchange={handleFileUpload}
|
||||
style="display: none;"
|
||||
/>
|
||||
|
||||
<!-- Media Library Modal -->
|
||||
<MediaLibraryModal
|
||||
bind:isOpen={isMediaLibraryOpen}
|
||||
mode="single"
|
||||
fileType="image"
|
||||
onSelect={handleMediaSelect}
|
||||
onClose={handleMediaLibraryClose}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
|
||||
<style>
|
||||
.edra-media-placeholder-container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 2px dashed #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #f9fafb;
|
||||
transition: all 0.2s ease;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.edra-media-placeholder-container:hover {
|
||||
border-color: #d1d5db;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.edra-media-placeholder-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.edra-media-placeholder-option:hover {
|
||||
border-color: #d1d5db;
|
||||
background: #f9fafb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.edra-media-placeholder-option:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.edra-media-placeholder-uploading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #f3f4f6;
|
||||
border-top: 2px solid #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
:global(.edra-media-placeholder-icon) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.edra-media-placeholder-text {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@
|
|||
import VideoExtendedComponent from './components/VideoExtended.svelte'
|
||||
import { VideoExtended } from '../extensions/video/VideoExtended.js'
|
||||
import { AudioExtended } from '../extensions/audio/AudiExtended.js'
|
||||
import { GalleryPlaceholder } from '../extensions/gallery/GalleryPlaceholder.js'
|
||||
import GalleryPlaceholderComponent from './components/GalleryPlaceholder.svelte'
|
||||
import { GalleryExtended } from '../extensions/gallery/GalleryExtended.js'
|
||||
import GalleryExtendedComponent from './components/GalleryExtended.svelte'
|
||||
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'
|
||||
|
|
@ -69,11 +73,13 @@
|
|||
}),
|
||||
AudioPlaceholder(AudioPlaceholderComponent),
|
||||
ImagePlaceholder(ImagePlaceholderComponent),
|
||||
GalleryPlaceholder(GalleryPlaceholderComponent),
|
||||
IFramePlaceholder(IFramePlaceholderComponent),
|
||||
IFrameExtended(IFrameExtendedComponent),
|
||||
VideoPlaceholder(VideoPlaceholderComponent),
|
||||
AudioExtended(AudioExtendedComponent),
|
||||
ImageExtended(ImageExtendedComponent),
|
||||
GalleryExtended(GalleryExtendedComponent),
|
||||
VideoExtended(VideoExtendedComponent),
|
||||
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
|
||||
],
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@
|
|||
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
||||
import Editor from '$lib/components/admin/Editor.svelte'
|
||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||
import PhotoPostForm from '$lib/components/admin/PhotoPostForm.svelte'
|
||||
import AlbumForm from '$lib/components/admin/AlbumForm.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
let post: any = null
|
||||
|
|
@ -251,6 +253,30 @@
|
|||
<div class="loading-container">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if post && postType === 'photo'}
|
||||
<PhotoPostForm
|
||||
mode="edit"
|
||||
postId={post.id}
|
||||
initialData={{
|
||||
title: post.title,
|
||||
content: post.content,
|
||||
featuredImage: post.featuredImage,
|
||||
status: post.status,
|
||||
tags: post.tags
|
||||
}}
|
||||
/>
|
||||
{:else if post && postType === 'album'}
|
||||
<AlbumForm
|
||||
mode="edit"
|
||||
postId={post.id}
|
||||
initialData={{
|
||||
title: post.title,
|
||||
content: post.content,
|
||||
gallery: post.gallery || [],
|
||||
status: post.status,
|
||||
tags: post.tags
|
||||
}}
|
||||
/>
|
||||
{:else if post}
|
||||
<div class="post-composer">
|
||||
<div class="main-content">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
import { onMount } from 'svelte'
|
||||
import EssayForm from '$lib/components/admin/EssayForm.svelte'
|
||||
import SimplePostForm from '$lib/components/admin/SimplePostForm.svelte'
|
||||
import PhotoPostForm from '$lib/components/admin/PhotoPostForm.svelte'
|
||||
import AlbumForm from '$lib/components/admin/AlbumForm.svelte'
|
||||
|
||||
let postType: 'blog' | 'microblog' | 'link' | 'photo' | 'album' = 'blog'
|
||||
let mounted = false
|
||||
|
|
@ -21,11 +23,9 @@
|
|||
<EssayForm mode="create" />
|
||||
{:else if postType === 'microblog' || postType === 'link'}
|
||||
<SimplePostForm {postType} mode="create" />
|
||||
{:else}
|
||||
<!-- TODO: Implement photo and album forms -->
|
||||
<div style="padding: 2rem; text-align: center;">
|
||||
<p>Photo and album post types coming soon!</p>
|
||||
<a href="/admin/posts" style="color: blue;">← Back to posts</a>
|
||||
</div>
|
||||
{:else if postType === 'photo'}
|
||||
<PhotoPostForm mode="create" />
|
||||
{:else if postType === 'album'}
|
||||
<AlbumForm mode="create" />
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -71,15 +71,46 @@ export const POST: RequestHandler = async (event) => {
|
|||
data.publishedAt = new Date()
|
||||
}
|
||||
|
||||
// Handle photo attachments for posts
|
||||
let featuredImageId = data.featuredImage
|
||||
if (data.attachedPhotos && data.attachedPhotos.length > 0 && !featuredImageId) {
|
||||
// Use first attached photo as featured image for photo posts
|
||||
featuredImageId = data.attachedPhotos[0]
|
||||
}
|
||||
|
||||
// Handle album gallery - use first image as featured image
|
||||
if (data.gallery && data.gallery.length > 0 && !featuredImageId) {
|
||||
// Get the media URL for the first gallery item
|
||||
const firstMedia = await prisma.media.findUnique({
|
||||
where: { id: data.gallery[0] },
|
||||
select: { url: true }
|
||||
})
|
||||
if (firstMedia) {
|
||||
featuredImageId = firstMedia.url
|
||||
}
|
||||
}
|
||||
|
||||
// For albums, store gallery IDs in content field as a special structure
|
||||
let postContent = data.content
|
||||
if (data.type === 'album' && data.gallery) {
|
||||
postContent = {
|
||||
type: 'album',
|
||||
gallery: data.gallery,
|
||||
description: data.content
|
||||
}
|
||||
}
|
||||
|
||||
const post = await prisma.post.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
postType: data.type,
|
||||
status: data.status,
|
||||
content: data.content,
|
||||
content: postContent,
|
||||
excerpt: data.excerpt,
|
||||
linkUrl: data.link_url,
|
||||
linkDescription: data.linkDescription,
|
||||
featuredImage: featuredImageId,
|
||||
tags: data.tags,
|
||||
publishedAt: data.publishedAt
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,29 @@ export const PUT: RequestHandler = async (event) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Handle album gallery updates
|
||||
let featuredImageId = data.featuredImage
|
||||
if (data.gallery && data.gallery.length > 0 && !featuredImageId) {
|
||||
// Get the media URL for the first gallery item
|
||||
const firstMedia = await prisma.media.findUnique({
|
||||
where: { id: data.gallery[0] },
|
||||
select: { url: true }
|
||||
})
|
||||
if (firstMedia) {
|
||||
featuredImageId = firstMedia.url
|
||||
}
|
||||
}
|
||||
|
||||
// For albums, store gallery IDs in content field as a special structure
|
||||
let postContent = data.content
|
||||
if (data.type === 'album' && data.gallery) {
|
||||
postContent = {
|
||||
type: 'album',
|
||||
gallery: data.gallery,
|
||||
description: data.content
|
||||
}
|
||||
}
|
||||
|
||||
const post = await prisma.post.update({
|
||||
where: { id },
|
||||
data: {
|
||||
|
|
@ -64,10 +87,13 @@ export const PUT: RequestHandler = async (event) => {
|
|||
slug: data.slug,
|
||||
postType: data.type,
|
||||
status: data.status,
|
||||
content: data.content,
|
||||
content: postContent,
|
||||
excerpt: data.excerpt,
|
||||
linkUrl: data.link_url,
|
||||
tags: data.tags
|
||||
linkDescription: data.linkDescription,
|
||||
featuredImage: featuredImageId,
|
||||
tags: data.tags,
|
||||
publishedAt: data.publishedAt
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue