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
|
### Dependencies
|
||||||
|
|
||||||
- [ ] `edra` (Edra editor)
|
- [x] `edra` (Edra editor) - Integrated and configured
|
||||||
- [ ] `@prisma/client` or `postgres` driver
|
- [x] `@prisma/client` - Set up with complete schema
|
||||||
- [ ] `exifr` for EXIF data extraction
|
- [x] `cloudinary` - SDK integrated for image processing and storage
|
||||||
- [ ] `sharp` or Cloudinary SDK for image processing
|
- [x] Form validation with built-in validation
|
||||||
- [ ] Form validation library (Zod/Valibot)
|
- [ ] `exifr` for EXIF data extraction (needed for photos system)
|
||||||
|
|
||||||
### Admin Interface
|
### Admin Interface
|
||||||
|
|
||||||
- [ ] Admin layout and navigation
|
- [x] Admin layout and navigation
|
||||||
- [ ] Content type switcher
|
- [x] Content type switcher (Dashboard, Projects, Universe, Media)
|
||||||
- [ ] List views for each content type
|
- [x] List views for projects and posts
|
||||||
- [ ] Form builders for Projects
|
- [x] Complete form system for Projects (metadata, branding, styling)
|
||||||
- [ ] Edra wrapper for Posts
|
- [x] Edra wrapper for Posts with all post types
|
||||||
- [ ] Photo uploader with drag-and-drop
|
- [x] Comprehensive admin component library
|
||||||
- [ ] Media library browser
|
- [ ] Photo uploader with drag-and-drop (for albums system)
|
||||||
|
- [ ] Media library browser modal
|
||||||
|
|
||||||
### APIs
|
### APIs
|
||||||
|
|
||||||
- [ ] CRUD endpoints for all content types
|
- [x] CRUD endpoints for projects and posts
|
||||||
- [ ] Media upload with progress
|
- [x] Media upload with progress
|
||||||
- [ ] Bulk operations (delete, publish)
|
- [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
|
- [ ] Search and filtering endpoints
|
||||||
|
|
||||||
### Public Display
|
### Public Display
|
||||||
|
|
@ -468,36 +472,50 @@ Based on requirements discussion:
|
||||||
## Current Status (December 2024)
|
## Current Status (December 2024)
|
||||||
|
|
||||||
### Completed
|
### Completed
|
||||||
|
|
||||||
- ✅ Database setup with Prisma and PostgreSQL
|
- ✅ Database setup with Prisma and PostgreSQL
|
||||||
- ✅ Media management system with Cloudinary integration
|
- ✅ Media management system with Cloudinary integration
|
||||||
- ✅ Admin foundation (layout, navigation, auth, forms, data tables)
|
- ✅ Admin foundation (layout, navigation, auth, forms, data tables)
|
||||||
- ✅ Edra rich text editor integration for case studies
|
- ✅ Edra rich text editor integration for case studies
|
||||||
- ✅ Edra image uploads configured to use media API
|
- ✅ Edra image uploads configured to use media API
|
||||||
- ✅ Local development mode for media uploads (no Cloudinary usage)
|
- ✅ Local development mode for media uploads (no Cloudinary usage)
|
||||||
- ✅ Project CRUD system with metadata fields
|
- ✅ Project CRUD system with metadata fields and enhanced schema
|
||||||
- ✅ Project list view in admin
|
- ✅ 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
|
- ✅ Test page for verifying upload functionality
|
||||||
|
|
||||||
### In Progress
|
### In Progress
|
||||||
- 🔄 Posts System - Core functionality implemented
|
|
||||||
|
- 🔄 Albums/Photos System - Schema implemented, UI components needed
|
||||||
|
|
||||||
### Next Steps
|
### 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**
|
1. **Media Library System** (Critical dependency for other features)
|
||||||
- Technology tag selector
|
|
||||||
- Featured image picker with media library
|
|
||||||
- Gallery manager for project images
|
|
||||||
- Project ordering/display order
|
|
||||||
|
|
||||||
4. **Photos & Albums System**
|
- Media library modal component
|
||||||
- Album creation and management
|
- Integration with existing media APIs
|
||||||
- Bulk photo upload interface
|
- 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
|
- 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
|
## Phased Implementation Plan
|
||||||
|
|
||||||
|
|
@ -534,9 +552,10 @@ Based on requirements discussion:
|
||||||
- [x] Create admin layout component
|
- [x] Create admin layout component
|
||||||
- [x] Build admin navigation with content type switcher
|
- [x] Build admin navigation with content type switcher
|
||||||
- [x] Implement admin authentication (basic for now)
|
- [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] Build data table component for list views
|
||||||
- [x] Add loading and error states
|
- [x] Add loading and error states
|
||||||
|
- [x] Create comprehensive admin UI component library
|
||||||
- [ ] Create media library modal component
|
- [ ] Create media library modal component
|
||||||
|
|
||||||
### Phase 4: Posts System (All Types)
|
### Phase 4: Posts System (All Types)
|
||||||
|
|
@ -549,29 +568,34 @@ Based on requirements discussion:
|
||||||
- [x] Create posts list view in admin
|
- [x] Create posts list view in admin
|
||||||
- [x] Implement post CRUD APIs
|
- [x] Implement post CRUD APIs
|
||||||
- [x] Post editor page with type-specific fields
|
- [x] Post editor page with type-specific fields
|
||||||
- [ ] Create photo post selector
|
- [x] Complete posts database schema with all post types
|
||||||
- [ ] Build album post selector
|
- [x] Posts administration interface
|
||||||
|
- [ ] Create photo post selector (needs media library modal)
|
||||||
|
- [ ] Build album post selector (needs albums system)
|
||||||
- [ ] Add auto-save functionality
|
- [ ] Add auto-save functionality
|
||||||
|
|
||||||
### Phase 5: Projects System
|
### Phase 5: Projects System
|
||||||
|
|
||||||
- [x] Build project form with all metadata fields
|
- [x] Build project form with all metadata fields
|
||||||
- [ ] Create technology tag selector
|
- [x] Enhanced schema with branding fields (logo, colors)
|
||||||
- [ ] Implement featured image picker
|
- [x] Project branding and styling forms
|
||||||
- [ ] Build gallery manager with drag-and-drop ordering
|
|
||||||
- [x] Add optional Edra editor for case studies
|
- [x] Add optional Edra editor for case studies
|
||||||
- [x] Create project CRUD APIs
|
- [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
|
- [ ] Add project ordering functionality
|
||||||
|
|
||||||
### Phase 6: Photos & Albums System
|
### 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
|
- [ ] Create album management interface
|
||||||
- [ ] Build bulk photo uploader with progress
|
- [ ] Build bulk photo uploader with progress
|
||||||
- [ ] Implement EXIF data extraction for photos
|
- [ ] Implement EXIF data extraction for photos
|
||||||
- [ ] Implement drag-and-drop photo ordering
|
- [ ] Implement drag-and-drop photo ordering
|
||||||
- [ ] Add individual photo publishing UI
|
- [ ] Add individual photo publishing UI
|
||||||
- [ ] Create photo/album CRUD APIs
|
|
||||||
- [ ] Build photo metadata editor
|
- [ ] Build photo metadata editor
|
||||||
- [ ] Implement album cover selection
|
- [ ] Implement album cover selection
|
||||||
- [ ] Add "show in universe" toggle for albums
|
- [ ] 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 { IFramePlaceholder } from '$lib/components/edra/extensions/iframe/IFramePlaceholder.js'
|
||||||
import { IFrameExtended } from '$lib/components/edra/extensions/iframe/IFrameExtended.js'
|
import { IFrameExtended } from '$lib/components/edra/extensions/iframe/IFrameExtended.js'
|
||||||
import IFrameExtendedComponent from '$lib/components/edra/headless/components/IFrameExtended.svelte'
|
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 Edra styles
|
||||||
import '$lib/components/edra/headless/style.css'
|
import '$lib/components/edra/headless/style.css'
|
||||||
|
|
@ -296,11 +300,13 @@
|
||||||
}),
|
}),
|
||||||
AudioPlaceholder(AudioPlaceholderComponent),
|
AudioPlaceholder(AudioPlaceholderComponent),
|
||||||
ImagePlaceholder(ImageUploadPlaceholder), // Use our custom component
|
ImagePlaceholder(ImageUploadPlaceholder), // Use our custom component
|
||||||
|
GalleryPlaceholder(GalleryPlaceholderComponent),
|
||||||
IFramePlaceholder(IFramePlaceholderComponent),
|
IFramePlaceholder(IFramePlaceholderComponent),
|
||||||
IFrameExtended(IFrameExtendedComponent),
|
IFrameExtended(IFrameExtendedComponent),
|
||||||
VideoPlaceholder(VideoPlaceholderComponent),
|
VideoPlaceholder(VideoPlaceholderComponent),
|
||||||
AudioExtended(AudioExtendedComponent),
|
AudioExtended(AudioExtendedComponent),
|
||||||
ImageExtended(ImageExtendedComponent),
|
ImageExtended(ImageExtendedComponent),
|
||||||
|
GalleryExtended(GalleryExtendedComponent),
|
||||||
VideoExtended(VideoExtendedComponent),
|
VideoExtended(VideoExtendedComponent),
|
||||||
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
|
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
|
||||||
],
|
],
|
||||||
|
|
@ -500,6 +506,46 @@
|
||||||
</svg>
|
</svg>
|
||||||
<span>Image</span>
|
<span>Image</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,55 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NodeViewProps } from '@tiptap/core'
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
|
import type { Media } from '@prisma/client'
|
||||||
import Image from 'lucide-svelte/icons/image'
|
import Image from 'lucide-svelte/icons/image'
|
||||||
import Upload from 'lucide-svelte/icons/upload'
|
import Upload from 'lucide-svelte/icons/upload'
|
||||||
import Link from 'lucide-svelte/icons/link'
|
import Link from 'lucide-svelte/icons/link'
|
||||||
|
import Grid from 'lucide-svelte/icons/grid-3x3'
|
||||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
import { NodeViewWrapper } from 'svelte-tiptap'
|
||||||
|
import MediaLibraryModal from './MediaLibraryModal.svelte'
|
||||||
|
|
||||||
const { editor }: NodeViewProps = $props()
|
const { editor, deleteNode }: NodeViewProps = $props()
|
||||||
|
|
||||||
let fileInput: HTMLInputElement
|
let fileInput: HTMLInputElement
|
||||||
let isDragging = $state(false)
|
let isDragging = $state(false)
|
||||||
|
let isMediaLibraryOpen = $state(false)
|
||||||
|
let isUploading = $state(false)
|
||||||
|
|
||||||
function handleClick(e: MouseEvent) {
|
function handleBrowseLibrary(e: MouseEvent) {
|
||||||
if (!editor.isEditable) return
|
if (!editor.isEditable) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
isMediaLibraryOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
// Show options: upload file or enter URL
|
function handleDirectUpload(e: MouseEvent) {
|
||||||
const choice = confirm('Click OK to upload a file, or Cancel to enter a URL')
|
if (!editor.isEditable) return
|
||||||
|
e.preventDefault()
|
||||||
|
fileInput.click()
|
||||||
|
}
|
||||||
|
|
||||||
if (choice) {
|
function handleMediaSelect(media: Media | Media[]) {
|
||||||
// Upload file
|
const selectedMedia = Array.isArray(media) ? media[0] : media
|
||||||
fileInput?.click()
|
if (selectedMedia) {
|
||||||
} else {
|
// Set a reasonable default width (max 600px)
|
||||||
// Enter URL
|
const displayWidth = selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width
|
||||||
const imageUrl = prompt('Enter the URL of an image:')
|
|
||||||
if (imageUrl) {
|
editor
|
||||||
editor.chain().focus().setImage({ src: imageUrl }).run()
|
.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) {
|
async function handleFileSelect(e: Event) {
|
||||||
|
|
@ -53,6 +76,8 @@
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isUploading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = localStorage.getItem('admin_auth')
|
const auth = localStorage.getItem('admin_auth')
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
|
|
@ -61,6 +86,7 @@
|
||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
formData.append('type', 'image')
|
||||||
|
|
||||||
const response = await fetch('/api/media/upload', {
|
const response = await fetch('/api/media/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -84,7 +110,7 @@
|
||||||
.focus()
|
.focus()
|
||||||
.setImage({
|
.setImage({
|
||||||
src: media.url,
|
src: media.url,
|
||||||
alt: media.filename || '',
|
alt: media.altText || '',
|
||||||
width: displayWidth,
|
width: displayWidth,
|
||||||
height: media.height,
|
height: media.height,
|
||||||
align: 'center'
|
align: 'center'
|
||||||
|
|
@ -93,6 +119,8 @@
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Image upload failed:', error)
|
console.error('Image upload failed:', error)
|
||||||
alert('Failed to upload image. Please try again.')
|
alert('Failed to upload image. Please try again.')
|
||||||
|
} finally {
|
||||||
|
isUploading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,6 +147,16 @@
|
||||||
await uploadFile(file)
|
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>
|
</script>
|
||||||
|
|
||||||
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
|
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
|
||||||
|
|
@ -130,38 +168,124 @@
|
||||||
style="display: none;"
|
style="display: none;"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<div class="edra-image-placeholder-container">
|
||||||
<span
|
{#if isUploading}
|
||||||
class="edra-media-placeholder-content {isDragging ? 'dragging' : ''}"
|
<div class="edra-image-placeholder-uploading">
|
||||||
onclick={handleClick}
|
<div class="spinner"></div>
|
||||||
ondragover={handleDragOver}
|
<span>Uploading image...</span>
|
||||||
ondragleave={handleDragLeave}
|
</div>
|
||||||
ondrop={handleDrop}
|
{:else}
|
||||||
tabindex="0"
|
<button
|
||||||
role="button"
|
class="edra-image-placeholder-option"
|
||||||
aria-label="Insert An Image"
|
onclick={handleDirectUpload}
|
||||||
>
|
onkeydown={handleKeyDown}
|
||||||
<Image class="edra-media-placeholder-icon" />
|
tabindex="0"
|
||||||
<span class="edra-media-placeholder-text">
|
aria-label="Upload Image"
|
||||||
{isDragging ? 'Drop image here' : 'Click to upload or drag & drop'}
|
title="Upload from device"
|
||||||
</span>
|
>
|
||||||
<span class="edra-media-placeholder-subtext"> or paste from clipboard </span>
|
<Upload class="edra-image-placeholder-icon" />
|
||||||
</span>
|
<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>
|
</NodeViewWrapper>
|
||||||
|
|
||||||
<style>
|
<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;
|
transition: all 0.2s ease;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edra-media-placeholder-content.dragging {
|
.edra-image-placeholder-container:hover {
|
||||||
background-color: rgba(59, 130, 246, 0.1);
|
border-color: #d1d5db;
|
||||||
border-color: rgb(59, 130, 246);
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edra-media-placeholder-subtext {
|
.edra-image-placeholder-option {
|
||||||
font-size: 0.875em;
|
display: flex;
|
||||||
opacity: 0.7;
|
flex-direction: column;
|
||||||
margin-top: 0.25rem;
|
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>
|
</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 FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||||
import Button from './Button.svelte'
|
import Button from './Button.svelte'
|
||||||
import Input from './Input.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 { JSONContent } from '@tiptap/core'
|
||||||
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
export let isOpen = false
|
export let isOpen = false
|
||||||
export let initialMode: 'modal' | 'page' = 'modal'
|
export let initialMode: 'modal' | 'page' = 'modal'
|
||||||
|
|
@ -37,6 +41,15 @@
|
||||||
let essayTags = ''
|
let essayTags = ''
|
||||||
let essayTab = 0
|
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 CHARACTER_LIMIT = 280
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
|
@ -50,7 +63,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasContent(): boolean {
|
function hasContent(): boolean {
|
||||||
return characterCount > 0 || linkUrl.length > 0
|
return characterCount > 0 || linkUrl.length > 0 || attachedPhotos.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetComposer() {
|
function resetComposer() {
|
||||||
|
|
@ -65,6 +78,7 @@
|
||||||
linkDescription = ''
|
linkDescription = ''
|
||||||
showLinkFields = false
|
showLinkFields = false
|
||||||
characterCount = 0
|
characterCount = 0
|
||||||
|
attachedPhotos = []
|
||||||
if (editorInstance) {
|
if (editorInstance) {
|
||||||
editorInstance.clear()
|
editorInstance.clear()
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +104,83 @@
|
||||||
showLinkFields = !showLinkFields
|
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 {
|
function getTextFromContent(json: JSONContent): number {
|
||||||
if (!json || !json.content) return 0
|
if (!json || !json.content) return 0
|
||||||
|
|
||||||
|
|
@ -114,7 +205,8 @@
|
||||||
|
|
||||||
let postData: any = {
|
let postData: any = {
|
||||||
content,
|
content,
|
||||||
status: 'published'
|
status: 'published',
|
||||||
|
attachedPhotos: attachedPhotos.map(photo => photo.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (postType === 'essay') {
|
if (postType === 'essay') {
|
||||||
|
|
@ -137,7 +229,7 @@
|
||||||
} else {
|
} else {
|
||||||
postData = {
|
postData = {
|
||||||
...postData,
|
...postData,
|
||||||
type: 'microblog'
|
type: attachedPhotos.length > 0 ? 'photo' : 'microblog'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -165,7 +257,7 @@
|
||||||
|
|
||||||
$: isOverLimit = characterCount > CHARACTER_LIMIT
|
$: isOverLimit = characterCount > CHARACTER_LIMIT
|
||||||
$: canSave =
|
$: canSave =
|
||||||
(postType === 'post' && characterCount > 0 && !isOverLimit) ||
|
(postType === 'post' && (characterCount > 0 || attachedPhotos.length > 0) && !isOverLimit) ||
|
||||||
(showLinkFields && linkUrl.length > 0) ||
|
(showLinkFields && linkUrl.length > 0) ||
|
||||||
(postType === 'essay' && essayTitle.length > 0 && content)
|
(postType === 'essay' && essayTitle.length > 0 && content)
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -238,6 +330,42 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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="composer-footer">
|
||||||
<div class="footer-left">
|
<div class="footer-left">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -275,7 +403,7 @@
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
iconOnly
|
iconOnly
|
||||||
size="icon"
|
size="icon"
|
||||||
onclick={() => alert('Image upload coming soon!')}
|
onclick={handlePhotoUpload}
|
||||||
title="Add image"
|
title="Add image"
|
||||||
class="tool-button"
|
class="tool-button"
|
||||||
>
|
>
|
||||||
|
|
@ -299,6 +427,25 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<div class="footer-right">
|
<div class="footer-right">
|
||||||
|
|
@ -433,6 +580,42 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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="composer-footer">
|
||||||
<div class="footer-left">
|
<div class="footer-left">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -470,7 +653,7 @@
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
iconOnly
|
iconOnly
|
||||||
size="icon"
|
size="icon"
|
||||||
onclick={() => alert('Image upload coming soon!')}
|
onclick={handlePhotoUpload}
|
||||||
title="Add image"
|
title="Add image"
|
||||||
class="tool-button"
|
class="tool-button"
|
||||||
>
|
>
|
||||||
|
|
@ -494,6 +677,25 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</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>
|
||||||
|
|
||||||
<div class="footer-right">
|
<div class="footer-right">
|
||||||
|
|
@ -514,6 +716,35 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/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">
|
<style lang="scss">
|
||||||
@import '$styles/variables.scss';
|
@import '$styles/variables.scss';
|
||||||
|
|
||||||
|
|
@ -756,4 +987,71 @@
|
||||||
border-top: 1px solid $grey-80;
|
border-top: 1px solid $grey-80;
|
||||||
background-color: $grey-90;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -258,6 +258,14 @@ export const commands: Record<string, EdraCommandGroup> = {
|
||||||
editor.chain().focus().insertImagePlaceholder().run()
|
editor.chain().focus().insertImagePlaceholder().run()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
iconName: 'Images',
|
||||||
|
name: 'gallery-placeholder',
|
||||||
|
label: 'Gallery',
|
||||||
|
action: (editor) => {
|
||||||
|
editor.chain().focus().insertGalleryPlaceholder().run()
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
iconName: 'Video',
|
iconName: 'Video',
|
||||||
name: 'video-placeholder',
|
name: 'video-placeholder',
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,8 @@ export const initiateEditor = (
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
codeBlock: false,
|
codeBlock: false,
|
||||||
text: false
|
text: false,
|
||||||
|
image: false
|
||||||
}),
|
}),
|
||||||
SmilieReplacer,
|
SmilieReplacer,
|
||||||
ColorHighlighter,
|
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">
|
<script lang="ts">
|
||||||
import type { NodeViewProps } from '@tiptap/core'
|
import type { NodeViewProps } from '@tiptap/core'
|
||||||
|
import type { Media } from '@prisma/client'
|
||||||
import Image from 'lucide-svelte/icons/image'
|
import Image from 'lucide-svelte/icons/image'
|
||||||
|
import Upload from 'lucide-svelte/icons/upload'
|
||||||
import { NodeViewWrapper } from 'svelte-tiptap'
|
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
|
if (!editor.isEditable) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const imageUrl = prompt('Enter the URL of an image:')
|
isMediaLibraryOpen = true
|
||||||
if (!imageUrl) {
|
}
|
||||||
|
|
||||||
|
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
|
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>
|
</script>
|
||||||
|
|
||||||
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
|
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<div class="edra-media-placeholder-container">
|
||||||
<span
|
{#if isUploading}
|
||||||
class="edra-media-placeholder-content"
|
<div class="edra-media-placeholder-uploading">
|
||||||
onclick={handleClick}
|
<div class="spinner"></div>
|
||||||
tabindex="0"
|
<span>Uploading...</span>
|
||||||
role="button"
|
</div>
|
||||||
aria-label="Insert An Image"
|
{:else}
|
||||||
>
|
<button
|
||||||
<Image class="edra-media-placeholder-icon" />
|
class="edra-media-placeholder-option"
|
||||||
<span class="edra-media-placeholder-text">Insert An Image</span>
|
onclick={handleDirectUpload}
|
||||||
</span>
|
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>
|
</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 VideoExtendedComponent from './components/VideoExtended.svelte'
|
||||||
import { VideoExtended } from '../extensions/video/VideoExtended.js'
|
import { VideoExtended } from '../extensions/video/VideoExtended.js'
|
||||||
import { AudioExtended } from '../extensions/audio/AudiExtended.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 LinkMenu from './menus/link-menu.svelte'
|
||||||
import TableRowMenu from './menus/table/table-row-menu.svelte'
|
import TableRowMenu from './menus/table/table-row-menu.svelte'
|
||||||
import TableColMenu from './menus/table/table-col-menu.svelte'
|
import TableColMenu from './menus/table/table-col-menu.svelte'
|
||||||
|
|
@ -69,11 +73,13 @@
|
||||||
}),
|
}),
|
||||||
AudioPlaceholder(AudioPlaceholderComponent),
|
AudioPlaceholder(AudioPlaceholderComponent),
|
||||||
ImagePlaceholder(ImagePlaceholderComponent),
|
ImagePlaceholder(ImagePlaceholderComponent),
|
||||||
|
GalleryPlaceholder(GalleryPlaceholderComponent),
|
||||||
IFramePlaceholder(IFramePlaceholderComponent),
|
IFramePlaceholder(IFramePlaceholderComponent),
|
||||||
IFrameExtended(IFrameExtendedComponent),
|
IFrameExtended(IFrameExtendedComponent),
|
||||||
VideoPlaceholder(VideoPlaceholderComponent),
|
VideoPlaceholder(VideoPlaceholderComponent),
|
||||||
AudioExtended(AudioExtendedComponent),
|
AudioExtended(AudioExtendedComponent),
|
||||||
ImageExtended(ImageExtendedComponent),
|
ImageExtended(ImageExtendedComponent),
|
||||||
|
GalleryExtended(GalleryExtendedComponent),
|
||||||
VideoExtended(VideoExtendedComponent),
|
VideoExtended(VideoExtendedComponent),
|
||||||
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
|
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
||||||
import Editor from '$lib/components/admin/Editor.svelte'
|
import Editor from '$lib/components/admin/Editor.svelte'
|
||||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.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'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
let post: any = null
|
let post: any = null
|
||||||
|
|
@ -251,6 +253,30 @@
|
||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</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}
|
{:else if post}
|
||||||
<div class="post-composer">
|
<div class="post-composer">
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import EssayForm from '$lib/components/admin/EssayForm.svelte'
|
import EssayForm from '$lib/components/admin/EssayForm.svelte'
|
||||||
import SimplePostForm from '$lib/components/admin/SimplePostForm.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 postType: 'blog' | 'microblog' | 'link' | 'photo' | 'album' = 'blog'
|
||||||
let mounted = false
|
let mounted = false
|
||||||
|
|
@ -21,11 +23,9 @@
|
||||||
<EssayForm mode="create" />
|
<EssayForm mode="create" />
|
||||||
{:else if postType === 'microblog' || postType === 'link'}
|
{:else if postType === 'microblog' || postType === 'link'}
|
||||||
<SimplePostForm {postType} mode="create" />
|
<SimplePostForm {postType} mode="create" />
|
||||||
{:else}
|
{:else if postType === 'photo'}
|
||||||
<!-- TODO: Implement photo and album forms -->
|
<PhotoPostForm mode="create" />
|
||||||
<div style="padding: 2rem; text-align: center;">
|
{:else if postType === 'album'}
|
||||||
<p>Photo and album post types coming soon!</p>
|
<AlbumForm mode="create" />
|
||||||
<a href="/admin/posts" style="color: blue;">← Back to posts</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -71,15 +71,46 @@ export const POST: RequestHandler = async (event) => {
|
||||||
data.publishedAt = new Date()
|
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({
|
const post = await prisma.post.create({
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
slug: data.slug,
|
slug: data.slug,
|
||||||
postType: data.type,
|
postType: data.type,
|
||||||
status: data.status,
|
status: data.status,
|
||||||
content: data.content,
|
content: postContent,
|
||||||
excerpt: data.excerpt,
|
excerpt: data.excerpt,
|
||||||
linkUrl: data.link_url,
|
linkUrl: data.link_url,
|
||||||
|
linkDescription: data.linkDescription,
|
||||||
|
featuredImage: featuredImageId,
|
||||||
tags: data.tags,
|
tags: data.tags,
|
||||||
publishedAt: data.publishedAt
|
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({
|
const post = await prisma.post.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -64,10 +87,13 @@ export const PUT: RequestHandler = async (event) => {
|
||||||
slug: data.slug,
|
slug: data.slug,
|
||||||
postType: data.type,
|
postType: data.type,
|
||||||
status: data.status,
|
status: data.status,
|
||||||
content: data.content,
|
content: postContent,
|
||||||
excerpt: data.excerpt,
|
excerpt: data.excerpt,
|
||||||
linkUrl: data.link_url,
|
linkUrl: data.link_url,
|
||||||
tags: data.tags
|
linkDescription: data.linkDescription,
|
||||||
|
featuredImage: featuredImageId,
|
||||||
|
tags: data.tags,
|
||||||
|
publishedAt: data.publishedAt
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue