Library support in Edra

This commit is contained in:
Justin Edmund 2025-05-31 13:17:38 -07:00
parent c7b4f57ab0
commit b314be59f4
20 changed files with 3294 additions and 110 deletions

View file

@ -417,27 +417,31 @@ const handleImageUpload = async (file) => {
### Dependencies
- [ ] `edra` (Edra editor)
- [ ] `@prisma/client` or `postgres` driver
- [ ] `exifr` for EXIF data extraction
- [ ] `sharp` or Cloudinary SDK for image processing
- [ ] Form validation library (Zod/Valibot)
- [x] `edra` (Edra editor) - Integrated and configured
- [x] `@prisma/client` - Set up with complete schema
- [x] `cloudinary` - SDK integrated for image processing and storage
- [x] Form validation with built-in validation
- [ ] `exifr` for EXIF data extraction (needed for photos system)
### Admin Interface
- [ ] Admin layout and navigation
- [ ] Content type switcher
- [ ] List views for each content type
- [ ] Form builders for Projects
- [ ] Edra wrapper for Posts
- [ ] Photo uploader with drag-and-drop
- [ ] Media library browser
- [x] Admin layout and navigation
- [x] Content type switcher (Dashboard, Projects, Universe, Media)
- [x] List views for projects and posts
- [x] Complete form system for Projects (metadata, branding, styling)
- [x] Edra wrapper for Posts with all post types
- [x] Comprehensive admin component library
- [ ] Photo uploader with drag-and-drop (for albums system)
- [ ] Media library browser modal
### APIs
- [ ] CRUD endpoints for all content types
- [ ] Media upload with progress
- [ ] Bulk operations (delete, publish)
- [x] CRUD endpoints for projects and posts
- [x] Media upload with progress
- [x] Bulk upload operations for media
- [x] Media usage tracking endpoints
- [ ] Albums CRUD endpoints (schema ready, UI needed)
- [ ] Bulk operations (delete, publish) for content
- [ ] Search and filtering endpoints
### Public Display
@ -468,36 +472,50 @@ Based on requirements discussion:
## Current Status (December 2024)
### Completed
- ✅ Database setup with Prisma and PostgreSQL
- ✅ Media management system with Cloudinary integration
- ✅ Admin foundation (layout, navigation, auth, forms, data tables)
- ✅ Edra rich text editor integration for case studies
- ✅ Edra image uploads configured to use media API
- ✅ Local development mode for media uploads (no Cloudinary usage)
- ✅ Project CRUD system with metadata fields
- ✅ Project list view in admin
- ✅ Project CRUD system with metadata fields and enhanced schema
- ✅ Project list view in admin with enhanced UI
- ✅ Project forms with branding (logo, colors) and styling
- ✅ Posts CRUD system with all post types (blog, microblog, link, photo, album)
- ✅ Posts list view and editor in admin
- ✅ Complete database schema matching PRD requirements
- ✅ Media API endpoints with upload, bulk upload, and usage tracking
- ✅ Component library for admin interface (buttons, inputs, modals, etc.)
- ✅ Test page for verifying upload functionality
### In Progress
- 🔄 Posts System - Core functionality implemented
- 🔄 Albums/Photos System - Schema implemented, UI components needed
### Next Steps
1. **Posts System Enhancements**
- Media library modal for photo/album post types
- Auto-save functionality
- Preview mode for posts
- Tags/categories management UI
2. **Projects System Enhancements**
- Technology tag selector
- Featured image picker with media library
- Gallery manager for project images
- Project ordering/display order
1. **Media Library System** (Critical dependency for other features)
4. **Photos & Albums System**
- Album creation and management
- Bulk photo upload interface
- Media library modal component
- Integration with existing media APIs
- Search and filter functionality within media browser
2. **Albums & Photos Management Interface**
- Album creation and management UI
- Bulk photo upload interface with progress
- Photo ordering within albums
- Album cover selection
- EXIF data extraction and display
3. **Enhanced Content Features**
- Photo/album post selectors using media library
- Featured image picker for projects
- Technology tag selector for projects
- Auto-save functionality for all editors
- Gallery manager for project images
## Phased Implementation Plan
@ -534,9 +552,10 @@ Based on requirements discussion:
- [x] Create admin layout component
- [x] Build admin navigation with content type switcher
- [x] Implement admin authentication (basic for now)
- [x] Create reusable form components
- [x] Create reusable form components (Button, Input, Modal, etc.)
- [x] Build data table component for list views
- [x] Add loading and error states
- [x] Create comprehensive admin UI component library
- [ ] Create media library modal component
### Phase 4: Posts System (All Types)
@ -549,29 +568,34 @@ Based on requirements discussion:
- [x] Create posts list view in admin
- [x] Implement post CRUD APIs
- [x] Post editor page with type-specific fields
- [ ] Create photo post selector
- [ ] Build album post selector
- [x] Complete posts database schema with all post types
- [x] Posts administration interface
- [ ] Create photo post selector (needs media library modal)
- [ ] Build album post selector (needs albums system)
- [ ] Add auto-save functionality
### Phase 5: Projects System
- [x] Build project form with all metadata fields
- [ ] Create technology tag selector
- [ ] Implement featured image picker
- [ ] Build gallery manager with drag-and-drop ordering
- [x] Enhanced schema with branding fields (logo, colors)
- [x] Project branding and styling forms
- [x] Add optional Edra editor for case studies
- [x] Create project CRUD APIs
- [x] Build project list view with thumbnails
- [x] Build project list view with enhanced UI
- [ ] Create technology tag selector
- [ ] Implement featured image picker (needs media library modal)
- [ ] Build gallery manager with drag-and-drop ordering
- [ ] Add project ordering functionality
### Phase 6: Photos & Albums System
- [x] Complete database schema for albums and photos
- [x] Photo/album CRUD API endpoints (albums endpoint exists)
- [ ] Create album management interface
- [ ] Build bulk photo uploader with progress
- [ ] Implement EXIF data extraction for photos
- [ ] Implement drag-and-drop photo ordering
- [ ] Add individual photo publishing UI
- [ ] Create photo/album CRUD APIs
- [ ] Build photo metadata editor
- [ ] Implement album cover selection
- [ ] Add "show in universe" toggle for albums

534
PRD-media-library.md Normal file
View 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.

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

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

View file

@ -34,6 +34,10 @@
import { IFramePlaceholder } from '$lib/components/edra/extensions/iframe/IFramePlaceholder.js'
import { IFrameExtended } from '$lib/components/edra/extensions/iframe/IFrameExtended.js'
import IFrameExtendedComponent from '$lib/components/edra/headless/components/IFrameExtended.svelte'
import { GalleryPlaceholder } from '$lib/components/edra/extensions/gallery/GalleryPlaceholder.js'
import GalleryPlaceholderComponent from '$lib/components/edra/headless/components/GalleryPlaceholder.svelte'
import { GalleryExtended } from '$lib/components/edra/extensions/gallery/GalleryExtended.js'
import GalleryExtendedComponent from '$lib/components/edra/headless/components/GalleryExtended.svelte'
// Import Edra styles
import '$lib/components/edra/headless/style.css'
@ -296,11 +300,13 @@
}),
AudioPlaceholder(AudioPlaceholderComponent),
ImagePlaceholder(ImageUploadPlaceholder), // Use our custom component
GalleryPlaceholder(GalleryPlaceholderComponent),
IFramePlaceholder(IFramePlaceholderComponent),
IFrameExtended(IFrameExtendedComponent),
VideoPlaceholder(VideoPlaceholderComponent),
AudioExtended(AudioExtendedComponent),
ImageExtended(ImageExtendedComponent),
GalleryExtended(GalleryExtendedComponent),
VideoExtended(VideoExtendedComponent),
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
],
@ -500,6 +506,46 @@
</svg>
<span>Image</span>
</button>
<button
class="dropdown-item"
onclick={() => {
editor?.chain().focus().insertGalleryPlaceholder().run()
showMediaDropdown = false
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" class="dropdown-icon">
<rect
x="2"
y="4"
width="12"
height="9"
stroke="currentColor"
stroke-width="2"
fill="none"
rx="1"
/>
<rect
x="6"
y="7"
width="12"
height="9"
stroke="currentColor"
stroke-width="2"
fill="none"
rx="1"
/>
<circle cx="6.5" cy="9.5" r="1" stroke="currentColor" stroke-width="1.5" fill="none" />
<path
d="M6 12L8 10L10 12L12 10L15 13"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
<span>Gallery</span>
</button>
<button
class="dropdown-item"
onclick={() => {

View file

@ -1,32 +1,55 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core'
import type { Media } from '@prisma/client'
import Image from 'lucide-svelte/icons/image'
import Upload from 'lucide-svelte/icons/upload'
import Link from 'lucide-svelte/icons/link'
import Grid from 'lucide-svelte/icons/grid-3x3'
import { NodeViewWrapper } from 'svelte-tiptap'
import MediaLibraryModal from './MediaLibraryModal.svelte'
const { editor }: NodeViewProps = $props()
const { editor, deleteNode }: NodeViewProps = $props()
let fileInput: HTMLInputElement
let isDragging = $state(false)
let isMediaLibraryOpen = $state(false)
let isUploading = $state(false)
function handleClick(e: MouseEvent) {
function handleBrowseLibrary(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
// Show options: upload file or enter URL
const choice = confirm('Click OK to upload a file, or Cancel to enter a URL')
if (choice) {
// Upload file
fileInput?.click()
} else {
// Enter URL
const imageUrl = prompt('Enter the URL of an image:')
if (imageUrl) {
editor.chain().focus().setImage({ src: imageUrl }).run()
isMediaLibraryOpen = true
}
function handleDirectUpload(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
fileInput.click()
}
function handleMediaSelect(media: Media | Media[]) {
const selectedMedia = Array.isArray(media) ? media[0] : media
if (selectedMedia) {
// Set a reasonable default width (max 600px)
const displayWidth = selectedMedia.width && selectedMedia.width > 600 ? 600 : selectedMedia.width
editor
.chain()
.focus()
.setImage({
src: selectedMedia.url,
alt: selectedMedia.altText || '',
width: displayWidth,
height: selectedMedia.height,
align: 'center'
})
.run()
}
isMediaLibraryOpen = false
}
function handleMediaLibraryClose() {
isMediaLibraryOpen = false
}
async function handleFileSelect(e: Event) {
@ -53,6 +76,8 @@
return
}
isUploading = true
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
@ -61,6 +86,7 @@
const formData = new FormData()
formData.append('file', file)
formData.append('type', 'image')
const response = await fetch('/api/media/upload', {
method: 'POST',
@ -84,7 +110,7 @@
.focus()
.setImage({
src: media.url,
alt: media.filename || '',
alt: media.altText || '',
width: displayWidth,
height: media.height,
align: 'center'
@ -93,6 +119,8 @@
} catch (error) {
console.error('Image upload failed:', error)
alert('Failed to upload image. Please try again.')
} finally {
isUploading = false
}
}
@ -119,6 +147,16 @@
await uploadFile(file)
}
}
// Handle keyboard navigation
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleBrowseLibrary(e as any)
} else if (e.key === 'Escape') {
deleteNode()
}
}
</script>
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
@ -130,38 +168,124 @@
style="display: none;"
/>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<span
class="edra-media-placeholder-content {isDragging ? 'dragging' : ''}"
onclick={handleClick}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
<div class="edra-image-placeholder-container">
{#if isUploading}
<div class="edra-image-placeholder-uploading">
<div class="spinner"></div>
<span>Uploading image...</span>
</div>
{:else}
<button
class="edra-image-placeholder-option"
onclick={handleDirectUpload}
onkeydown={handleKeyDown}
tabindex="0"
role="button"
aria-label="Insert An Image"
aria-label="Upload Image"
title="Upload from device"
>
<Image class="edra-media-placeholder-icon" />
<span class="edra-media-placeholder-text">
{isDragging ? 'Drop image here' : 'Click to upload or drag & drop'}
</span>
<span class="edra-media-placeholder-subtext"> or paste from clipboard </span>
</span>
<Upload class="edra-image-placeholder-icon" />
<span class="edra-image-placeholder-text">Upload Image</span>
</button>
<button
class="edra-image-placeholder-option"
onclick={handleBrowseLibrary}
onkeydown={handleKeyDown}
tabindex="0"
aria-label="Browse Media Library"
title="Choose from library"
>
<Grid class="edra-image-placeholder-icon" />
<span class="edra-image-placeholder-text">Browse Library</span>
</button>
{/if}
</div>
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={isMediaLibraryOpen}
mode="single"
fileType="image"
onSelect={handleMediaSelect}
onClose={handleMediaLibraryClose}
/>
</NodeViewWrapper>
<style>
.edra-media-placeholder-content {
.edra-image-placeholder-container {
display: flex;
gap: 12px;
padding: 24px;
border: 2px dashed #e5e7eb;
border-radius: 8px;
background: #f9fafb;
transition: all 0.2s ease;
justify-content: center;
align-items: center;
}
.edra-media-placeholder-content.dragging {
background-color: rgba(59, 130, 246, 0.1);
border-color: rgb(59, 130, 246);
.edra-image-placeholder-container:hover {
border-color: #d1d5db;
background: #f3f4f6;
}
.edra-media-placeholder-subtext {
font-size: 0.875em;
opacity: 0.7;
margin-top: 0.25rem;
.edra-image-placeholder-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 20px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s ease;
min-width: 140px;
}
.edra-image-placeholder-option:hover {
border-color: #d1d5db;
background: #f9fafb;
transform: translateY(-1px);
}
.edra-image-placeholder-option:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.edra-image-placeholder-uploading {
display: flex;
align-items: center;
gap: 8px;
padding: 20px;
color: #6b7280;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #f3f4f6;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
:global(.edra-image-placeholder-icon) {
width: 28px;
height: 28px;
color: #6b7280;
}
.edra-image-placeholder-text {
font-size: 14px;
color: #6b7280;
font-weight: 500;
}
</style>

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

View file

@ -7,7 +7,11 @@
import FormFieldWrapper from './FormFieldWrapper.svelte'
import Button from './Button.svelte'
import Input from './Input.svelte'
import MediaLibraryModal from './MediaLibraryModal.svelte'
import MediaDetailsModal from './MediaDetailsModal.svelte'
import SmartImage from '../SmartImage.svelte'
import type { JSONContent } from '@tiptap/core'
import type { Media } from '@prisma/client'
export let isOpen = false
export let initialMode: 'modal' | 'page' = 'modal'
@ -37,6 +41,15 @@
let essayTags = ''
let essayTab = 0
// Photo attachment state
let attachedPhotos: Media[] = []
let isMediaLibraryOpen = false
let fileInput: HTMLInputElement
// Media details modal state
let selectedMedia: Media | null = null
let isMediaDetailsOpen = false
const CHARACTER_LIMIT = 280
const dispatch = createEventDispatcher()
@ -50,7 +63,7 @@
}
function hasContent(): boolean {
return characterCount > 0 || linkUrl.length > 0
return characterCount > 0 || linkUrl.length > 0 || attachedPhotos.length > 0
}
function resetComposer() {
@ -65,6 +78,7 @@
linkDescription = ''
showLinkFields = false
characterCount = 0
attachedPhotos = []
if (editorInstance) {
editorInstance.clear()
}
@ -90,6 +104,83 @@
showLinkFields = !showLinkFields
}
function handlePhotoUpload() {
fileInput.click()
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
for (const file of files) {
if (!file.type.startsWith('image/')) continue
const formData = new FormData()
formData.append('file', file)
formData.append('type', 'image')
// Add auth header if needed
const auth = localStorage.getItem('admin_auth')
const headers: Record<string, string> = {}
if (auth) {
headers.Authorization = `Basic ${auth}`
}
try {
const response = await fetch('/api/media/upload', {
method: 'POST',
headers,
body: formData
})
if (response.ok) {
const media = await response.json()
attachedPhotos = [...attachedPhotos, media]
} else {
console.error('Failed to upload image:', response.status)
}
} catch (error) {
console.error('Error uploading image:', error)
}
}
// Clear the input
input.value = ''
}
function handleMediaSelect(media: Media | Media[]) {
const mediaArray = Array.isArray(media) ? media : [media]
const currentIds = attachedPhotos.map(p => p.id)
const newMedia = mediaArray.filter(m => !currentIds.includes(m.id))
attachedPhotos = [...attachedPhotos, ...newMedia]
}
function handleMediaLibraryClose() {
isMediaLibraryOpen = false
}
function removePhoto(photoId: number) {
attachedPhotos = attachedPhotos.filter(p => p.id !== photoId)
}
function handlePhotoClick(photo: Media) {
selectedMedia = photo
isMediaDetailsOpen = true
}
function handleMediaDetailsClose() {
isMediaDetailsOpen = false
selectedMedia = null
}
function handleMediaUpdate(updatedMedia: Media) {
// Update the photo in the attachedPhotos array
attachedPhotos = attachedPhotos.map(photo =>
photo.id === updatedMedia.id ? updatedMedia : photo
)
}
function getTextFromContent(json: JSONContent): number {
if (!json || !json.content) return 0
@ -114,7 +205,8 @@
let postData: any = {
content,
status: 'published'
status: 'published',
attachedPhotos: attachedPhotos.map(photo => photo.id)
}
if (postType === 'essay') {
@ -137,7 +229,7 @@
} else {
postData = {
...postData,
type: 'microblog'
type: attachedPhotos.length > 0 ? 'photo' : 'microblog'
}
}
@ -165,7 +257,7 @@
$: isOverLimit = characterCount > CHARACTER_LIMIT
$: canSave =
(postType === 'post' && characterCount > 0 && !isOverLimit) ||
(postType === 'post' && (characterCount > 0 || attachedPhotos.length > 0) && !isOverLimit) ||
(showLinkFields && linkUrl.length > 0) ||
(postType === 'essay' && essayTitle.length > 0 && content)
</script>
@ -238,6 +330,42 @@
</div>
{/if}
{#if attachedPhotos.length > 0}
<div class="attached-photos">
{#each attachedPhotos as photo}
<div class="photo-item">
<button
class="photo-button"
onclick={() => handlePhotoClick(photo)}
title="View media details"
>
<img
src={photo.url}
alt={photo.altText || ''}
class="photo-preview"
/>
</button>
<button
class="remove-photo"
onclick={() => removePhoto(photo.id)}
title="Remove photo"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M4 4L12 12M4 12L12 4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
{/each}
</div>
{/if}
<div class="composer-footer">
<div class="footer-left">
<Button
@ -275,7 +403,7 @@
variant="ghost"
iconOnly
size="icon"
onclick={() => alert('Image upload coming soon!')}
onclick={handlePhotoUpload}
title="Add image"
class="tool-button"
>
@ -299,6 +427,25 @@
/>
</svg>
</Button>
<Button
variant="ghost"
iconOnly
size="icon"
onclick={() => (isMediaLibraryOpen = true)}
title="Browse library"
class="tool-button"
>
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path
d="M2 5L9 12L16 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
</div>
<div class="footer-right">
@ -433,6 +580,42 @@
</div>
{/if}
{#if attachedPhotos.length > 0}
<div class="attached-photos">
{#each attachedPhotos as photo}
<div class="photo-item">
<button
class="photo-button"
onclick={() => handlePhotoClick(photo)}
title="View media details"
>
<img
src={photo.url}
alt={photo.altText || ''}
class="photo-preview"
/>
</button>
<button
class="remove-photo"
onclick={() => removePhoto(photo.id)}
title="Remove photo"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M4 4L12 12M4 12L12 4"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
{/each}
</div>
{/if}
<div class="composer-footer">
<div class="footer-left">
<Button
@ -470,7 +653,7 @@
variant="ghost"
iconOnly
size="icon"
onclick={() => alert('Image upload coming soon!')}
onclick={handlePhotoUpload}
title="Add image"
class="tool-button"
>
@ -494,6 +677,25 @@
/>
</svg>
</Button>
<Button
variant="ghost"
iconOnly
size="icon"
onclick={() => (isMediaLibraryOpen = true)}
title="Browse library"
class="tool-button"
>
<svg slot="icon" width="18" height="18" viewBox="0 0 18 18" fill="none">
<path
d="M2 5L9 12L16 5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</Button>
</div>
<div class="footer-right">
@ -514,6 +716,35 @@
{/if}
{/if}
<!-- Hidden file input for photo upload -->
<input
bind:this={fileInput}
type="file"
accept="image/*"
multiple
onchange={handleFileUpload}
style="display: none;"
/>
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={isMediaLibraryOpen}
mode="multiple"
fileType="image"
onSelect={handleMediaSelect}
onClose={handleMediaLibraryClose}
/>
<!-- Media Details Modal -->
{#if selectedMedia}
<MediaDetailsModal
bind:isOpen={isMediaDetailsOpen}
media={selectedMedia}
onClose={handleMediaDetailsClose}
onUpdate={handleMediaUpdate}
/>
{/if}
<style lang="scss">
@import '$styles/variables.scss';
@ -756,4 +987,71 @@
border-top: 1px solid $grey-80;
background-color: $grey-90;
}
.attached-photos {
padding: 0 $unit-3x $unit-2x;
display: flex;
flex-wrap: wrap;
gap: $unit;
}
.photo-item {
position: relative;
.photo-button {
border: none;
background: none;
padding: 0;
cursor: pointer;
display: block;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
}
:global(.photo-preview) {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 12px;
display: block;
}
.remove-photo {
position: absolute;
top: -6px;
right: -6px;
width: 20px;
height: 20px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.8);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
opacity: 0;
&:hover {
background: rgba(0, 0, 0, 0.9);
}
svg {
width: 10px;
height: 10px;
}
}
&:hover .remove-photo {
opacity: 1;
}
}
.inline-composer .attached-photos {
padding: 0 $unit-3x $unit-2x;
}
</style>

View file

@ -258,6 +258,14 @@ export const commands: Record<string, EdraCommandGroup> = {
editor.chain().focus().insertImagePlaceholder().run()
}
},
{
iconName: 'Images',
name: 'gallery-placeholder',
label: 'Gallery',
action: (editor) => {
editor.chain().focus().insertGalleryPlaceholder().run()
}
},
{
iconName: 'Video',
name: 'video-placeholder',

View file

@ -53,7 +53,8 @@ export const initiateEditor = (
}
},
codeBlock: false,
text: false
text: false,
image: false
}),
SmilieReplacer,
ColorHighlighter,

View file

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

View file

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

View file

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

View file

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

View file

@ -1,30 +1,237 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core'
import type { Media } from '@prisma/client'
import Image from 'lucide-svelte/icons/image'
import Upload from 'lucide-svelte/icons/upload'
import { NodeViewWrapper } from 'svelte-tiptap'
const { editor }: NodeViewProps = $props()
import MediaLibraryModal from '../../../admin/MediaLibraryModal.svelte'
import { onMount } from 'svelte'
function handleClick(e: MouseEvent) {
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()
const imageUrl = prompt('Enter the URL of an image:')
if (!imageUrl) {
isMediaLibraryOpen = true
}
function handleDirectUpload(e: MouseEvent) {
if (!editor.isEditable) return
e.preventDefault()
fileInput.click()
}
function handleMediaSelect(media: Media | Media[]) {
const selectedMedia = Array.isArray(media) ? media[0] : media
if (selectedMedia) {
editor.chain().focus().setImage({
src: selectedMedia.url,
alt: selectedMedia.altText || '',
title: selectedMedia.description || ''
}).run()
}
isMediaLibraryOpen = false
}
function handleMediaLibraryClose() {
isMediaLibraryOpen = false
}
async function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const files = input.files
if (!files || files.length === 0) return
const file = files[0]
if (!file.type.startsWith('image/')) {
alert('Please select an image file.')
return
}
editor.chain().focus().setImage({ src: imageUrl }).run()
isUploading = true
try {
const formData = new FormData()
formData.append('file', file)
formData.append('type', 'image')
// Add auth header if needed
const auth = localStorage.getItem('admin_auth')
const headers: Record<string, string> = {}
if (auth) {
headers.Authorization = `Basic ${auth}`
}
const response = await fetch('/api/media/upload', {
method: 'POST',
headers,
body: formData
})
if (response.ok) {
const media = await response.json()
editor.chain().focus().setImage({
src: media.url,
alt: media.altText || '',
title: media.description || ''
}).run()
} else {
console.error('Failed to upload image:', response.status)
alert('Failed to upload image. Please try again.')
}
} catch (error) {
console.error('Error uploading image:', error)
alert('Failed to upload image. Please try again.')
} finally {
isUploading = false
// Clear the input
input.value = ''
}
}
// Handle keyboard navigation
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleBrowseLibrary(e as any)
} else if (e.key === 'Escape') {
deleteNode()
}
}
</script>
<NodeViewWrapper class="edra-media-placeholder-wrapper" contenteditable="false">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<span
class="edra-media-placeholder-content"
onclick={handleClick}
<div class="edra-media-placeholder-container">
{#if isUploading}
<div class="edra-media-placeholder-uploading">
<div class="spinner"></div>
<span>Uploading...</span>
</div>
{:else}
<button
class="edra-media-placeholder-option"
onclick={handleDirectUpload}
onkeydown={handleKeyDown}
tabindex="0"
role="button"
aria-label="Insert An Image"
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">Insert An Image</span>
</span>
<span class="edra-media-placeholder-text">Browse Library</span>
</button>
{/if}
</div>
<!-- Hidden file input -->
<input
bind:this={fileInput}
type="file"
accept="image/*"
onchange={handleFileUpload}
style="display: none;"
/>
<!-- Media Library Modal -->
<MediaLibraryModal
bind:isOpen={isMediaLibraryOpen}
mode="single"
fileType="image"
onSelect={handleMediaSelect}
onClose={handleMediaLibraryClose}
/>
</NodeViewWrapper>
<style>
.edra-media-placeholder-container {
display: flex;
gap: 12px;
padding: 16px;
border: 2px dashed #e5e7eb;
border-radius: 8px;
background: #f9fafb;
transition: all 0.2s ease;
justify-content: center;
align-items: center;
}
.edra-media-placeholder-container:hover {
border-color: #d1d5db;
background: #f3f4f6;
}
.edra-media-placeholder-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid #e5e7eb;
border-radius: 6px;
background: white;
cursor: pointer;
transition: all 0.2s ease;
min-width: 120px;
}
.edra-media-placeholder-option:hover {
border-color: #d1d5db;
background: #f9fafb;
transform: translateY(-1px);
}
.edra-media-placeholder-option:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.edra-media-placeholder-uploading {
display: flex;
align-items: center;
gap: 8px;
padding: 16px;
color: #6b7280;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #f3f4f6;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
:global(.edra-media-placeholder-icon) {
width: 24px;
height: 24px;
color: #6b7280;
}
.edra-media-placeholder-text {
font-size: 14px;
color: #6b7280;
font-weight: 500;
}
</style>

View file

@ -25,6 +25,10 @@
import VideoExtendedComponent from './components/VideoExtended.svelte'
import { VideoExtended } from '../extensions/video/VideoExtended.js'
import { AudioExtended } from '../extensions/audio/AudiExtended.js'
import { GalleryPlaceholder } from '../extensions/gallery/GalleryPlaceholder.js'
import GalleryPlaceholderComponent from './components/GalleryPlaceholder.svelte'
import { GalleryExtended } from '../extensions/gallery/GalleryExtended.js'
import GalleryExtendedComponent from './components/GalleryExtended.svelte'
import LinkMenu from './menus/link-menu.svelte'
import TableRowMenu from './menus/table/table-row-menu.svelte'
import TableColMenu from './menus/table/table-col-menu.svelte'
@ -69,11 +73,13 @@
}),
AudioPlaceholder(AudioPlaceholderComponent),
ImagePlaceholder(ImagePlaceholderComponent),
GalleryPlaceholder(GalleryPlaceholderComponent),
IFramePlaceholder(IFramePlaceholderComponent),
IFrameExtended(IFrameExtendedComponent),
VideoPlaceholder(VideoPlaceholderComponent),
AudioExtended(AudioExtendedComponent),
ImageExtended(ImageExtendedComponent),
GalleryExtended(GalleryExtendedComponent),
VideoExtended(VideoExtendedComponent),
...(showSlashCommands ? [slashcommand(SlashCommandList)] : [])
],

View file

@ -7,6 +7,8 @@
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
import Editor from '$lib/components/admin/Editor.svelte'
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
import PhotoPostForm from '$lib/components/admin/PhotoPostForm.svelte'
import AlbumForm from '$lib/components/admin/AlbumForm.svelte'
import type { JSONContent } from '@tiptap/core'
let post: any = null
@ -251,6 +253,30 @@
<div class="loading-container">
<LoadingSpinner />
</div>
{:else if post && postType === 'photo'}
<PhotoPostForm
mode="edit"
postId={post.id}
initialData={{
title: post.title,
content: post.content,
featuredImage: post.featuredImage,
status: post.status,
tags: post.tags
}}
/>
{:else if post && postType === 'album'}
<AlbumForm
mode="edit"
postId={post.id}
initialData={{
title: post.title,
content: post.content,
gallery: post.gallery || [],
status: post.status,
tags: post.tags
}}
/>
{:else if post}
<div class="post-composer">
<div class="main-content">

View file

@ -3,6 +3,8 @@
import { onMount } from 'svelte'
import EssayForm from '$lib/components/admin/EssayForm.svelte'
import SimplePostForm from '$lib/components/admin/SimplePostForm.svelte'
import PhotoPostForm from '$lib/components/admin/PhotoPostForm.svelte'
import AlbumForm from '$lib/components/admin/AlbumForm.svelte'
let postType: 'blog' | 'microblog' | 'link' | 'photo' | 'album' = 'blog'
let mounted = false
@ -21,11 +23,9 @@
<EssayForm mode="create" />
{:else if postType === 'microblog' || postType === 'link'}
<SimplePostForm {postType} mode="create" />
{:else}
<!-- TODO: Implement photo and album forms -->
<div style="padding: 2rem; text-align: center;">
<p>Photo and album post types coming soon!</p>
<a href="/admin/posts" style="color: blue;">← Back to posts</a>
</div>
{:else if postType === 'photo'}
<PhotoPostForm mode="create" />
{:else if postType === 'album'}
<AlbumForm mode="create" />
{/if}
{/if}

View file

@ -71,15 +71,46 @@ export const POST: RequestHandler = async (event) => {
data.publishedAt = new Date()
}
// Handle photo attachments for posts
let featuredImageId = data.featuredImage
if (data.attachedPhotos && data.attachedPhotos.length > 0 && !featuredImageId) {
// Use first attached photo as featured image for photo posts
featuredImageId = data.attachedPhotos[0]
}
// Handle album gallery - use first image as featured image
if (data.gallery && data.gallery.length > 0 && !featuredImageId) {
// Get the media URL for the first gallery item
const firstMedia = await prisma.media.findUnique({
where: { id: data.gallery[0] },
select: { url: true }
})
if (firstMedia) {
featuredImageId = firstMedia.url
}
}
// For albums, store gallery IDs in content field as a special structure
let postContent = data.content
if (data.type === 'album' && data.gallery) {
postContent = {
type: 'album',
gallery: data.gallery,
description: data.content
}
}
const post = await prisma.post.create({
data: {
title: data.title,
slug: data.slug,
postType: data.type,
status: data.status,
content: data.content,
content: postContent,
excerpt: data.excerpt,
linkUrl: data.link_url,
linkDescription: data.linkDescription,
featuredImage: featuredImageId,
tags: data.tags,
publishedAt: data.publishedAt
}

View file

@ -57,6 +57,29 @@ export const PUT: RequestHandler = async (event) => {
}
}
// Handle album gallery updates
let featuredImageId = data.featuredImage
if (data.gallery && data.gallery.length > 0 && !featuredImageId) {
// Get the media URL for the first gallery item
const firstMedia = await prisma.media.findUnique({
where: { id: data.gallery[0] },
select: { url: true }
})
if (firstMedia) {
featuredImageId = firstMedia.url
}
}
// For albums, store gallery IDs in content field as a special structure
let postContent = data.content
if (data.type === 'album' && data.gallery) {
postContent = {
type: 'album',
gallery: data.gallery,
description: data.content
}
}
const post = await prisma.post.update({
where: { id },
data: {
@ -64,10 +87,13 @@ export const PUT: RequestHandler = async (event) => {
slug: data.slug,
postType: data.type,
status: data.status,
content: data.content,
content: postContent,
excerpt: data.excerpt,
linkUrl: data.link_url,
tags: data.tags
linkDescription: data.linkDescription,
featuredImage: featuredImageId,
tags: data.tags,
publishedAt: data.publishedAt
}
})