We ran the linter

This commit is contained in:
Justin Edmund 2025-06-11 00:54:05 -07:00
parent c6ce13a530
commit 3ba7f6b762
30 changed files with 751 additions and 546 deletions

View file

@ -180,6 +180,7 @@ The system now uses a dedicated `media_usage` table for robust tracking:
``` ```
**Benefits:** **Benefits:**
- Accurate usage tracking across all content types - Accurate usage tracking across all content types
- Efficient queries for usage information - Efficient queries for usage information
- Safe bulk deletion with automatic reference cleanup - Safe bulk deletion with automatic reference cleanup
@ -287,39 +288,46 @@ const ImageBlock = {
```typescript ```typescript
// Projects // Projects
GET /api/projects GET / api / projects
POST /api/projects POST / api / projects
GET /api/projects/[slug] GET / api / projects / [slug]
PUT /api/projects/[id] PUT / api / projects / [id]
DELETE /api/projects/[id] DELETE / api / projects / [id]
// Posts // Posts
GET /api/posts GET / api / posts
POST /api/posts POST / api / posts
GET /api/posts/[slug] GET / api / posts / [slug]
PUT /api/posts/[id] PUT / api / posts / [id]
DELETE /api/posts/[id] DELETE / api / posts / [id]
// Albums & Photos // Albums & Photos
GET /api/albums GET / api / albums
POST /api/albums POST / api / albums
GET /api/albums/[slug] GET / api / albums / [slug]
PUT /api/albums/[id] PUT / api / albums / [id]
DELETE /api/albums/[id] DELETE / api / albums / [id]
POST /api/albums/[id]/photos POST / api / albums / [id] / photos
DELETE /api/photos/[id] DELETE / api / photos / [id]
PUT /api/photos/[id]/order PUT / api / photos / [id] / order
// Media Management // Media Management
POST /api/media/upload // Single file upload POST / api / media / upload // Single file upload
POST /api/media/bulk-upload // Multiple file upload POST / api / media / bulk - upload // Multiple file upload
GET /api/media // Browse with filters, pagination GET / api / media // Browse with filters, pagination
GET /api/media/[id] // Get single media item GET / api / media / [id] // Get single media item
PUT /api/media/[id] // Update media (alt text, description) PUT / api / media / [id] // Update media (alt text, description)
DELETE /api/media/[id] // Delete single media item DELETE / api / media / [id] // Delete single media item
DELETE /api/media/bulk-delete // Delete multiple media items DELETE / api / media / bulk -
GET /api/media/[id]/usage // Check where media is used delete (
POST /api/media/backfill-usage // Backfill usage tracking for existing content // Delete multiple media items
GET
) /
api /
media /
[id] /
usage // Check where media is used
POST / api / media / backfill - usage // Backfill usage tracking for existing content
``` ```
### 8. Media Management & Cleanup ### 8. Media Management & Cleanup
@ -580,7 +588,7 @@ Based on requirements discussion:
- Add photography toggle to media details modal - Add photography toggle to media details modal
- Add photography indicator pills in admin interface - Add photography indicator pills in admin interface
2. **Albums & Photos Management Interface** 2. **Albums & Photos Management Interface**
- Album creation and management UI with photography toggle - Album creation and management UI with photography toggle
- Bulk photo upload interface with progress - Bulk photo upload interface with progress
@ -682,7 +690,7 @@ Based on requirements discussion:
### Phase 6: Content Simplification & Photo Curation ### Phase 6: Content Simplification & Photo Curation
- [x] Add `isPhotography` field to Media table (migration) - [x] Add `isPhotography` field to Media table (migration)
- [x] Add `isPhotography` field to Album table (migration) - [x] Add `isPhotography` field to Album table (migration)
- [x] Simplify post types to "post" and "essay" only - [x] Simplify post types to "post" and "essay" only
- [x] Update UniverseComposer to use simplified post types - [x] Update UniverseComposer to use simplified post types
- [x] Add photography toggle to MediaDetailsModal - [x] Add photography toggle to MediaDetailsModal
@ -712,7 +720,7 @@ Based on requirements discussion:
- [x] Replace static Work page with dynamic data - [x] Replace static Work page with dynamic data
- [x] Update project detail pages - [x] Update project detail pages
- [x] Build Universe mixed feed component - [x] Build Universe mixed feed component
- [x] Create different card types for each post type - [x] Create different card types for each post type
- [x] Update Photos page with dynamic albums/photos - [x] Update Photos page with dynamic albums/photos
- [x] Implement individual photo pages - [x] Implement individual photo pages
@ -755,10 +763,12 @@ Based on requirements discussion:
### Design Decisions Made (May 2024) ### Design Decisions Made (May 2024)
1. **Simplified Post Types**: Reduced from 5 types (blog, microblog, link, photo, album) to 2 types: 1. **Simplified Post Types**: Reduced from 5 types (blog, microblog, link, photo, album) to 2 types:
- **Post**: Simple content with optional attachments (handles previous microblog, link, photo use cases) - **Post**: Simple content with optional attachments (handles previous microblog, link, photo use cases)
- **Essay**: Full editor with title/metadata + attachments (handles previous blog use cases) - **Essay**: Full editor with title/metadata + attachments (handles previous blog use cases)
2. **Photo Curation Strategy**: Dual-level curation system: 2. **Photo Curation Strategy**: Dual-level curation system:
- **Media Level**: `isPhotography` boolean - stars individual media for photo experience - **Media Level**: `isPhotography` boolean - stars individual media for photo experience
- **Album Level**: `isPhotography` boolean - marks entire albums for photo experience - **Album Level**: `isPhotography` boolean - marks entire albums for photo experience
- **Mixed Content**: Photography albums can contain non-photography media (Option A) - **Mixed Content**: Photography albums can contain non-photography media (Option A)
@ -774,18 +784,21 @@ Based on requirements discussion:
### Implementation Task List ### Implementation Task List
#### Phase 1: Database Updates #### Phase 1: Database Updates
- [x] Create migration to add `isPhotography` field to Media table - [x] Create migration to add `isPhotography` field to Media table
- [x] Create migration to add `isPhotography` field to Album table - [x] Create migration to add `isPhotography` field to Album table
- [x] Update Prisma schema with new fields - [x] Update Prisma schema with new fields
- [x] Test migrations on local database - [x] Test migrations on local database
#### Phase 2: API Updates #### Phase 2: API Updates
- [x] Update Media API endpoints to handle `isPhotography` flag - [x] Update Media API endpoints to handle `isPhotography` flag
- [x] Update Album API endpoints to handle `isPhotography` flag - [x] Update Album API endpoints to handle `isPhotography` flag
- [x] Update media usage tracking to work with new flags - [x] Update media usage tracking to work with new flags
- [x] Add filtering capabilities for photography content - [x] Add filtering capabilities for photography content
#### Phase 3: Admin Interface Updates #### Phase 3: Admin Interface Updates
- [x] Add photography toggle to MediaDetailsModal - [x] Add photography toggle to MediaDetailsModal
- [x] Add photography indicator pills for media items (grid and list views) - [x] Add photography indicator pills for media items (grid and list views)
- [x] Add photography indicator pills for albums - [x] Add photography indicator pills for albums
@ -793,6 +806,7 @@ Based on requirements discussion:
- [x] Add bulk photography operations (mark/unmark multiple items) - [x] Add bulk photography operations (mark/unmark multiple items)
#### Phase 4: Post Type Simplification #### Phase 4: Post Type Simplification
- [x] Update UniverseComposer to use only "post" and "essay" types - [x] Update UniverseComposer to use only "post" and "essay" types
- [x] Remove complex post type selector UI - [x] Remove complex post type selector UI
- [x] Update post creation flows - [x] Update post creation flows
@ -800,6 +814,7 @@ Based on requirements discussion:
- [x] Update post display logic to handle simplified types - [x] Update post display logic to handle simplified types
#### Phase 5: Album Management System #### Phase 5: Album Management System
- [x] Create album creation/editing interface with photography toggle - [x] Create album creation/editing interface with photography toggle
- [x] Build album list view with photography indicators - [x] Build album list view with photography indicators
- [ ] **Critical Missing Feature: Album Photo Management** - [ ] **Critical Missing Feature: Album Photo Management**
@ -814,6 +829,7 @@ Based on requirements discussion:
- [ ] Add bulk photo upload to albums with automatic photography detection - [ ] Add bulk photo upload to albums with automatic photography detection
#### Phase 6: Photography Experience #### Phase 6: Photography Experience
- [ ] Build photography album filtering in admin - [ ] Build photography album filtering in admin
- [ ] Create photography-focused views and workflows - [ ] Create photography-focused views and workflows
- [ ] Add batch operations for photo curation - [ ] Add batch operations for photo curation

View file

@ -24,12 +24,14 @@ Upgrade the current JSON-based tag system to a relational database model with ad
## Current State vs Target State ## Current State vs Target State
### Current Implementation ### Current Implementation
- Tags stored as JSON arrays: `tags: ['announcement', 'meta', 'cms']` - Tags stored as JSON arrays: `tags: ['announcement', 'meta', 'cms']`
- Simple display-only functionality - Simple display-only functionality
- No querying capabilities - No querying capabilities
- Manual tag input with Add button - Manual tag input with Add button
### Target Implementation ### Target Implementation
- Relational many-to-many tag system - Relational many-to-many tag system
- Full CRUD operations for tags - Full CRUD operations for tags
- Advanced filtering and search - Advanced filtering and search
@ -82,10 +84,10 @@ model Tag {
color String? @db.VarChar(7) // Hex color color String? @db.VarChar(7) // Hex color
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// Relations // Relations
posts PostTag[] posts PostTag[]
@@index([name]) @@index([name])
@@index([slug]) @@index([slug])
} }
@ -95,11 +97,11 @@ model PostTag {
postId Int postId Int
tagId Int tagId Int
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// Relations // Relations
post Post @relation(fields: [postId], references: [id], onDelete: Cascade) post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@unique([postId, tagId]) @@unique([postId, tagId])
@@index([postId]) @@index([postId])
@@index([tagId]) @@index([tagId])
@ -117,7 +119,9 @@ model Post {
### 1. Tag Management Interface ### 1. Tag Management Interface
#### Admin Tag Manager (`/admin/tags`) #### Admin Tag Manager (`/admin/tags`)
- **Tag List View** - **Tag List View**
- DataTable with tag name, usage count, created date - DataTable with tag name, usage count, created date
- Search and filter capabilities - Search and filter capabilities
- Bulk operations (delete, merge, rename) - Bulk operations (delete, merge, rename)
@ -130,7 +134,9 @@ model Post {
- Merge with other tags functionality - Merge with other tags functionality
#### Tag Analytics Dashboard #### Tag Analytics Dashboard
- **Usage Statistics** - **Usage Statistics**
- Most/least used tags - Most/least used tags
- Tag usage trends over time - Tag usage trends over time
- Orphaned tags (no posts) - Orphaned tags (no posts)
@ -144,6 +150,7 @@ model Post {
### 2. Enhanced Tag Input Component (`TagInput.svelte`) ### 2. Enhanced Tag Input Component (`TagInput.svelte`)
#### Features #### Features
- **Typeahead Search**: Real-time search of existing tags - **Typeahead Search**: Real-time search of existing tags
- **Keyboard Navigation**: Arrow keys to navigate suggestions - **Keyboard Navigation**: Arrow keys to navigate suggestions
- **Instant Add**: Press Enter to add tag without button click - **Instant Add**: Press Enter to add tag without button click
@ -152,137 +159,150 @@ model Post {
- **Quick Actions**: Backspace to remove last tag - **Quick Actions**: Backspace to remove last tag
#### Component API #### Component API
```typescript ```typescript
interface TagInputProps { interface TagInputProps {
tags: string[] | Tag[] // Current tags tags: string[] | Tag[] // Current tags
suggestions?: Tag[] // Available tags for typeahead suggestions?: Tag[] // Available tags for typeahead
placeholder?: string // Input placeholder text placeholder?: string // Input placeholder text
maxTags?: number // Maximum number of tags maxTags?: number // Maximum number of tags
allowNew?: boolean // Allow creating new tags allowNew?: boolean // Allow creating new tags
size?: 'small' | 'medium' | 'large' size?: 'small' | 'medium' | 'large'
disabled?: boolean disabled?: boolean
onTagAdd?: (tag: Tag) => void onTagAdd?: (tag: Tag) => void
onTagRemove?: (tag: Tag) => void onTagRemove?: (tag: Tag) => void
onTagCreate?: (name: string) => void onTagCreate?: (name: string) => void
} }
``` ```
#### Svelte 5 Implementation #### Svelte 5 Implementation
```svelte ```svelte
<script lang="ts"> <script lang="ts">
let { let {
tags = $bindable([]), tags = $bindable([]),
suggestions = [], suggestions = [],
placeholder = "Add tags...", placeholder = 'Add tags...',
maxTags = 10, maxTags = 10,
allowNew = true, allowNew = true,
size = 'medium', size = 'medium',
disabled = false, disabled = false,
onTagAdd, onTagAdd,
onTagRemove, onTagRemove,
onTagCreate onTagCreate
}: TagInputProps = $props() }: TagInputProps = $props()
let inputValue = $state('') let inputValue = $state('')
let showSuggestions = $state(false) let showSuggestions = $state(false)
let selectedIndex = $state(-1) let selectedIndex = $state(-1)
let inputElement: HTMLInputElement let inputElement: HTMLInputElement
// Filtered suggestions based on input // Filtered suggestions based on input
let filteredSuggestions = $derived( let filteredSuggestions = $derived(
suggestions.filter(tag => suggestions.filter(
tag.name.toLowerCase().includes(inputValue.toLowerCase()) && (tag) =>
!tags.some(t => t.id === tag.id) tag.name.toLowerCase().includes(inputValue.toLowerCase()) &&
) !tags.some((t) => t.id === tag.id)
) )
)
// Handle keyboard navigation // Handle keyboard navigation
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
switch (e.key) { switch (e.key) {
case 'Enter': case 'Enter':
e.preventDefault() e.preventDefault()
if (selectedIndex >= 0) { if (selectedIndex >= 0) {
addExistingTag(filteredSuggestions[selectedIndex]) addExistingTag(filteredSuggestions[selectedIndex])
} else if (inputValue.trim() && allowNew) { } else if (inputValue.trim() && allowNew) {
createNewTag(inputValue.trim()) createNewTag(inputValue.trim())
} }
break break
case 'ArrowDown': case 'ArrowDown':
e.preventDefault() e.preventDefault()
selectedIndex = Math.min(selectedIndex + 1, filteredSuggestions.length - 1) selectedIndex = Math.min(selectedIndex + 1, filteredSuggestions.length - 1)
break break
case 'ArrowUp': case 'ArrowUp':
e.preventDefault() e.preventDefault()
selectedIndex = Math.max(selectedIndex - 1, -1) selectedIndex = Math.max(selectedIndex - 1, -1)
break break
case 'Backspace': case 'Backspace':
if (!inputValue && tags.length > 0) { if (!inputValue && tags.length > 0) {
removeTag(tags[tags.length - 1]) removeTag(tags[tags.length - 1])
} }
break break
case 'Escape': case 'Escape':
showSuggestions = false showSuggestions = false
selectedIndex = -1 selectedIndex = -1
break break
} }
} }
</script> </script>
``` ```
### 3. Post Filtering by Tags ### 3. Post Filtering by Tags
#### Frontend Components #### Frontend Components
- **Tag Filter Bar**: Multi-select tag filtering - **Tag Filter Bar**: Multi-select tag filtering
- **Tag Cloud**: Visual tag representation with usage counts - **Tag Cloud**: Visual tag representation with usage counts
- **Search Integration**: Combine text search with tag filters - **Search Integration**: Combine text search with tag filters
#### API Endpoints #### API Endpoints
```typescript ```typescript
// GET /api/posts?tags=javascript,react&operation=AND // GET /api/posts?tags=javascript,react&operation=AND
// GET /api/posts?tags=design,ux&operation=OR // GET /api/posts?tags=design,ux&operation=OR
interface PostsQueryParams { interface PostsQueryParams {
tags?: string[] // Tag names or IDs tags?: string[] // Tag names or IDs
operation?: 'AND' | 'OR' // How to combine multiple tags operation?: 'AND' | 'OR' // How to combine multiple tags
page?: number page?: number
limit?: number limit?: number
status?: 'published' | 'draft' status?: 'published' | 'draft'
} }
// GET /api/tags/suggest?q=java // GET /api/tags/suggest?q=java
interface TagSuggestResponse { interface TagSuggestResponse {
tags: Array<{ tags: Array<{
id: number id: number
name: string name: string
slug: string slug: string
usageCount: number usageCount: number
}> }>
} }
``` ```
### 4. Related Posts Feature ### 4. Related Posts Feature
#### Implementation #### Implementation
- **Algorithm**: Find posts sharing the most tags - **Algorithm**: Find posts sharing the most tags
- **Weighting**: Consider tag importance and recency - **Weighting**: Consider tag importance and recency
- **Exclusions**: Don't show current post in related list - **Exclusions**: Don't show current post in related list
- **Limit**: Show 3-6 related posts maximum - **Limit**: Show 3-6 related posts maximum
#### Component (`RelatedPosts.svelte`) #### Component (`RelatedPosts.svelte`)
```svelte ```svelte
<script lang="ts"> <script lang="ts">
let { postId, tags, limit = 4 }: { let {
postId: number postId,
tags: Tag[] tags,
limit?: number limit = 4
} = $props() }: {
postId: number
tags: Tag[]
limit?: number
} = $props()
let relatedPosts = $state<Post[]>([]) let relatedPosts = $state<Post[]>([])
$effect(async () => { $effect(async () => {
const tagIds = tags.map(t => t.id) const tagIds = tags.map((t) => t.id)
const response = await fetch(`/api/posts/related?postId=${postId}&tagIds=${tagIds.join(',')}&limit=${limit}`) const response = await fetch(
relatedPosts = await response.json() `/api/posts/related?postId=${postId}&tagIds=${tagIds.join(',')}&limit=${limit}`
}) )
relatedPosts = await response.json()
})
</script> </script>
``` ```
@ -293,24 +313,24 @@ interface TagSuggestResponse {
```typescript ```typescript
// GET /api/tags - List all tags // GET /api/tags - List all tags
interface TagsResponse { interface TagsResponse {
tags: Tag[] tags: Tag[]
total: number total: number
page: number page: number
limit: number limit: number
} }
// POST /api/tags - Create new tag // POST /api/tags - Create new tag
interface CreateTagRequest { interface CreateTagRequest {
name: string name: string
description?: string description?: string
color?: string color?: string
} }
// PUT /api/tags/[id] - Update tag // PUT /api/tags/[id] - Update tag
interface UpdateTagRequest { interface UpdateTagRequest {
name?: string name?: string
description?: string description?: string
color?: string color?: string
} }
// DELETE /api/tags/[id] - Delete tag // DELETE /api/tags/[id] - Delete tag
@ -318,23 +338,23 @@ interface UpdateTagRequest {
// POST /api/tags/merge - Merge tags // POST /api/tags/merge - Merge tags
interface MergeTagsRequest { interface MergeTagsRequest {
sourceTagIds: number[] sourceTagIds: number[]
targetTagId: number targetTagId: number
} }
// GET /api/tags/[id]/posts - Get posts for tag // GET /api/tags/[id]/posts - Get posts for tag
interface TagPostsResponse { interface TagPostsResponse {
posts: Post[] posts: Post[]
tag: Tag tag: Tag
total: number total: number
} }
// GET /api/tags/analytics - Tag usage analytics // GET /api/tags/analytics - Tag usage analytics
interface TagAnalyticsResponse { interface TagAnalyticsResponse {
mostUsed: Array<{ tag: Tag; count: number }> mostUsed: Array<{ tag: Tag; count: number }>
leastUsed: Array<{ tag: Tag; count: number }> leastUsed: Array<{ tag: Tag; count: number }>
trending: Array<{ tag: Tag; growth: number }> trending: Array<{ tag: Tag; growth: number }>
orphaned: Tag[] orphaned: Tag[]
} }
``` ```
@ -343,20 +363,20 @@ interface TagAnalyticsResponse {
```typescript ```typescript
// GET /api/posts/related?postId=123&tagIds=1,2,3&limit=4 // GET /api/posts/related?postId=123&tagIds=1,2,3&limit=4
interface RelatedPostsResponse { interface RelatedPostsResponse {
posts: Array<{ posts: Array<{
id: number id: number
title: string title: string
slug: string slug: string
excerpt?: string excerpt?: string
publishedAt: string publishedAt: string
tags: Tag[] tags: Tag[]
sharedTagsCount: number // Number of tags in common sharedTagsCount: number // Number of tags in common
}> }>
} }
// PUT /api/posts/[id]/tags - Update post tags // PUT /api/posts/[id]/tags - Update post tags
interface UpdatePostTagsRequest { interface UpdatePostTagsRequest {
tagIds: number[] tagIds: number[]
} }
``` ```
@ -365,6 +385,7 @@ interface UpdatePostTagsRequest {
### 1. TagInput Component Features ### 1. TagInput Component Features
#### Visual States #### Visual States
- **Default**: Clean input with placeholder - **Default**: Clean input with placeholder
- **Focused**: Show suggestions dropdown - **Focused**: Show suggestions dropdown
- **Typing**: Filter and highlight matches - **Typing**: Filter and highlight matches
@ -373,6 +394,7 @@ interface UpdatePostTagsRequest {
- **Full**: Disable input when max tags reached - **Full**: Disable input when max tags reached
#### Accessibility #### Accessibility
- **ARIA Labels**: Proper labeling for screen readers - **ARIA Labels**: Proper labeling for screen readers
- **Keyboard Navigation**: Full keyboard accessibility - **Keyboard Navigation**: Full keyboard accessibility
- **Focus Management**: Logical tab order - **Focus Management**: Logical tab order
@ -381,61 +403,59 @@ interface UpdatePostTagsRequest {
### 2. Tag Display Components ### 2. Tag Display Components
#### TagPill Component #### TagPill Component
```svelte ```svelte
<script lang="ts"> <script lang="ts">
let { let {
tag, tag,
size = 'medium', size = 'medium',
removable = false, removable = false,
clickable = false, clickable = false,
showCount = false, showCount = false,
onRemove, onRemove,
onClick onClick
}: TagPillProps = $props() }: TagPillProps = $props()
</script> </script>
<span <span
class="tag-pill tag-pill-{size}" class="tag-pill tag-pill-{size}"
style="--tag-color: {tag.color}" style="--tag-color: {tag.color}"
class:clickable class:clickable
class:removable class:removable
onclick={onClick} onclick={onClick}
> >
{tag.name} {tag.name}
{#if showCount} {#if showCount}
<span class="tag-count">({tag.usageCount})</span> <span class="tag-count">({tag.usageCount})</span>
{/if} {/if}
{#if removable} {#if removable}
<button onclick={onRemove} class="tag-remove">×</button> <button onclick={onRemove} class="tag-remove">×</button>
{/if} {/if}
</span> </span>
``` ```
#### TagCloud Component #### TagCloud Component
```svelte ```svelte
<script lang="ts"> <script lang="ts">
let { let { tags, maxTags = 50, minFontSize = 12, maxFontSize = 24, onClick }: TagCloudProps = $props()
tags,
maxTags = 50,
minFontSize = 12,
maxFontSize = 24,
onClick
}: TagCloudProps = $props()
// Calculate font sizes based on usage // Calculate font sizes based on usage
let tagSizes = $derived(calculateTagSizes(tags, minFontSize, maxFontSize)) let tagSizes = $derived(calculateTagSizes(tags, minFontSize, maxFontSize))
</script> </script>
``` ```
### 3. Admin Interface Updates ### 3. Admin Interface Updates
#### Posts List with Tag Filtering #### Posts List with Tag Filtering
- **Filter Bar**: Multi-select tag filter above posts list - **Filter Bar**: Multi-select tag filter above posts list
- **Tag Pills**: Show tags on each post item - **Tag Pills**: Show tags on each post item
- **Quick Filter**: Click tag to filter by that tag - **Quick Filter**: Click tag to filter by that tag
- **Clear Filters**: Easy way to reset all filters - **Clear Filters**: Easy way to reset all filters
#### Posts Edit Form Integration #### Posts Edit Form Integration
- **Replace Current**: Swap existing tag input with new TagInput - **Replace Current**: Swap existing tag input with new TagInput
- **Preserve UX**: Maintain current metadata popover - **Preserve UX**: Maintain current metadata popover
- **Tag Management**: Quick access to create/edit tags - **Tag Management**: Quick access to create/edit tags
@ -443,12 +463,15 @@ interface UpdatePostTagsRequest {
## Migration Strategy ## Migration Strategy
### Phase 1: Database Migration (Week 1) ### Phase 1: Database Migration (Week 1)
1. **Create Migration Script** 1. **Create Migration Script**
- Create new tables (tags, post_tags) - Create new tables (tags, post_tags)
- Migrate existing JSON tags to relational format - Migrate existing JSON tags to relational format
- Create indexes for performance - Create indexes for performance
2. **Data Migration** 2. **Data Migration**
- Extract unique tags from existing posts - Extract unique tags from existing posts
- Create tag records with auto-generated slugs - Create tag records with auto-generated slugs
- Create post_tag relationships - Create post_tag relationships
@ -459,12 +482,15 @@ interface UpdatePostTagsRequest {
- Dual-write to both systems during transition - Dual-write to both systems during transition
### Phase 2: API Development (Week 1-2) ### Phase 2: API Development (Week 1-2)
1. **Tag Management APIs** 1. **Tag Management APIs**
- CRUD operations for tags - CRUD operations for tags
- Tag suggestions and search - Tag suggestions and search
- Analytics endpoints - Analytics endpoints
2. **Enhanced Post APIs** 2. **Enhanced Post APIs**
- Update post endpoints for relational tags - Update post endpoints for relational tags
- Related posts algorithm - Related posts algorithm
- Tag filtering capabilities - Tag filtering capabilities
@ -475,12 +501,15 @@ interface UpdatePostTagsRequest {
- Data consistency checks - Data consistency checks
### Phase 3: Frontend Components (Week 2-3) ### Phase 3: Frontend Components (Week 2-3)
1. **Core Components** 1. **Core Components**
- TagInput with typeahead - TagInput with typeahead
- TagPill and TagCloud - TagPill and TagCloud
- Tag management interface - Tag management interface
2. **Integration** 2. **Integration**
- Update MetadataPopover - Update MetadataPopover
- Add tag filtering to posts list - Add tag filtering to posts list
- Implement related posts component - Implement related posts component
@ -491,12 +520,15 @@ interface UpdatePostTagsRequest {
- Bulk operations interface - Bulk operations interface
### Phase 4: Features & Polish (Week 3-4) ### Phase 4: Features & Polish (Week 3-4)
1. **Advanced Features** 1. **Advanced Features**
- Tag merging functionality - Tag merging functionality
- Usage analytics - Usage analytics
- Tag suggestions based on content - Tag suggestions based on content
2. **Performance Optimization** 2. **Performance Optimization**
- Query optimization - Query optimization
- Caching strategies - Caching strategies
- Load testing - Load testing
@ -509,21 +541,25 @@ interface UpdatePostTagsRequest {
## Success Metrics ## Success Metrics
### Performance ### Performance
- Tag search responses under 50ms - Tag search responses under 50ms
- Post filtering responses under 100ms - Post filtering responses under 100ms
- Page load times maintained or improved - Page load times maintained or improved
### Usability ### Usability
- Reduced clicks to add tags (eliminate Add button) - Reduced clicks to add tags (eliminate Add button)
- Faster tag input with typeahead - Faster tag input with typeahead
- Improved content discovery through related posts - Improved content discovery through related posts
### Content Management ### Content Management
- Ability to merge duplicate tags - Ability to merge duplicate tags
- Insights into tag usage patterns - Insights into tag usage patterns
- Better content organization capabilities - Better content organization capabilities
### Analytics ### Analytics
- Track tag usage growth over time - Track tag usage growth over time
- Identify content gaps through tag analysis - Identify content gaps through tag analysis
- Measure impact on content engagement - Measure impact on content engagement
@ -531,18 +567,21 @@ interface UpdatePostTagsRequest {
## Technical Considerations ## Technical Considerations
### Performance ### Performance
- **Database Indexes**: Proper indexing on tag names and relationships - **Database Indexes**: Proper indexing on tag names and relationships
- **Query Optimization**: Efficient joins for tag filtering - **Query Optimization**: Efficient joins for tag filtering
- **Caching**: Cache popular tag lists and related posts - **Caching**: Cache popular tag lists and related posts
- **Pagination**: Handle large tag lists efficiently - **Pagination**: Handle large tag lists efficiently
### Data Integrity ### Data Integrity
- **Constraints**: Prevent duplicate tag names - **Constraints**: Prevent duplicate tag names
- **Cascading Deletes**: Properly handle tag/post deletions - **Cascading Deletes**: Properly handle tag/post deletions
- **Validation**: Ensure tag names follow naming conventions - **Validation**: Ensure tag names follow naming conventions
- **Backup Strategy**: Safe migration with rollback capability - **Backup Strategy**: Safe migration with rollback capability
### User Experience ### User Experience
- **Progressive Enhancement**: Graceful degradation if JS fails - **Progressive Enhancement**: Graceful degradation if JS fails
- **Loading States**: Smooth loading indicators - **Loading States**: Smooth loading indicators
- **Error Handling**: Clear error messages for users - **Error Handling**: Clear error messages for users
@ -551,12 +590,14 @@ interface UpdatePostTagsRequest {
## Future Enhancements ## Future Enhancements
### Advanced Features (Post-MVP) ### Advanced Features (Post-MVP)
- **Hierarchical Tags**: Parent/child tag relationships - **Hierarchical Tags**: Parent/child tag relationships
- **Tag Synonyms**: Alternative names for the same concept - **Tag Synonyms**: Alternative names for the same concept
- **Auto-tagging**: ML-based tag suggestions from content - **Auto-tagging**: ML-based tag suggestions from content
- **Tag Templates**: Predefined tag sets for different content types - **Tag Templates**: Predefined tag sets for different content types
### Integrations ### Integrations
- **External APIs**: Import tags from external sources - **External APIs**: Import tags from external sources
- **Search Integration**: Enhanced search with tag faceting - **Search Integration**: Enhanced search with tag faceting
- **Analytics**: Deep tag performance analytics - **Analytics**: Deep tag performance analytics
@ -565,11 +606,13 @@ interface UpdatePostTagsRequest {
## Risk Assessment ## Risk Assessment
### High Risk ### High Risk
- **Data Migration**: Complex migration of existing tag data - **Data Migration**: Complex migration of existing tag data
- **Performance Impact**: New queries might affect page load times - **Performance Impact**: New queries might affect page load times
- **User Adoption**: Users need to learn new tag input interface - **User Adoption**: Users need to learn new tag input interface
### Mitigation Strategies ### Mitigation Strategies
- **Staged Rollout**: Deploy to staging first, then gradual production rollout - **Staged Rollout**: Deploy to staging first, then gradual production rollout
- **Performance Monitoring**: Continuous monitoring during migration - **Performance Monitoring**: Continuous monitoring during migration
- **User Training**: Clear documentation and smooth UX transitions - **User Training**: Clear documentation and smooth UX transitions
@ -578,6 +621,7 @@ interface UpdatePostTagsRequest {
## Success Criteria ## Success Criteria
### Must Have ### Must Have
- ✅ All existing tags migrated successfully - ✅ All existing tags migrated successfully
- ✅ Tag input works with keyboard-only navigation - ✅ Tag input works with keyboard-only navigation
- ✅ Posts can be filtered by single or multiple tags - ✅ Posts can be filtered by single or multiple tags
@ -585,12 +629,14 @@ interface UpdatePostTagsRequest {
- ✅ Performance remains acceptable (< 100ms for most operations) - ✅ Performance remains acceptable (< 100ms for most operations)
### Should Have ### Should Have
- ✅ Tag management interface for admins - ✅ Tag management interface for admins
- ✅ Tag usage analytics and insights - ✅ Tag usage analytics and insights
- ✅ Ability to merge duplicate tags - ✅ Ability to merge duplicate tags
- ✅ Tag color coding and visual improvements - ✅ Tag color coding and visual improvements
### Could Have ### Could Have
- Tag auto-suggestions based on post content - Tag auto-suggestions based on post content
- Tag trending and popularity metrics - Tag trending and popularity metrics
- Advanced tag analytics and reporting - Advanced tag analytics and reporting
@ -607,4 +653,4 @@ interface UpdatePostTagsRequest {
## Conclusion ## Conclusion
This enhanced tag system will significantly improve content organization, discoverability, and management capabilities while providing a modern, intuitive user interface built with Svelte 5 runes. The migration strategy ensures minimal disruption while delivering substantial improvements in functionality and user experience. This enhanced tag system will significantly improve content organization, discoverability, and management capabilities while providing a modern, intuitive user interface built with Svelte 5 runes. The migration strategy ensures minimal disruption while delivering substantial improvements in functionality and user experience.

View file

@ -1,20 +1,24 @@
# PRD: Interactive Project Headers # PRD: Interactive Project Headers
## Overview ## Overview
Implement a system for project-specific interactive headers that can be selected per project through the admin UI. Each project can have a unique, animated header component or use a generic default. Implement a system for project-specific interactive headers that can be selected per project through the admin UI. Each project can have a unique, animated header component or use a generic default.
## Goals ## Goals
- Create engaging, project-specific header experiences - Create engaging, project-specific header experiences
- Maintain simplicity in implementation and admin UI - Maintain simplicity in implementation and admin UI
- Allow for creative freedom while keeping the system maintainable - Allow for creative freedom while keeping the system maintainable
- Provide a path for adding new header types over time - Provide a path for adding new header types over time
## Implementation Strategy ## Implementation Strategy
We will use a component-based system where each project can select from a predefined list of header components. Each header component is a fully custom Svelte component that receives the project data as props. We will use a component-based system where each project can select from a predefined list of header components. Each header component is a fully custom Svelte component that receives the project data as props.
## Technical Implementation ## Technical Implementation
### 1. Database Schema Update ### 1. Database Schema Update
Add a `headerType` field to the Project model in `prisma/schema.prisma`: Add a `headerType` field to the Project model in `prisma/schema.prisma`:
```prisma ```prisma
@ -25,6 +29,7 @@ model Project {
``` ```
### 2. Component Structure ### 2. Component Structure
Create a new directory structure for header components: Create a new directory structure for header components:
``` ```
@ -37,66 +42,72 @@ src/lib/components/headers/
``` ```
### 3. ProjectHeader Component (Switcher) ### 3. ProjectHeader Component (Switcher)
The main component that switches between different header types: The main component that switches between different header types:
```svelte ```svelte
<!-- ProjectHeader.svelte --> <!-- ProjectHeader.svelte -->
<script> <script>
import LogoOnBackgroundHeader from './LogoOnBackgroundHeader.svelte'; import LogoOnBackgroundHeader from './LogoOnBackgroundHeader.svelte'
import PinterestHeader from './PinterestHeader.svelte'; import PinterestHeader from './PinterestHeader.svelte'
import MaitsuHeader from './MaitsuHeader.svelte'; import MaitsuHeader from './MaitsuHeader.svelte'
let { project } = $props(); let { project } = $props()
const headers = { const headers = {
logoOnBackground: LogoOnBackgroundHeader, logoOnBackground: LogoOnBackgroundHeader,
pinterest: PinterestHeader, pinterest: PinterestHeader,
maitsu: MaitsuHeader, maitsu: MaitsuHeader
// Add more as needed // Add more as needed
}; }
const HeaderComponent = headers[project.headerType] || LogoOnBackgroundHeader; const HeaderComponent = headers[project.headerType] || LogoOnBackgroundHeader
</script> </script>
{#if project.headerType !== 'none'} {#if project.headerType !== 'none'}
<HeaderComponent {project} /> <HeaderComponent {project} />
{/if} {/if}
``` ```
### 4. Update Project Detail Page ### 4. Update Project Detail Page
Modify `/routes/work/[slug]/+page.svelte` to use the new header system instead of the current static header. Modify `/routes/work/[slug]/+page.svelte` to use the new header system instead of the current static header.
### 5. Admin UI Integration ### 5. Admin UI Integration
Add a select field to the project form components: Add a select field to the project form components:
```svelte ```svelte
<Select <Select
label="Header Type" label="Header Type"
name="headerType" name="headerType"
value={formData.headerType} value={formData.headerType}
onchange={(e) => formData.headerType = e.target.value} onchange={(e) => (formData.headerType = e.target.value)}
> >
<option value="none">No Header</option> <option value="none">No Header</option>
<option value="logoOnBackground">Logo on Background (Default)</option> <option value="logoOnBackground">Logo on Background (Default)</option>
<option value="pinterest">Pinterest Header</option> <option value="pinterest">Pinterest Header</option>
<option value="maitsu">Maitsu Header</option> <option value="maitsu">Maitsu Header</option>
</Select> </Select>
``` ```
## Header Type Specifications ## Header Type Specifications
### LogoOnBackgroundHeader (Default) ### LogoOnBackgroundHeader (Default)
- Current behavior: centered logo with title and subtitle - Current behavior: centered logo with title and subtitle
- Uses project's `logoUrl`, `backgroundColor`, and text - Uses project's `logoUrl`, `backgroundColor`, and text
- Simple, clean presentation - Simple, clean presentation
### PinterestHeader ### PinterestHeader
- Interactive grid of Pinterest-style cards - Interactive grid of Pinterest-style cards
- Cards rearrange/animate on hover - Cards rearrange/animate on hover
- Could pull from project gallery or use custom assets - Could pull from project gallery or use custom assets
- Red color scheme matching Pinterest brand - Red color scheme matching Pinterest brand
### MaitsuHeader ### MaitsuHeader
- Japanese-inspired animations - Japanese-inspired animations
- Could feature: - Could feature:
- Animated kanji/hiragana characters - Animated kanji/hiragana characters
@ -105,11 +116,14 @@ Add a select field to the project form components:
- Uses project colors for theming - Uses project colors for theming
### None ### None
- No header displayed - No header displayed
- Project content starts immediately - Project content starts immediately
## Data Available to Headers ## Data Available to Headers
Each header component receives the full project object with access to: Each header component receives the full project object with access to:
- `project.logoUrl` - Project logo - `project.logoUrl` - Project logo
- `project.backgroundColor` - Primary background color - `project.backgroundColor` - Primary background color
- `project.highlightColor` - Accent color - `project.highlightColor` - Accent color
@ -121,12 +135,14 @@ Each header component receives the full project object with access to:
## Future Considerations ## Future Considerations
### Potential Additional Header Types ### Potential Additional Header Types
- **SlackHeader**: Animated emoji reactions floating up - **SlackHeader**: Animated emoji reactions floating up
- **FigmaHeader**: Interactive design tools/cursors - **FigmaHeader**: Interactive design tools/cursors
- **TypegraphicaHeader**: Kinetic typography animations - **TypegraphicaHeader**: Kinetic typography animations
- **Custom**: Allow arbitrary component code (requires security considerations) - **Custom**: Allow arbitrary component code (requires security considerations)
### Possible Enhancements ### Possible Enhancements
1. **Configuration Options**: Add a `headerConfig` JSON field for component-specific settings 1. **Configuration Options**: Add a `headerConfig` JSON field for component-specific settings
2. **Asset Management**: Dedicated header assets separate from project gallery 2. **Asset Management**: Dedicated header assets separate from project gallery
3. **Responsive Behaviors**: Different animations for mobile vs desktop 3. **Responsive Behaviors**: Different animations for mobile vs desktop
@ -134,6 +150,7 @@ Each header component receives the full project object with access to:
5. **A/B Testing**: Support multiple headers per project for testing 5. **A/B Testing**: Support multiple headers per project for testing
## Implementation Steps ## Implementation Steps
1. Add `headerType` field to Prisma schema 1. Add `headerType` field to Prisma schema
2. Create database migration 2. Create database migration
3. Create base `ProjectHeader` switcher component 3. Create base `ProjectHeader` switcher component
@ -145,6 +162,7 @@ Each header component receives the full project object with access to:
9. Document how to add new header types 9. Document how to add new header types
## Success Criteria ## Success Criteria
- Projects can select from multiple header types via admin UI - Projects can select from multiple header types via admin UI
- Each header type provides a unique, engaging experience - Each header type provides a unique, engaging experience
- System is extensible for adding new headers - System is extensible for adding new headers
@ -153,8 +171,9 @@ Each header component receives the full project object with access to:
- Clean separation between header components - Clean separation between header components
## Technical Notes ## Technical Notes
- Use Svelte 5 runes syntax (`$props()`, `$state()`, etc.) - Use Svelte 5 runes syntax (`$props()`, `$state()`, etc.)
- Leverage existing animation patterns (spring physics, CSS transitions) - Leverage existing animation patterns (spring physics, CSS transitions)
- Follow established SCSS variable system - Follow established SCSS variable system
- Ensure headers are responsive - Ensure headers are responsive
- Consider accessibility (prefers-reduced-motion) - Consider accessibility (prefers-reduced-motion)

View file

@ -1,10 +1,11 @@
# Product Requirements Document: Media Library Modal System # Product Requirements Document: Media Library Modal System
## 🎉 **PROJECT STATUS: CORE IMPLEMENTATION COMPLETE!** ## 🎉 **PROJECT STATUS: CORE IMPLEMENTATION COMPLETE!**
We have successfully implemented a comprehensive Media Library system with both direct upload workflows and library browsing capabilities. **All major components are functional and integrated throughout the admin interface.** We have successfully implemented a comprehensive Media Library system with both direct upload workflows and library browsing capabilities. **All major components are functional and integrated throughout the admin interface.**
### 🏆 Major Achievements ### 🏆 Major Achievements
- **✅ Complete MediaLibraryModal system** with single/multiple selection - **✅ Complete MediaLibraryModal system** with single/multiple selection
- **✅ Enhanced upload components** (ImageUploader, GalleryUploader) with MediaLibraryModal integration - **✅ Enhanced upload components** (ImageUploader, GalleryUploader) with MediaLibraryModal integration
- **✅ Full form integration** across projects, posts, albums, and editor - **✅ Full form integration** across projects, posts, albums, and editor
@ -27,18 +28,21 @@ Implement a comprehensive Media Library modal system that provides a unified int
## Goals ## Goals
### Primary Goals (Direct Upload Workflow) ### Primary Goals (Direct Upload Workflow)
- **Enable direct file upload within forms** where content will be used (projects, posts, albums) - **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 - **Provide immediate upload and preview** without requiring navigation to separate media management
- **Store comprehensive metadata** including alt text for accessibility and SEO - **Store comprehensive metadata** including alt text for accessibility and SEO
- **Support drag-and-drop and click-to-browse** for intuitive file selection - **Support drag-and-drop and click-to-browse** for intuitive file selection
### Secondary Goals (Media Library Browser) ### Secondary Goals (Media Library Browser)
- Create a reusable media browser for **selecting previously uploaded content** - Create a reusable media browser for **selecting previously uploaded content**
- Provide **media management interface** showing where files are referenced - Provide **media management interface** showing where files are referenced
- Enable **bulk operations** and **metadata editing** (especially alt text) - Enable **bulk operations** and **metadata editing** (especially alt text)
- Support **file organization** and **usage tracking** - Support **file organization** and **usage tracking**
### Technical Goals ### Technical Goals
- Maintain consistent UX across all media interactions - Maintain consistent UX across all media interactions
- Support different file type filtering based on context - Support different file type filtering based on context
- Integrate seamlessly with existing admin components - Integrate seamlessly with existing admin components
@ -46,6 +50,7 @@ Implement a comprehensive Media Library modal system that provides a unified int
## Current State Analysis ## Current State Analysis
### ✅ What We Have ### ✅ What We Have
- Complete media API (`/api/media`, `/api/media/upload`, `/api/media/bulk-upload`) - Complete media API (`/api/media`, `/api/media/upload`, `/api/media/bulk-upload`)
- Media management page with grid/list views and search/filtering - Media management page with grid/list views and search/filtering
- Modal base component (`Modal.svelte`) - Modal base component (`Modal.svelte`)
@ -62,17 +67,20 @@ Implement a comprehensive Media Library modal system that provides a unified int
### 🎯 What We Need ### 🎯 What We Need
#### High Priority (Remaining Tasks) #### High Priority (Remaining Tasks)
- **Enhanced upload features** with drag & drop zones in all upload components - **Enhanced upload features** with drag & drop zones in all upload components
- **Bulk alt text editing** in Media Library for existing content - **Bulk alt text editing** in Media Library for existing content
- **Usage tracking display** showing where media is referenced - **Usage tracking display** showing where media is referenced
- **Performance optimizations** for large media libraries - **Performance optimizations** for large media libraries
#### Medium Priority (Polish & Advanced Features) #### Medium Priority (Polish & Advanced Features)
- **Image optimization options** during upload - **Image optimization options** during upload
- **Advanced search capabilities** (by alt text, usage, etc.) - **Advanced search capabilities** (by alt text, usage, etc.)
- **Bulk operations** (delete multiple, bulk metadata editing) - **Bulk operations** (delete multiple, bulk metadata editing)
#### Low Priority (Future Enhancements) #### Low Priority (Future Enhancements)
- **AI-powered alt text suggestions** - **AI-powered alt text suggestions**
- **Duplicate detection** and management - **Duplicate detection** and management
- **Advanced analytics** and usage reporting - **Advanced analytics** and usage reporting
@ -80,6 +88,7 @@ Implement a comprehensive Media Library modal system that provides a unified int
## Workflow Priorities ## Workflow Priorities
### 🥇 Primary Workflow: Direct Upload in Forms ### 🥇 Primary Workflow: Direct Upload in Forms
This is the **main workflow** that users will use 90% of the time: This is the **main workflow** that users will use 90% of the time:
1. **User creates content** (project, post, album) 1. **User creates content** (project, post, album)
@ -89,11 +98,13 @@ This is the **main workflow** that users will use 90% of the time:
5. **Content is saved** with proper media references 5. **Content is saved** with proper media references
**Key Components**: **Key Components**:
- `ImageUploader` - Direct drag-and-drop/click upload with preview - `ImageUploader` - Direct drag-and-drop/click upload with preview
- `GalleryUploader` - Multiple file upload with immediate gallery preview - `GalleryUploader` - Multiple file upload with immediate gallery preview
- `MediaMetadataForm` - Alt text and description capture during upload - `MediaMetadataForm` - Alt text and description capture during upload
### 🥈 Secondary Workflow: Browse Existing Media ### 🥈 Secondary Workflow: Browse Existing Media
This workflow is for **reusing previously uploaded content**: This workflow is for **reusing previously uploaded content**:
1. **User needs to select existing media** (rare case) 1. **User needs to select existing media** (rare case)
@ -103,6 +114,7 @@ This workflow is for **reusing previously uploaded content**:
5. **Media references are updated** 5. **Media references are updated**
**Key Components**: **Key Components**:
- `MediaLibraryModal` - Browse and select existing media - `MediaLibraryModal` - Browse and select existing media
- `MediaSelector` - Grid interface for selection - `MediaSelector` - Grid interface for selection
- `MediaManager` - Edit alt text and view usage - `MediaManager` - Edit alt text and view usage
@ -112,22 +124,24 @@ This workflow is for **reusing previously uploaded content**:
### 1. Enhanced Upload Components (Primary) ### 1. Enhanced Upload Components (Primary)
#### ImageUploader Component #### ImageUploader Component
**Purpose**: Direct image upload with immediate preview and metadata capture **Purpose**: Direct image upload with immediate preview and metadata capture
```typescript ```typescript
interface ImageUploaderProps { interface ImageUploaderProps {
label: string label: string
value?: Media | null value?: Media | null
onUpload: (media: Media) => void onUpload: (media: Media) => void
aspectRatio?: string aspectRatio?: string
required?: boolean required?: boolean
error?: string error?: string
allowAltText?: boolean // Enable alt text input allowAltText?: boolean // Enable alt text input
maxFileSize?: number // MB limit maxFileSize?: number // MB limit
} }
``` ```
**Features**: **Features**:
- Drag-and-drop upload zone with visual feedback - Drag-and-drop upload zone with visual feedback
- Click to browse files from computer - Click to browse files from computer
- Immediate image preview with proper aspect ratio - Immediate image preview with proper aspect ratio
@ -137,22 +151,24 @@ interface ImageUploaderProps {
- Replace/remove functionality - Replace/remove functionality
#### GalleryUploader Component #### GalleryUploader Component
**Purpose**: Multiple file upload with gallery preview and reordering **Purpose**: Multiple file upload with gallery preview and reordering
```typescript ```typescript
interface GalleryUploaderProps { interface GalleryUploaderProps {
label: string label: string
value?: Media[] value?: Media[]
onUpload: (media: Media[]) => void onUpload: (media: Media[]) => void
onReorder?: (media: Media[]) => void onReorder?: (media: Media[]) => void
maxItems?: number maxItems?: number
allowAltText?: boolean allowAltText?: boolean
required?: boolean required?: boolean
error?: string error?: string
} }
``` ```
**Features**: **Features**:
- Multiple file drag-and-drop - Multiple file drag-and-drop
- Immediate gallery preview grid - Immediate gallery preview grid
- Individual alt text inputs for each image - Individual alt text inputs for each image
@ -165,20 +181,22 @@ interface GalleryUploaderProps {
**Purpose**: Main modal component that wraps the media browser functionality **Purpose**: Main modal component that wraps the media browser functionality
**Props Interface**: **Props Interface**:
```typescript ```typescript
interface MediaLibraryModalProps { interface MediaLibraryModalProps {
isOpen: boolean isOpen: boolean
mode: 'single' | 'multiple' mode: 'single' | 'multiple'
fileType?: 'image' | 'video' | 'all' fileType?: 'image' | 'video' | 'all'
onSelect: (media: Media | Media[]) => void onSelect: (media: Media | Media[]) => void
onClose: () => void onClose: () => void
selectedIds?: number[] // Pre-selected items selectedIds?: number[] // Pre-selected items
title?: string // Modal title title?: string // Modal title
confirmText?: string // Confirm button text confirmText?: string // Confirm button text
} }
``` ```
**Features**: **Features**:
- Modal overlay with proper focus management - Modal overlay with proper focus management
- Header with title and close button - Header with title and close button
- Media browser grid with selection indicators - Media browser grid with selection indicators
@ -192,6 +210,7 @@ interface MediaLibraryModalProps {
**Purpose**: The actual media browsing interface within the modal **Purpose**: The actual media browsing interface within the modal
**Features**: **Features**:
- Grid layout with thumbnail previews - Grid layout with thumbnail previews
- Individual item selection with visual feedback - Individual item selection with visual feedback
- Keyboard navigation support - Keyboard navigation support
@ -199,6 +218,7 @@ interface MediaLibraryModalProps {
- "Select All" / "Clear Selection" bulk actions (for multiple mode) - "Select All" / "Clear Selection" bulk actions (for multiple mode)
**Item Display**: **Item Display**:
- Thumbnail image - Thumbnail image
- Filename (truncated) - Filename (truncated)
- File size and dimensions - File size and dimensions
@ -210,6 +230,7 @@ interface MediaLibraryModalProps {
**Purpose**: Handle file uploads within the modal **Purpose**: Handle file uploads within the modal
**Features**: **Features**:
- Drag-and-drop upload zone - Drag-and-drop upload zone
- Click to browse files - Click to browse files
- Upload progress indicators - Upload progress indicators
@ -218,6 +239,7 @@ interface MediaLibraryModalProps {
- Automatic refresh of media grid after upload - Automatic refresh of media grid after upload
**Validation**: **Validation**:
- File type restrictions based on context - File type restrictions based on context
- File size limits (10MB per file) - File size limits (10MB per file)
- Maximum number of files for bulk upload - Maximum number of files for bulk upload
@ -225,22 +247,24 @@ interface MediaLibraryModalProps {
### 4. Form Integration Components ### 4. Form Integration Components
#### MediaInput Component #### MediaInput Component
**Purpose**: Generic input field that opens media library modal **Purpose**: Generic input field that opens media library modal
```typescript ```typescript
interface MediaInputProps { interface MediaInputProps {
label: string label: string
value?: Media | Media[] | null value?: Media | Media[] | null
mode: 'single' | 'multiple' mode: 'single' | 'multiple'
fileType?: 'image' | 'video' | 'all' fileType?: 'image' | 'video' | 'all'
onSelect: (media: Media | Media[] | null) => void onSelect: (media: Media | Media[] | null) => void
placeholder?: string placeholder?: string
required?: boolean required?: boolean
error?: string error?: string
} }
``` ```
**Display**: **Display**:
- Label and optional required indicator - Label and optional required indicator
- Preview of selected media (thumbnail + filename) - Preview of selected media (thumbnail + filename)
- "Browse" button to open modal - "Browse" button to open modal
@ -248,42 +272,46 @@ interface MediaInputProps {
- Error state display - Error state display
#### ImagePicker Component #### ImagePicker Component
**Purpose**: Specialized single image selector with enhanced preview **Purpose**: Specialized single image selector with enhanced preview
```typescript ```typescript
interface ImagePickerProps { interface ImagePickerProps {
label: string label: string
value?: Media | null value?: Media | null
onSelect: (media: Media | null) => void onSelect: (media: Media | null) => void
aspectRatio?: string // e.g., "16:9", "1:1" aspectRatio?: string // e.g., "16:9", "1:1"
placeholder?: string placeholder?: string
required?: boolean required?: boolean
error?: string error?: string
} }
``` ```
**Display**: **Display**:
- Large preview area with placeholder - Large preview area with placeholder
- Image preview with proper aspect ratio - Image preview with proper aspect ratio
- Overlay with "Change" and "Remove" buttons on hover - Overlay with "Change" and "Remove" buttons on hover
- Upload progress indicator - Upload progress indicator
#### GalleryManager Component #### GalleryManager Component
**Purpose**: Multiple image selection with drag-and-drop reordering **Purpose**: Multiple image selection with drag-and-drop reordering
```typescript ```typescript
interface GalleryManagerProps { interface GalleryManagerProps {
label: string label: string
value?: Media[] value?: Media[]
onSelect: (media: Media[]) => void onSelect: (media: Media[]) => void
onReorder?: (media: Media[]) => void onReorder?: (media: Media[]) => void
maxItems?: number maxItems?: number
required?: boolean required?: boolean
error?: string error?: string
} }
``` ```
**Display**: **Display**:
- Grid of selected images with reorder handles - Grid of selected images with reorder handles
- "Add Images" button to open modal - "Add Images" button to open modal
- Individual remove buttons on each image - Individual remove buttons on each image
@ -294,6 +322,7 @@ interface GalleryManagerProps {
### 🥇 Primary Flow: Direct Upload in Forms ### 🥇 Primary Flow: Direct Upload in Forms
#### 1. Single Image Upload (Project Featured Image) #### 1. Single Image Upload (Project Featured Image)
1. **User creates/edits project** and reaches featured image field 1. **User creates/edits project** and reaches featured image field
2. **User drags image file** directly onto ImageUploader component OR clicks to browse 2. **User drags image file** directly onto ImageUploader component OR clicks to browse
3. **File is immediately uploaded** with progress indicator 3. **File is immediately uploaded** with progress indicator
@ -303,6 +332,7 @@ interface GalleryManagerProps {
7. **Form can be saved** with media reference and metadata 7. **Form can be saved** with media reference and metadata
#### 2. Multiple Image Upload (Project Gallery) #### 2. Multiple Image Upload (Project Gallery)
1. **User reaches gallery section** of project form 1. **User reaches gallery section** of project form
2. **User drags multiple files** onto GalleryUploader OR clicks to browse multiple 2. **User drags multiple files** onto GalleryUploader OR clicks to browse multiple
3. **Upload progress shown** for each file individually 3. **Upload progress shown** for each file individually
@ -313,6 +343,7 @@ interface GalleryManagerProps {
8. **Form saves** with complete gallery and metadata 8. **Form saves** with complete gallery and metadata
#### 3. Media Management and Alt Text Editing #### 3. Media Management and Alt Text Editing
1. **User visits Media Library page** to manage uploaded content 1. **User visits Media Library page** to manage uploaded content
2. **User clicks on any media item** to open details modal 2. **User clicks on any media item** to open details modal
3. **User can edit alt text** and other metadata 3. **User can edit alt text** and other metadata
@ -322,6 +353,7 @@ interface GalleryManagerProps {
### 🥈 Secondary Flow: Browse Existing Media ### 🥈 Secondary Flow: Browse Existing Media
#### 1. Selecting Previously Uploaded Image #### 1. Selecting Previously Uploaded Image
1. **User clicks "Browse Library"** button (secondary option in forms) 1. **User clicks "Browse Library"** button (secondary option in forms)
2. **MediaLibraryModal opens** showing all previously uploaded media 2. **MediaLibraryModal opens** showing all previously uploaded media
3. **User browses or searches** existing content 3. **User browses or searches** existing content
@ -329,6 +361,7 @@ interface GalleryManagerProps {
5. **Modal closes** and form shows selected media with existing alt text 5. **Modal closes** and form shows selected media with existing alt text
#### 2. Managing Media Library #### 2. Managing Media Library
1. **User visits dedicated Media Library page** 1. **User visits dedicated Media Library page**
2. **User can view all uploaded media** in grid/list format 2. **User can view all uploaded media** in grid/list format
3. **User can edit metadata** including alt text for any media 3. **User can edit metadata** including alt text for any media
@ -338,12 +371,14 @@ interface GalleryManagerProps {
## Design Specifications ## Design Specifications
### Modal Layout ### Modal Layout
- **Width**: 1200px max, responsive on smaller screens - **Width**: 1200px max, responsive on smaller screens
- **Height**: 80vh max with scroll - **Height**: 80vh max with scroll
- **Grid**: 4-6 columns depending on screen size - **Grid**: 4-6 columns depending on screen size
- **Item Size**: 180px × 140px thumbnails - **Item Size**: 180px × 140px thumbnails
### Visual States ### Visual States
- **Default**: Border with subtle background - **Default**: Border with subtle background
- **Selected**: Blue border and checkmark overlay - **Selected**: Blue border and checkmark overlay
- **Hover**: Slight scale and shadow effect - **Hover**: Slight scale and shadow effect
@ -351,6 +386,7 @@ interface GalleryManagerProps {
- **Upload**: Progress overlay with percentage - **Upload**: Progress overlay with percentage
### Colors (Using Existing Variables) ### Colors (Using Existing Variables)
- **Selection**: `$blue-60` for selected state - **Selection**: `$blue-60` for selected state
- **Hover**: `$grey-10` background - **Hover**: `$grey-10` background
- **Upload Progress**: `$green-60` for success, `$red-60` for error - **Upload Progress**: `$green-60` for success, `$red-60` for error
@ -358,17 +394,20 @@ interface GalleryManagerProps {
## API Integration ## API Integration
### Endpoints Used ### Endpoints Used
- `GET /api/media` - Browse media with search/filter/pagination - `GET /api/media` - Browse media with search/filter/pagination
- `POST /api/media/upload` - Single file upload - `POST /api/media/upload` - Single file upload
- `POST /api/media/bulk-upload` - Multiple file upload - `POST /api/media/bulk-upload` - Multiple file upload
### Search and Filtering ### Search and Filtering
- **Search**: By filename (case-insensitive) - **Search**: By filename (case-insensitive)
- **Filter by Type**: image/*, video/*, all - **Filter by Type**: image/_, video/_, all
- **Filter by Usage**: unused only, all - **Filter by Usage**: unused only, all
- **Sort**: Most recent first - **Sort**: Most recent first
### Pagination ### Pagination
- 24 items per page - 24 items per page
- Infinite scroll or traditional pagination - Infinite scroll or traditional pagination
- Loading states during page changes - Loading states during page changes
@ -376,7 +415,9 @@ interface GalleryManagerProps {
## Implementation Plan ## Implementation Plan
### ✅ Phase 1: Database Schema Updates (COMPLETED) ### ✅ Phase 1: Database Schema Updates (COMPLETED)
1. **✅ Alt Text Support** 1. **✅ Alt Text Support**
- Database schema includes `altText` and `description` fields - Database schema includes `altText` and `description` fields
- API endpoints support alt text in upload and update operations - API endpoints support alt text in upload and update operations
@ -385,13 +426,16 @@ interface GalleryManagerProps {
- Need dedicated tracking table for comprehensive usage analytics - Need dedicated tracking table for comprehensive usage analytics
### ✅ Phase 2: Direct Upload Components (COMPLETED) ### ✅ Phase 2: Direct Upload Components (COMPLETED)
1. **✅ ImageUploader Component** 1. **✅ ImageUploader Component**
- Drag-and-drop upload zone with visual feedback - Drag-and-drop upload zone with visual feedback
- Immediate upload and preview functionality - Immediate upload and preview functionality
- Alt text input integration - Alt text input integration
- MediaLibraryModal integration as secondary option - MediaLibraryModal integration as secondary option
2. **✅ GalleryUploader Component** 2. **✅ GalleryUploader Component**
- Multiple file drag-and-drop support - Multiple file drag-and-drop support
- Individual alt text inputs per image - Individual alt text inputs per image
- Drag-and-drop reordering functionality - Drag-and-drop reordering functionality
@ -404,7 +448,9 @@ interface GalleryManagerProps {
- Batch uploads with individual alt text support - Batch uploads with individual alt text support
### ✅ Phase 3: Form Integration (COMPLETED) ### ✅ Phase 3: Form Integration (COMPLETED)
1. **✅ Project Forms Enhancement** 1. **✅ Project Forms Enhancement**
- Logo field enhanced with ImageUploader + Browse Library - Logo field enhanced with ImageUploader + Browse Library
- Featured image support with ImageUploader - Featured image support with ImageUploader
- Gallery section implemented with GalleryUploader - Gallery section implemented with GalleryUploader
@ -417,7 +463,9 @@ interface GalleryManagerProps {
- Enhanced Edra editor with inline image/gallery support - Enhanced Edra editor with inline image/gallery support
### ✅ Phase 4: Media Library Management (MOSTLY COMPLETED) ### ✅ Phase 4: Media Library Management (MOSTLY COMPLETED)
1. **✅ Enhanced Media Library Page** 1. **✅ Enhanced Media Library Page**
- Alt text editing for existing media via MediaDetailsModal - Alt text editing for existing media via MediaDetailsModal
- Clickable media items with edit functionality - Clickable media items with edit functionality
- Grid and list view toggles - Grid and list view toggles
@ -431,7 +479,9 @@ interface GalleryManagerProps {
### 🎯 Phase 5: Remaining Enhancements (CURRENT PRIORITIES) ### 🎯 Phase 5: Remaining Enhancements (CURRENT PRIORITIES)
#### 🔥 High Priority (Next Sprint) #### 🔥 High Priority (Next Sprint)
1. **Enhanced Media Library Features** 1. **Enhanced Media Library Features**
- **Bulk alt text editing** - Select multiple media items and edit alt text in batch - **Bulk alt text editing** - Select multiple media items and edit alt text in batch
- **Usage tracking display** - Show where each media item is referenced - **Usage tracking display** - Show where each media item is referenced
- **Advanced drag & drop zones** - More intuitive upload areas in all components - **Advanced drag & drop zones** - More intuitive upload areas in all components
@ -442,7 +492,9 @@ interface GalleryManagerProps {
- **Thumbnail optimization** for faster loading - **Thumbnail optimization** for faster loading
#### 🔥 Medium Priority (Future Sprints) #### 🔥 Medium Priority (Future Sprints)
1. **Advanced Upload Features** 1. **Advanced Upload Features**
- **Image resizing/optimization** options during upload - **Image resizing/optimization** options during upload
- **Duplicate detection** to prevent redundant uploads - **Duplicate detection** to prevent redundant uploads
- **Bulk upload improvements** with better progress tracking - **Bulk upload improvements** with better progress tracking
@ -453,6 +505,7 @@ interface GalleryManagerProps {
- **Advanced search** by alt text, usage status, date ranges - **Advanced search** by alt text, usage status, date ranges
#### 🔥 Low Priority (Nice-to-Have) #### 🔥 Low Priority (Nice-to-Have)
1. **AI Integration** 1. **AI Integration**
- **Automatic alt text suggestions** using image recognition - **Automatic alt text suggestions** using image recognition
- **Smart tagging** for better organization - **Smart tagging** for better organization
@ -463,8 +516,9 @@ interface GalleryManagerProps {
### Functional Requirements ### Functional Requirements
#### Primary Workflow (Direct Upload) #### Primary Workflow (Direct Upload)
- [x] **Drag-and-drop upload works** in all form components - [x] **Drag-and-drop upload works** in all form components
- [x] **Click-to-browse file selection** works reliably - [x] **Click-to-browse file selection** works reliably
- [x] **Immediate upload and preview** happens without page navigation - [x] **Immediate upload and preview** happens without page navigation
- [x] **Alt text input appears** and saves with uploaded media - [x] **Alt text input appears** and saves with uploaded media
- [x] **Upload progress** is clearly indicated with percentage - [x] **Upload progress** is clearly indicated with percentage
@ -473,20 +527,23 @@ interface GalleryManagerProps {
- [x] **Gallery reordering** works with drag-and-drop after upload - [x] **Gallery reordering** works with drag-and-drop after upload
#### Secondary Workflow (Media Library) #### Secondary Workflow (Media Library)
- [x] **Media Library Modal** opens and closes properly with smooth animations - [x] **Media Library Modal** opens and closes properly with smooth animations
- [x] **Single and multiple selection** modes work correctly - [x] **Single and multiple selection** modes work correctly
- [x] **Search and filtering** return accurate results - [x] **Search and filtering** return accurate results
- [ ] **Usage tracking** shows where media is referenced (IN PROGRESS) - [ ] **Usage tracking** shows where media is referenced (IN PROGRESS)
- [x] **Alt text editing** works in Media Library management - [x] **Alt text editing** works in Media Library management
- [x] **All components are keyboard accessible** - [x] **All components are keyboard accessible**
#### Edra Editor Integration #### Edra Editor Integration
- [x] **Slash commands** work for image and gallery insertion - [x] **Slash commands** work for image and gallery insertion
- [x] **MediaLibraryModal integration** in editor placeholders - [x] **MediaLibraryModal integration** in editor placeholders
- [x] **Gallery management** within rich text editor - [x] **Gallery management** within rich text editor
- [x] **Image replacement** functionality in editor - [x] **Image replacement** functionality in editor
### Performance Requirements ### Performance Requirements
- [x] Modal opens in under 200ms - [x] Modal opens in under 200ms
- [x] Media grid loads in under 1 second - [x] Media grid loads in under 1 second
- [x] Search results appear in under 500ms - [x] Search results appear in under 500ms
@ -494,6 +551,7 @@ interface GalleryManagerProps {
- [x] No memory leaks when opening/closing modal multiple times - [x] No memory leaks when opening/closing modal multiple times
### UX Requirements ### UX Requirements
- [x] Interface is intuitive without instruction - [x] Interface is intuitive without instruction
- [x] Visual feedback is clear for all interactions - [x] Visual feedback is clear for all interactions
- [x] Error messages are helpful and actionable - [x] Error messages are helpful and actionable
@ -503,23 +561,27 @@ interface GalleryManagerProps {
## Technical Considerations ## Technical Considerations
### State Management ### State Management
- Use Svelte runes for reactive state - Use Svelte runes for reactive state
- Maintain selection state during modal lifecycle - Maintain selection state during modal lifecycle
- Handle API loading and error states properly - Handle API loading and error states properly
### Accessibility ### Accessibility
- Proper ARIA labels and roles - Proper ARIA labels and roles
- Keyboard navigation support - Keyboard navigation support
- Focus management when modal opens/closes - Focus management when modal opens/closes
- Screen reader announcements for state changes - Screen reader announcements for state changes
### Performance ### Performance
- Lazy load thumbnails as they come into view - Lazy load thumbnails as they come into view
- Debounce search input to prevent excessive API calls - Debounce search input to prevent excessive API calls
- Efficient reordering without full re-renders - Efficient reordering without full re-renders
- Memory cleanup when modal is closed - Memory cleanup when modal is closed
### Error Handling ### Error Handling
- Network failure recovery - Network failure recovery
- Upload failure feedback - Upload failure feedback
- File validation error messages - File validation error messages
@ -528,6 +590,7 @@ interface GalleryManagerProps {
## Future Enhancements ## Future Enhancements
### Nice-to-Have Features ### Nice-to-Have Features
- **Bulk Operations**: Delete multiple files, bulk tag editing - **Bulk Operations**: Delete multiple files, bulk tag editing
- **Advanced Search**: Search by tags, date range, file size - **Advanced Search**: Search by tags, date range, file size
- **Preview Mode**: Full-size preview with navigation - **Preview Mode**: Full-size preview with navigation
@ -537,6 +600,7 @@ interface GalleryManagerProps {
- **Alt Text Editor**: Quick alt text editing for accessibility - **Alt Text Editor**: Quick alt text editing for accessibility
### Integration Opportunities ### Integration Opportunities
- **CDN Optimization**: Automatic image optimization settings - **CDN Optimization**: Automatic image optimization settings
- **AI Tagging**: Automatic tag generation for uploaded images - **AI Tagging**: Automatic tag generation for uploaded images
- **Duplicate Detection**: Warn about similar/duplicate uploads - **Duplicate Detection**: Warn about similar/duplicate uploads
@ -545,6 +609,7 @@ interface GalleryManagerProps {
## Development Checklist ## Development Checklist
### Core Components ### Core Components
- [x] MediaLibraryModal base structure - [x] MediaLibraryModal base structure
- [x] MediaSelector with grid layout - [x] MediaSelector with grid layout
- [x] MediaUploader with drag-and-drop - [x] MediaUploader with drag-and-drop
@ -552,6 +617,7 @@ interface GalleryManagerProps {
- [x] Pagination implementation - [x] Pagination implementation
### Form Integration ### Form Integration
- [x] MediaInput generic component (ImageUploader/GalleryUploader) - [x] MediaInput generic component (ImageUploader/GalleryUploader)
- [x] ImagePicker specialized component (ImageUploader) - [x] ImagePicker specialized component (ImageUploader)
- [x] GalleryManager with reordering (GalleryUploader) - [x] GalleryManager with reordering (GalleryUploader)
@ -560,6 +626,7 @@ interface GalleryManagerProps {
- [x] Integration with Edra editor - [x] Integration with Edra editor
### Polish and Testing ### Polish and Testing
- [x] Responsive design implementation - [x] Responsive design implementation
- [x] Accessibility testing and fixes - [x] Accessibility testing and fixes
- [x] Performance optimization - [x] Performance optimization
@ -568,9 +635,10 @@ interface GalleryManagerProps {
- [x] Mobile device testing - [x] Mobile device testing
### 🎯 Next Priority Items ### 🎯 Next Priority Items
- [ ] **Bulk alt text editing** in Media Library - [ ] **Bulk alt text editing** in Media Library
- [ ] **Usage tracking display** for media references - [ ] **Usage tracking display** for media references
- [ ] **Advanced drag & drop zones** with better visual feedback - [ ] **Advanced drag & drop zones** with better visual feedback
- [ ] **Performance optimizations** for large libraries - [ ] **Performance optimizations** for large libraries
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. 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

@ -16,6 +16,7 @@ Implement Storybook as our component development and documentation platform to i
## Current State Analysis ## Current State Analysis
### ✅ What We Have ### ✅ What We Have
- Comprehensive admin UI component library (Button, Input, Modal, etc.) - Comprehensive admin UI component library (Button, Input, Modal, etc.)
- Media Library components (MediaLibraryModal, ImagePicker, GalleryManager, etc.) - Media Library components (MediaLibraryModal, ImagePicker, GalleryManager, etc.)
- SCSS-based styling system with global variables - SCSS-based styling system with global variables
@ -24,6 +25,7 @@ Implement Storybook as our component development and documentation platform to i
- Vite build system - Vite build system
### 🎯 What We Need ### 🎯 What We Need
- Storybook installation and configuration - Storybook installation and configuration
- Stories for existing components - Stories for existing components
- Visual regression testing setup - Visual regression testing setup
@ -45,6 +47,7 @@ npm install --save-dev @storybook/svelte-vite @storybook/addon-essentials
``` ```
**Expected File Structure**: **Expected File Structure**:
``` ```
.storybook/ .storybook/
├── main.js # Storybook configuration ├── main.js # Storybook configuration
@ -62,131 +65,135 @@ src/
### 2. Configuration Requirements ### 2. Configuration Requirements
#### Main Configuration (.storybook/main.js) #### Main Configuration (.storybook/main.js)
```javascript ```javascript
export default { export default {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|svelte)'], stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|svelte)'],
addons: [ addons: [
'@storybook/addon-essentials', // Controls, actions, viewport, etc. '@storybook/addon-essentials', // Controls, actions, viewport, etc.
'@storybook/addon-svelte-csf', // Svelte Component Story Format '@storybook/addon-svelte-csf', // Svelte Component Story Format
'@storybook/addon-a11y', // Accessibility testing '@storybook/addon-a11y', // Accessibility testing
'@storybook/addon-design-tokens', // Design system tokens '@storybook/addon-design-tokens' // Design system tokens
], ],
framework: { framework: {
name: '@storybook/svelte-vite', name: '@storybook/svelte-vite',
options: {} options: {}
}, },
viteFinal: async (config) => { viteFinal: async (config) => {
// Integrate with existing Vite config // Integrate with existing Vite config
// Import SCSS variables and aliases // Import SCSS variables and aliases
return mergeConfig(config, { return mergeConfig(config, {
resolve: { resolve: {
alias: { alias: {
'$lib': path.resolve('./src/lib'), $lib: path.resolve('./src/lib'),
'$components': path.resolve('./src/lib/components'), $components: path.resolve('./src/lib/components'),
'$icons': path.resolve('./src/assets/icons'), $icons: path.resolve('./src/assets/icons'),
'$illos': path.resolve('./src/assets/illos'), $illos: path.resolve('./src/assets/illos')
} }
}, },
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
additionalData: ` additionalData: `
@import './src/assets/styles/variables.scss'; @import './src/assets/styles/variables.scss';
@import './src/assets/styles/fonts.scss'; @import './src/assets/styles/fonts.scss';
@import './src/assets/styles/themes.scss'; @import './src/assets/styles/themes.scss';
@import './src/assets/styles/globals.scss'; @import './src/assets/styles/globals.scss';
` `
} }
} }
} }
}); })
} }
}; }
``` ```
#### Preview Configuration (.storybook/preview.js) #### Preview Configuration (.storybook/preview.js)
```javascript ```javascript
import '../src/assets/styles/reset.css'; import '../src/assets/styles/reset.css'
import '../src/assets/styles/globals.scss'; import '../src/assets/styles/globals.scss'
export const parameters = { export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' }, actions: { argTypesRegex: '^on[A-Z].*' },
controls: { controls: {
matchers: { matchers: {
color: /(background|color)$/i, color: /(background|color)$/i,
date: /Date$/, date: /Date$/
}, }
}, },
backgrounds: { backgrounds: {
default: 'light', default: 'light',
values: [ values: [
{ name: 'light', value: '#ffffff' }, { name: 'light', value: '#ffffff' },
{ name: 'dark', value: '#333333' }, { name: 'dark', value: '#333333' },
{ name: 'admin', value: '#f5f5f5' }, { name: 'admin', value: '#f5f5f5' }
], ]
}, },
viewport: { viewport: {
viewports: { viewports: {
mobile: { name: 'Mobile', styles: { width: '375px', height: '667px' } }, mobile: { name: 'Mobile', styles: { width: '375px', height: '667px' } },
tablet: { name: 'Tablet', styles: { width: '768px', height: '1024px' } }, tablet: { name: 'Tablet', styles: { width: '768px', height: '1024px' } },
desktop: { name: 'Desktop', styles: { width: '1440px', height: '900px' } }, desktop: { name: 'Desktop', styles: { width: '1440px', height: '900px' } }
}, }
}, }
}; }
``` ```
### 3. Component Story Standards ### 3. Component Story Standards
#### Story File Format #### Story File Format
Each component should have a corresponding `.stories.js` file following this structure: Each component should have a corresponding `.stories.js` file following this structure:
```javascript ```javascript
// Button.stories.js // Button.stories.js
import Button from '../lib/components/admin/Button.svelte'; import Button from '../lib/components/admin/Button.svelte'
export default { export default {
title: 'Admin/Button', title: 'Admin/Button',
component: Button, component: Button,
tags: ['autodocs'], tags: ['autodocs'],
argTypes: { argTypes: {
variant: { variant: {
control: { type: 'select' }, control: { type: 'select' },
options: ['primary', 'secondary', 'ghost', 'danger'] options: ['primary', 'secondary', 'ghost', 'danger']
}, },
size: { size: {
control: { type: 'select' }, control: { type: 'select' },
options: ['small', 'medium', 'large'] options: ['small', 'medium', 'large']
}, },
disabled: { disabled: {
control: 'boolean' control: 'boolean'
}, },
onclick: { action: 'clicked' } onclick: { action: 'clicked' }
} }
}; }
export const Primary = { export const Primary = {
args: { args: {
variant: 'primary', variant: 'primary',
children: 'Primary Button' children: 'Primary Button'
} }
}; }
export const Secondary = { export const Secondary = {
args: { args: {
variant: 'secondary', variant: 'secondary',
children: 'Secondary Button' children: 'Secondary Button'
} }
}; }
export const AllVariants = { export const AllVariants = {
render: () => ({ render: () => ({
Component: ButtonShowcase, Component: ButtonShowcase,
props: {} props: {}
}) })
}; }
``` ```
#### Story Organization #### Story Organization
``` ```
src/stories/ src/stories/
├── admin/ # Admin interface components ├── admin/ # Admin interface components
@ -208,7 +215,9 @@ src/stories/
## Implementation Plan ## Implementation Plan
### Phase 1: Initial Setup (1-2 days) ### Phase 1: Initial Setup (1-2 days)
1. **Install and Configure Storybook** 1. **Install and Configure Storybook**
- Run `npx storybook@latest init` - Run `npx storybook@latest init`
- Configure Vite integration for SCSS and aliases - Configure Vite integration for SCSS and aliases
- Set up TypeScript support - Set up TypeScript support
@ -220,13 +229,16 @@ src/stories/
- Test hot reloading - Test hot reloading
### Phase 2: Core Component Stories (3-4 days) ### Phase 2: Core Component Stories (3-4 days)
1. **Basic UI Components** 1. **Basic UI Components**
- Button (all variants, states, sizes) - Button (all variants, states, sizes)
- Input (text, textarea, validation states) - Input (text, textarea, validation states)
- Modal (different sizes, content types) - Modal (different sizes, content types)
- LoadingSpinner (different sizes) - LoadingSpinner (different sizes)
2. **Form Components** 2. **Form Components**
- MediaInput (single/multiple modes) - MediaInput (single/multiple modes)
- ImagePicker (different aspect ratios) - ImagePicker (different aspect ratios)
- GalleryManager (with/without items) - GalleryManager (with/without items)
@ -237,12 +249,15 @@ src/stories/
- AdminNavBar (active states) - AdminNavBar (active states)
### Phase 3: Advanced Features (2-3 days) ### Phase 3: Advanced Features (2-3 days)
1. **Mock Data Setup** 1. **Mock Data Setup**
- Create mock Media objects - Create mock Media objects
- Set up API mocking for components that need data - Set up API mocking for components that need data
- Create realistic test scenarios - Create realistic test scenarios
2. **Accessibility Testing** 2. **Accessibility Testing**
- Add @storybook/addon-a11y - Add @storybook/addon-a11y
- Test keyboard navigation - Test keyboard navigation
- Verify screen reader compatibility - Verify screen reader compatibility
@ -253,7 +268,9 @@ src/stories/
- Configure CI integration - Configure CI integration
### Phase 4: Documentation and Polish (1-2 days) ### Phase 4: Documentation and Polish (1-2 days)
1. **Component Documentation** 1. **Component Documentation**
- Add JSDoc comments to components - Add JSDoc comments to components
- Create usage examples - Create usage examples
- Document props and events - Document props and events
@ -267,6 +284,7 @@ src/stories/
## Success Criteria ## Success Criteria
### Functional Requirements ### Functional Requirements
- [ ] Storybook runs successfully with `npm run storybook` - [ ] Storybook runs successfully with `npm run storybook`
- [ ] All existing components have basic stories - [ ] All existing components have basic stories
- [ ] SCSS variables and global styles work correctly - [ ] SCSS variables and global styles work correctly
@ -275,6 +293,7 @@ src/stories/
- [ ] TypeScript support is fully functional - [ ] TypeScript support is fully functional
### Quality Requirements ### Quality Requirements
- [ ] Stories cover all major component variants - [ ] Stories cover all major component variants
- [ ] Interactive controls work for all props - [ ] Interactive controls work for all props
- [ ] Actions are properly logged for events - [ ] Actions are properly logged for events
@ -282,6 +301,7 @@ src/stories/
- [ ] Components are responsive across viewport sizes - [ ] Components are responsive across viewport sizes
### Developer Experience Requirements ### Developer Experience Requirements
- [ ] Story creation is straightforward and documented - [ ] Story creation is straightforward and documented
- [ ] Mock data is easily accessible and realistic - [ ] Mock data is easily accessible and realistic
- [ ] Component API is clearly documented - [ ] Component API is clearly documented
@ -290,12 +310,14 @@ src/stories/
## Integration with Existing Workflow ## Integration with Existing Workflow
### Development Workflow ### Development Workflow
1. **Component Development**: Start new components in Storybook 1. **Component Development**: Start new components in Storybook
2. **Testing**: Test all states and edge cases in stories 2. **Testing**: Test all states and edge cases in stories
3. **Documentation**: Stories serve as living documentation 3. **Documentation**: Stories serve as living documentation
4. **Review**: Use Storybook for design/code reviews 4. **Review**: Use Storybook for design/code reviews
### Project Structure Integration ### Project Structure Integration
``` ```
package.json # Add storybook scripts package.json # Add storybook scripts
├── "storybook": "storybook dev -p 6006" ├── "storybook": "storybook dev -p 6006"
@ -309,35 +331,40 @@ src/
``` ```
### Scripts and Commands ### Scripts and Commands
```json ```json
{ {
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build", "build-storybook": "storybook build",
"storybook:test": "test-storybook" "storybook:test": "test-storybook"
} }
} }
``` ```
## Technical Considerations ## Technical Considerations
### SCSS Integration ### SCSS Integration
- Import global variables in Storybook preview - Import global variables in Storybook preview
- Ensure component styles render correctly - Ensure component styles render correctly
- Test responsive breakpoints - Test responsive breakpoints
### SvelteKit Compatibility ### SvelteKit Compatibility
- Handle SvelteKit-specific imports (like `$app/stores`) - Handle SvelteKit-specific imports (like `$app/stores`)
- Mock SvelteKit modules when needed - Mock SvelteKit modules when needed
- Ensure aliases work in Storybook context - Ensure aliases work in Storybook context
### TypeScript Support ### TypeScript Support
- Configure proper type checking - Configure proper type checking
- Use TypeScript for story definitions where beneficial - Use TypeScript for story definitions where beneficial
- Ensure IntelliSense works for story arguments - Ensure IntelliSense works for story arguments
### Performance ### Performance
- Optimize bundle size for faster story loading - Optimize bundle size for faster story loading
- Use lazy loading for large story collections - Use lazy loading for large story collections
- Configure appropriate caching - Configure appropriate caching
@ -345,16 +372,19 @@ src/
## Future Enhancements ## Future Enhancements
### Advanced Testing ### Advanced Testing
- **Visual Regression Testing**: Use Chromatic for automated visual testing - **Visual Regression Testing**: Use Chromatic for automated visual testing
- **Interaction Testing**: Add @storybook/addon-interactions for user flow testing - **Interaction Testing**: Add @storybook/addon-interactions for user flow testing
- **Accessibility Automation**: Automated a11y testing in CI/CD - **Accessibility Automation**: Automated a11y testing in CI/CD
### Design System Evolution ### Design System Evolution
- **Design Tokens**: Implement design tokens addon - **Design Tokens**: Implement design tokens addon
- **Figma Integration**: Connect with Figma designs - **Figma Integration**: Connect with Figma designs
- **Component Status**: Track component implementation status - **Component Status**: Track component implementation status
### Collaboration Features ### Collaboration Features
- **Published Storybook**: Deploy Storybook for team access - **Published Storybook**: Deploy Storybook for team access
- **Design Review Process**: Use Storybook for design approvals - **Design Review Process**: Use Storybook for design approvals
- **Documentation Site**: Evolve into full design system documentation - **Documentation Site**: Evolve into full design system documentation
@ -362,30 +392,35 @@ src/
## Risks and Mitigation ## Risks and Mitigation
### Technical Risks ### Technical Risks
- **Build Conflicts**: Vite configuration conflicts - **Build Conflicts**: Vite configuration conflicts
- *Mitigation*: Careful configuration merging and testing - _Mitigation_: Careful configuration merging and testing
- **SCSS Import Issues**: Global styles not loading - **SCSS Import Issues**: Global styles not loading
- *Mitigation*: Test SCSS integration early in setup - _Mitigation_: Test SCSS integration early in setup
### Workflow Risks ### Workflow Risks
- **Adoption Resistance**: Team not using Storybook - **Adoption Resistance**: Team not using Storybook
- *Mitigation*: Start with high-value components, show immediate benefits - _Mitigation_: Start with high-value components, show immediate benefits
- **Maintenance Overhead**: Stories become outdated - **Maintenance Overhead**: Stories become outdated
- *Mitigation*: Include story updates in component change process - _Mitigation_: Include story updates in component change process
## Success Metrics ## Success Metrics
### Development Efficiency ### Development Efficiency
- Reduced time to develop new components - Reduced time to develop new components
- Faster iteration on component designs - Faster iteration on component designs
- Fewer bugs in component edge cases - Fewer bugs in component edge cases
### Code Quality ### Code Quality
- Better component API consistency - Better component API consistency
- Improved accessibility compliance - Improved accessibility compliance
- More comprehensive component testing - More comprehensive component testing
### Team Collaboration ### Team Collaboration
- Faster design review cycles - Faster design review cycles
- Better communication between design and development - Better communication between design and development
- More consistent component usage across the application - More consistent component usage across the application
@ -394,4 +429,4 @@ src/
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. 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. 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

@ -3,7 +3,10 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" /> <meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
<meta property="og:title" content="@jedmund" /> <meta property="og:title" content="@jedmund" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="https://jedmund.com" /> <meta property="og:url" content="https://jedmund.com" />

View file

@ -10,8 +10,7 @@
} = $props() } = $props()
// Convert string array to slideshow items // Convert string array to slideshow items
const slideshowItems = $derived(images.map(url => ({ url, alt }))) const slideshowItems = $derived(images.map((url) => ({ url, alt })))
</script> </script>
<Slideshow items={slideshowItems} {alt} aspectRatio="4/3" /> <Slideshow items={slideshowItems} {alt} aspectRatio="4/3" />

View file

@ -7,13 +7,7 @@
source?: string source?: string
} }
let { let { href = '', title = '', sourceType = '', date = '', source = '' }: Props = $props()
href = '',
title = '',
sourceType = '',
date = '',
source = ''
}: Props = $props()
</script> </script>
<li class="mention"> <li class="mention">

View file

@ -31,9 +31,10 @@
currentPath === '/' currentPath === '/'
? navItems[0] ? navItems[0]
: currentPath === '/about' : currentPath === '/about'
? navItems[4] ? navItems[4]
: navItems.find((item) => currentPath.startsWith(item.href === '/' ? '/work' : item.href)) || : navItems.find((item) =>
navItems[0] currentPath.startsWith(item.href === '/' ? '/work' : item.href)
) || navItems[0]
) )
// Get background color based on variant // Get background color based on variant

View file

@ -60,6 +60,8 @@
} }
&.essay { &.essay {
max-width: 100%; // Full width for essays
.post-body { .post-body {
font-size: 1rem; font-size: 1rem;
line-height: 1.5; line-height: 1.5;
@ -75,6 +77,8 @@
} }
&.blog { &.blog {
max-width: 100%; // Full width for blog posts (legacy essays)
.post-body { .post-body {
font-size: 1rem; font-size: 1rem;
line-height: 1.5; line-height: 1.5;

View file

@ -57,10 +57,10 @@
</time> </time>
</a> </a>
{#if type === 'album'} {#if type === 'album'}
<PhotosIcon class="card-icon" /> <PhotosIcon class="card-icon" />
{:else} {:else}
<UniverseIcon class="card-icon" /> <UniverseIcon class="card-icon" />
{/if} {/if}
</div> </div>
</div> </div>
</article> </article>

View file

@ -23,7 +23,6 @@
</h2> </h2>
{/if} {/if}
{#if post.content} {#if post.content}
<div class="post-excerpt"> <div class="post-excerpt">
<p>{getContentExcerpt(post.content, 150)}</p> <p>{getContentExcerpt(post.content, 150)}</p>

View file

@ -124,7 +124,7 @@
.split(',') .split(',')
.map((tag) => tag.trim()) .map((tag) => tag.trim())
.filter(Boolean) .filter(Boolean)
: [], : []
} }
const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts' const url = mode === 'edit' ? `/api/posts/${postId}` : '/api/posts'
@ -159,7 +159,6 @@
} }
} }
async function handlePublish() { async function handlePublish() {
status = 'published' status = 'published'
await handleSave() await handleSave()

View file

@ -59,7 +59,8 @@
role: data.role || '', role: data.role || '',
projectType: data.projectType || 'work', projectType: data.projectType || 'work',
externalUrl: data.externalUrl || '', externalUrl: data.externalUrl || '',
featuredImage: data.featuredImage && data.featuredImage.trim() !== '' ? data.featuredImage : null, featuredImage:
data.featuredImage && data.featuredImage.trim() !== '' ? data.featuredImage : null,
backgroundColor: data.backgroundColor || '', backgroundColor: data.backgroundColor || '',
highlightColor: data.highlightColor || '', highlightColor: data.highlightColor || '',
logoUrl: data.logoUrl && data.logoUrl.trim() !== '' ? data.logoUrl : '', logoUrl: data.logoUrl && data.logoUrl.trim() !== '' ? data.logoUrl : '',
@ -140,7 +141,8 @@
role: formData.role, role: formData.role,
projectType: formData.projectType, projectType: formData.projectType,
externalUrl: formData.externalUrl, externalUrl: formData.externalUrl,
featuredImage: formData.featuredImage && formData.featuredImage !== '' ? formData.featuredImage : null, featuredImage:
formData.featuredImage && formData.featuredImage !== '' ? formData.featuredImage : null,
logoUrl: formData.logoUrl && formData.logoUrl !== '' ? formData.logoUrl : null, logoUrl: formData.logoUrl && formData.logoUrl !== '' ? formData.logoUrl : null,
backgroundColor: formData.backgroundColor, backgroundColor: formData.backgroundColor,
highlightColor: formData.highlightColor, highlightColor: formData.highlightColor,
@ -222,11 +224,9 @@
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
disabled={isSaving} disabled={isSaving}
isLoading={isSaving} isLoading={isSaving}
primaryAction={ primaryAction={formData.status === 'published'
formData.status === 'published' ? { label: 'Save', status: 'published' }
? { label: 'Save', status: 'published' } : { label: 'Publish', status: 'published' }}
: { label: 'Publish', status: 'published' }
}
dropdownActions={[ dropdownActions={[
{ label: 'Save as Draft', status: 'draft', show: formData.status !== 'draft' }, { label: 'Save as Draft', status: 'draft', show: formData.status !== 'draft' },
{ label: 'List Only', status: 'list-only', show: formData.status !== 'list-only' }, { label: 'List Only', status: 'list-only', show: formData.status !== 'list-only' },

View file

@ -135,4 +135,4 @@
align-items: center; align-items: center;
margin-bottom: $unit-3x; margin-bottom: $unit-3x;
} }
</style> </style>

View file

@ -12,7 +12,6 @@
} }
let { formData = $bindable(), validationErrors, onSave }: Props = $props() let { formData = $bindable(), validationErrors, onSave }: Props = $props()
</script> </script>
<div class="form-section"> <div class="form-section">

View file

@ -94,7 +94,9 @@
&.active { &.active {
background-color: white; background-color: white;
color: $grey-10; color: $grey-10;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04); box-shadow:
0 1px 3px rgba(0, 0, 0, 0.08),
0 1px 2px rgba(0, 0, 0, 0.04);
} }
&:focus { &:focus {
@ -105,4 +107,4 @@
box-shadow: 0 0 0 2px $primary-color; box-shadow: 0 0 0 2px $primary-color;
} }
} }
</style> </style>

View file

@ -38,15 +38,7 @@
<FormFieldWrapper {label} {required} {helpText} {error}> <FormFieldWrapper {label} {required} {helpText} {error}>
{#snippet children()} {#snippet children()}
<Select <Select bind:value {options} {size} {variant} {fullWidth} {pill} {...restProps} />
bind:value
{options}
{size}
{variant}
{fullWidth}
{pill}
{...restProps}
/>
{/snippet} {/snippet}
</FormFieldWrapper> </FormFieldWrapper>
@ -57,4 +49,4 @@
margin-top: 0; margin-top: 0;
} }
} }
</style> </style>

View file

@ -78,8 +78,8 @@ export async function uploadFile(
): Promise<UploadResult> { ): Promise<UploadResult> {
try { try {
// TEMPORARY: Force Cloudinary usage for testing // TEMPORARY: Force Cloudinary usage for testing
const FORCE_CLOUDINARY_IN_DEV = true; // Toggle this to test const FORCE_CLOUDINARY_IN_DEV = true // Toggle this to test
// Use local storage in development or when Cloudinary is not configured // Use local storage in development or when Cloudinary is not configured
if ((dev && !FORCE_CLOUDINARY_IN_DEV) || !isCloudinaryConfigured()) { if ((dev && !FORCE_CLOUDINARY_IN_DEV) || !isCloudinaryConfigured()) {
logger.info('Using local storage for file upload') logger.info('Using local storage for file upload')
@ -111,11 +111,11 @@ export async function uploadFile(
// Check if file is SVG for logging purposes // Check if file is SVG for logging purposes
const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg') const isSvg = file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg')
// Extract filename without extension // Extract filename without extension
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '') const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '')
const fileExtension = file.name.split('.').pop()?.toLowerCase() const fileExtension = file.name.split('.').pop()?.toLowerCase()
// Prepare upload options // Prepare upload options
const uploadOptions = { const uploadOptions = {
...uploadPresets[type], ...uploadPresets[type],
@ -124,7 +124,7 @@ export async function uploadFile(
// For SVG files, explicitly set format to preserve extension // For SVG files, explicitly set format to preserve extension
...(isSvg && { format: 'svg' }) ...(isSvg && { format: 'svg' })
} }
// Log upload attempt for debugging // Log upload attempt for debugging
logger.info('Attempting file upload:', { logger.info('Attempting file upload:', {
filename: file.name, filename: file.name,
@ -136,14 +136,11 @@ export async function uploadFile(
// Upload to Cloudinary // Upload to Cloudinary
const result = await new Promise<UploadApiResponse>((resolve, reject) => { const result = await new Promise<UploadApiResponse>((resolve, reject) => {
const uploadStream = cloudinary.uploader.upload_stream( const uploadStream = cloudinary.uploader.upload_stream(uploadOptions, (error, result) => {
uploadOptions, if (error) reject(error)
(error, result) => { else if (result) resolve(result)
if (error) reject(error) else reject(new Error('Upload failed'))
else if (result) resolve(result) })
else reject(new Error('Upload failed'))
}
)
uploadStream.end(buffer) uploadStream.end(buffer)
}) })
@ -170,7 +167,7 @@ export async function uploadFile(
} catch (error) { } catch (error) {
logger.error('Cloudinary upload failed', error as Error) logger.error('Cloudinary upload failed', error as Error)
logger.mediaUpload(file.name, file.size, file.type, false) logger.mediaUpload(file.name, file.size, file.type, false)
// Enhanced error logging // Enhanced error logging
if (error instanceof Error) { if (error instanceof Error) {
logger.error('Upload error details:', { logger.error('Upload error details:', {
@ -209,7 +206,7 @@ export async function deleteFile(publicId: string): Promise<boolean> {
const result = await cloudinary.uploader.destroy(publicId, { const result = await cloudinary.uploader.destroy(publicId, {
resource_type: 'auto' resource_type: 'auto'
}) })
return result.result === 'ok' return result.result === 'ok'
} catch (error) { } catch (error) {
logger.error('Cloudinary delete failed', error as Error) logger.error('Cloudinary delete failed', error as Error)

View file

@ -169,47 +169,49 @@ function renderTiptapContent(doc: any): string {
// Render inline content (text nodes with marks) // Render inline content (text nodes with marks)
const renderInlineContent = (content: any[]): string => { const renderInlineContent = (content: any[]): string => {
return content.map((node: any) => { return content
if (node.type === 'text') { .map((node: any) => {
let text = escapeHtml(node.text || '') if (node.type === 'text') {
let text = escapeHtml(node.text || '')
// Apply marks (bold, italic, etc.)
if (node.marks) { // Apply marks (bold, italic, etc.)
node.marks.forEach((mark: any) => { if (node.marks) {
switch (mark.type) { node.marks.forEach((mark: any) => {
case 'bold': switch (mark.type) {
text = `<strong>${text}</strong>` case 'bold':
break text = `<strong>${text}</strong>`
case 'italic': break
text = `<em>${text}</em>` case 'italic':
break text = `<em>${text}</em>`
case 'underline': break
text = `<u>${text}</u>` case 'underline':
break text = `<u>${text}</u>`
case 'strike': break
text = `<s>${text}</s>` case 'strike':
break text = `<s>${text}</s>`
case 'code': break
text = `<code>${text}</code>` case 'code':
break text = `<code>${text}</code>`
case 'link': break
const href = mark.attrs?.href || '#' case 'link':
const target = mark.attrs?.target || '_blank' const href = mark.attrs?.href || '#'
text = `<a href="${href}" target="${target}" rel="noopener noreferrer">${text}</a>` const target = mark.attrs?.target || '_blank'
break text = `<a href="${href}" target="${target}" rel="noopener noreferrer">${text}</a>`
case 'highlight': break
text = `<mark>${text}</mark>` case 'highlight':
break text = `<mark>${text}</mark>`
} break
}) }
})
}
return text
} }
return text // Handle other inline nodes
} return renderNode(node)
})
// Handle other inline nodes .join('')
return renderNode(node)
}).join('')
} }
// Helper to escape HTML // Helper to escape HTML
@ -228,7 +230,7 @@ function renderTiptapContent(doc: any): string {
// Extract text content from Edra JSON for excerpt // Extract text content from Edra JSON for excerpt
export const getContentExcerpt = (content: any, maxLength = 200): string => { export const getContentExcerpt = (content: any, maxLength = 200): string => {
if (!content) return '' if (!content) return ''
// Handle Tiptap format first (has type: 'doc') // Handle Tiptap format first (has type: 'doc')
if (content.type === 'doc' && content.content) { if (content.type === 'doc' && content.content) {
return extractTiptapText(content, maxLength) return extractTiptapText(content, maxLength)
@ -263,14 +265,14 @@ function extractTiptapText(doc: any, maxLength: number): string {
if (node.type === 'text') { if (node.type === 'text') {
return node.text || '' return node.text || ''
} }
if (node.content && Array.isArray(node.content)) { if (node.content && Array.isArray(node.content)) {
return node.content.map(extractFromNode).join(' ') return node.content.map(extractFromNode).join(' ')
} }
return '' return ''
} }
const text = doc.content.map(extractFromNode).join(' ').trim() const text = doc.content.map(extractFromNode).join(' ').trim()
if (text.length <= maxLength) return text if (text.length <= maxLength) return text
return text.substring(0, maxLength).trim() + '...' return text.substring(0, maxLength).trim() + '...'

View file

@ -5,4 +5,4 @@ export function formatDate(dateString: string): string {
month: 'short', month: 'short',
day: 'numeric' day: 'numeric'
}) })
} }

View file

@ -9,10 +9,7 @@
<svelte:head> <svelte:head>
<title>@jedmund is a software designer</title> <title>@jedmund is a software designer</title>
<meta name="description" content="Justin Edmund is a software designer based in San Francisco." /> <meta name="description" content="Justin Edmund is a software designer based in San Francisco." />
<meta <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
</svelte:head> </svelte:head>
{#if !isAdminRoute} {#if !isAdminRoute}

View file

@ -213,11 +213,14 @@
// Add photos to album via API // Add photos to album via API
const addedPhotos = [] const addedPhotos = []
console.log('Adding photos to album:', newMedia.map(m => ({ id: m.id, filename: m.filename }))) console.log(
'Adding photos to album:',
newMedia.map((m) => ({ id: m.id, filename: m.filename }))
)
for (const media of newMedia) { for (const media of newMedia) {
console.log(`Adding photo ${media.id} (${media.filename}) to album ${album.id}`) console.log(`Adding photo ${media.id} (${media.filename}) to album ${album.id}`)
const response = await fetch(`/api/albums/${album.id}/photos`, { const response = await fetch(`/api/albums/${album.id}/photos`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -596,14 +599,10 @@
onStatusChange={handleSave} onStatusChange={handleSave}
disabled={isSaving} disabled={isSaving}
isLoading={isSaving} isLoading={isSaving}
primaryAction={ primaryAction={status === 'published'
status === 'published' ? { label: 'Save', status: 'published' }
? { label: 'Save', status: 'published' } : { label: 'Publish', status: 'published' }}
: { label: 'Publish', status: 'published' } dropdownActions={[{ label: 'Save as Draft', status: 'draft', show: status !== 'draft' }]}
}
dropdownActions={[
{ label: 'Save as Draft', status: 'draft', show: status !== 'draft' }
]}
/> />
</div> </div>
{/if} {/if}

View file

@ -102,13 +102,13 @@
// Add selected photos to the newly created album // Add selected photos to the newly created album
if (albumPhotos.length > 0) { if (albumPhotos.length > 0) {
console.log(`Adding ${albumPhotos.length} photos to newly created album ${album.id}`) console.log(`Adding ${albumPhotos.length} photos to newly created album ${album.id}`)
try { try {
const addedPhotos = [] const addedPhotos = []
for (let i = 0; i < albumPhotos.length; i++) { for (let i = 0; i < albumPhotos.length; i++) {
const media = albumPhotos[i] const media = albumPhotos[i]
console.log(`Adding photo ${media.id} to album ${album.id}`) console.log(`Adding photo ${media.id} to album ${album.id}`)
const photoResponse = await fetch(`/api/albums/${album.id}/photos`, { const photoResponse = await fetch(`/api/albums/${album.id}/photos`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -123,7 +123,11 @@
if (!photoResponse.ok) { if (!photoResponse.ok) {
const errorData = await photoResponse.text() const errorData = await photoResponse.text()
console.error(`Failed to add photo ${media.filename}:`, photoResponse.status, errorData) console.error(
`Failed to add photo ${media.filename}:`,
photoResponse.status,
errorData
)
// Continue with other photos even if one fails // Continue with other photos even if one fails
} else { } else {
const photo = await photoResponse.json() const photo = await photoResponse.json()
@ -131,8 +135,10 @@
console.log(`Successfully added photo ${photo.id} to album`) console.log(`Successfully added photo ${photo.id} to album`)
} }
} }
console.log(`Successfully added ${addedPhotos.length} out of ${albumPhotos.length} photos to album`) console.log(
`Successfully added ${addedPhotos.length} out of ${albumPhotos.length} photos to album`
)
} catch (photoError) { } catch (photoError) {
console.error('Error adding photos to album:', photoError) console.error('Error adding photos to album:', photoError)
// Don't fail the whole creation - just log the error // Don't fail the whole creation - just log the error

View file

@ -30,40 +30,37 @@
let error = $state('') let error = $state('')
let total = $state(0) let total = $state(0)
let postTypeCounts = $state<Record<string, number>>({}) let postTypeCounts = $state<Record<string, number>>({})
let statusCounts = $state<Record<string, number>>({})
// Filter state // Filter state
let selectedFilter = $state<string>('all') let selectedTypeFilter = $state<string>('all')
let selectedStatusFilter = $state<string>('all')
// Composer state // Composer state
let showInlineComposer = $state(true) let showInlineComposer = $state(true)
let isInteractingWithFilters = $state(false)
// Create filter options // Create filter options
const filterOptions = $derived([ const typeFilterOptions = $derived([
{ value: 'all', label: 'All posts' }, { value: 'all', label: 'All posts' },
{ value: 'post', label: 'Posts' }, { value: 'post', label: 'Posts' },
{ value: 'essay', label: 'Essays' } { value: 'essay', label: 'Essays' }
]) ])
const statusFilterOptions = $derived([
{ value: 'all', label: 'All statuses' },
{ value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' }
])
const postTypeIcons: Record<string, string> = { const postTypeIcons: Record<string, string> = {
post: '💭', post: '💭',
essay: '📝', essay: '📝'
// Legacy types for backward compatibility
blog: '📝',
microblog: '💭',
link: '🔗',
photo: '📷',
album: '🖼️'
} }
const postTypeLabels: Record<string, string> = { const postTypeLabels: Record<string, string> = {
post: 'Post', post: 'Post',
essay: 'Essay', essay: 'Essay'
// Legacy types for backward compatibility
blog: 'Essay',
microblog: 'Post',
link: 'Post',
photo: 'Post',
album: 'Album'
} }
onMount(async () => { onMount(async () => {
@ -94,24 +91,29 @@
posts = data.posts || [] posts = data.posts || []
total = data.pagination?.total || posts.length total = data.pagination?.total || posts.length
// Calculate post type counts and normalize types // Calculate post type counts
const counts: Record<string, number> = { const typeCounts: Record<string, number> = {
all: posts.length, all: posts.length,
post: 0, post: 0,
essay: 0 essay: 0
} }
posts.forEach((post) => { posts.forEach((post) => {
// Normalize legacy types to simplified types if (post.postType === 'post') {
if (post.postType === 'blog') { typeCounts.post++
counts.essay = (counts.essay || 0) + 1 } else if (post.postType === 'essay') {
} else if (['microblog', 'link', 'photo'].includes(post.postType)) { typeCounts.essay++
counts.post = (counts.post || 0) + 1
} else {
counts[post.postType] = (counts[post.postType] || 0) + 1
} }
}) })
postTypeCounts = counts postTypeCounts = typeCounts
// Calculate status counts
const statusCountsTemp: Record<string, number> = {
all: posts.length,
published: posts.filter((p) => p.status === 'published').length,
draft: posts.filter((p) => p.status === 'draft').length
}
statusCounts = statusCountsTemp
// Apply initial filter // Apply initial filter
applyFilter() applyFilter()
@ -124,18 +126,26 @@
} }
function applyFilter() { function applyFilter() {
if (selectedFilter === 'all') { let filtered = posts
filteredPosts = posts
} else if (selectedFilter === 'post') { // Apply type filter
filteredPosts = posts.filter((post) => ['post', 'microblog'].includes(post.postType)) if (selectedTypeFilter !== 'all') {
} else if (selectedFilter === 'essay') { filtered = filtered.filter((post) => post.postType === selectedTypeFilter)
filteredPosts = posts.filter((post) => ['essay', 'blog'].includes(post.postType))
} else {
filteredPosts = posts.filter((post) => post.postType === selectedFilter)
} }
// Apply status filter
if (selectedStatusFilter !== 'all') {
filtered = filtered.filter((post) => post.status === selectedStatusFilter)
}
filteredPosts = filtered
} }
function handleFilterChange() { function handleTypeFilterChange() {
applyFilter()
}
function handleStatusFilterChange() {
applyFilter() applyFilter()
} }
@ -168,11 +178,18 @@
<AdminFilters> <AdminFilters>
{#snippet left()} {#snippet left()}
<Select <Select
bind:value={selectedFilter} bind:value={selectedTypeFilter}
options={filterOptions} options={typeFilterOptions}
size="small" size="small"
variant="minimal" variant="minimal"
onchange={handleFilterChange} onchange={handleTypeFilterChange}
/>
<Select
bind:value={selectedStatusFilter}
options={statusFilterOptions}
size="small"
variant="minimal"
onchange={handleStatusFilterChange}
/> />
{/snippet} {/snippet}
</AdminFilters> </AdminFilters>
@ -187,10 +204,11 @@
<div class="empty-icon">📝</div> <div class="empty-icon">📝</div>
<h3>No posts found</h3> <h3>No posts found</h3>
<p> <p>
{#if selectedFilter === 'all'} {#if selectedTypeFilter === 'all' && selectedStatusFilter === 'all'}
Create your first post to get started! Create your first post to get started!
{:else} {:else}
No {selectedFilter}s found. Try a different filter or create a new {selectedFilter}. No posts found matching the current filters. Try adjusting your filters or create a new
post.
{/if} {/if}
</p> </p>
</div> </div>

View file

@ -34,22 +34,22 @@
let statusCounts = $state<Record<string, number>>({}) let statusCounts = $state<Record<string, number>>({})
// Filter state // Filter state
let selectedStatusFilter = $state<string>('all')
let selectedTypeFilter = $state<string>('all') let selectedTypeFilter = $state<string>('all')
let selectedStatusFilter = $state<string>('all')
// Create filter options // Create filter options
const statusFilterOptions = $derived([ const typeFilterOptions = $derived([
{ value: 'all', label: 'All projects' }, { value: 'all', label: 'All projects' },
{ value: 'work', label: 'Work' },
{ value: 'labs', label: 'Labs' }
])
const statusFilterOptions = $derived([
{ value: 'all', label: 'All statuses' },
{ value: 'published', label: 'Published' }, { value: 'published', label: 'Published' },
{ value: 'draft', label: 'Draft' } { value: 'draft', label: 'Draft' }
]) ])
const typeFilterOptions = [
{ value: 'all', label: 'All types' },
{ value: 'work', label: 'Work' },
{ value: 'labs', label: 'Labs' }
]
onMount(async () => { onMount(async () => {
await loadProjects() await loadProjects()
// Handle clicks outside dropdowns // Handle clicks outside dropdowns
@ -204,13 +204,6 @@
<!-- Filters --> <!-- Filters -->
<AdminFilters> <AdminFilters>
{#snippet left()} {#snippet left()}
<Select
bind:value={selectedStatusFilter}
options={statusFilterOptions}
size="small"
variant="minimal"
onchange={handleStatusFilterChange}
/>
<Select <Select
bind:value={selectedTypeFilter} bind:value={selectedTypeFilter}
options={typeFilterOptions} options={typeFilterOptions}
@ -218,6 +211,13 @@
variant="minimal" variant="minimal"
onchange={handleTypeFilterChange} onchange={handleTypeFilterChange}
/> />
<Select
bind:value={selectedStatusFilter}
options={statusFilterOptions}
size="small"
variant="minimal"
onchange={handleStatusFilterChange}
/>
{/snippet} {/snippet}
</AdminFilters> </AdminFilters>

View file

@ -140,9 +140,11 @@ export const PUT: RequestHandler = async (event) => {
date: body.date !== undefined ? (body.date ? new Date(body.date) : null) : existing.date, date: body.date !== undefined ? (body.date ? new Date(body.date) : null) : existing.date,
location: body.location !== undefined ? body.location : existing.location, location: body.location !== undefined ? body.location : existing.location,
coverPhotoId: body.coverPhotoId !== undefined ? body.coverPhotoId : existing.coverPhotoId, coverPhotoId: body.coverPhotoId !== undefined ? body.coverPhotoId : existing.coverPhotoId,
isPhotography: body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography, isPhotography:
body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography,
status: body.status !== undefined ? body.status : existing.status, status: body.status !== undefined ? body.status : existing.status,
showInUniverse: body.showInUniverse !== undefined ? body.showInUniverse : existing.showInUniverse showInUniverse:
body.showInUniverse !== undefined ? body.showInUniverse : existing.showInUniverse
} }
}) })

View file

@ -70,7 +70,8 @@ export const PUT: RequestHandler = async (event) => {
data: { data: {
altText: body.altText !== undefined ? body.altText : existing.altText, altText: body.altText !== undefined ? body.altText : existing.altText,
description: body.description !== undefined ? body.description : existing.description, description: body.description !== undefined ? body.description : existing.description,
isPhotography: body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography isPhotography:
body.isPhotography !== undefined ? body.isPhotography : existing.isPhotography
} }
}) })

View file

@ -50,7 +50,7 @@ export const DELETE: RequestHandler = async (event) => {
for (const media of mediaRecords) { for (const media of mediaRecords) {
try { try {
let deleted = false let deleted = false
// Check if it's a Cloudinary URL // Check if it's a Cloudinary URL
if (media.url.includes('cloudinary.com')) { if (media.url.includes('cloudinary.com')) {
const publicId = extractPublicId(media.url) const publicId = extractPublicId(media.url)
@ -64,7 +64,10 @@ export const DELETE: RequestHandler = async (event) => {
// Local storage deletion // Local storage deletion
deleted = await deleteFileLocally(media.url) deleted = await deleteFileLocally(media.url)
if (!deleted) { if (!deleted) {
logger.warn('Failed to delete from local storage', { url: media.url, mediaId: media.id }) logger.warn('Failed to delete from local storage', {
url: media.url,
mediaId: media.id
})
} }
} }
@ -114,8 +117,8 @@ export const DELETE: RequestHandler = async (event) => {
}) })
// Count successful storage deletions // Count successful storage deletions
const successfulStorageDeletions = storageDeleteResults.filter(r => r.deleted).length const successfulStorageDeletions = storageDeleteResults.filter((r) => r.deleted).length
const failedStorageDeletions = storageDeleteResults.filter(r => !r.deleted) const failedStorageDeletions = storageDeleteResults.filter((r) => !r.deleted)
logger.info('Bulk media deletion completed', { logger.info('Bulk media deletion completed', {
deletedCount: deleteResult.count, deletedCount: deleteResult.count,

View file

@ -82,13 +82,17 @@ export const PUT: RequestHandler = async (event) => {
year: body.year !== undefined ? body.year : existing.year, year: body.year !== undefined ? body.year : existing.year,
client: body.client !== undefined ? body.client : existing.client, client: body.client !== undefined ? body.client : existing.client,
role: body.role !== undefined ? body.role : existing.role, role: body.role !== undefined ? body.role : existing.role,
featuredImage: body.featuredImage !== undefined ? body.featuredImage : existing.featuredImage, featuredImage:
body.featuredImage !== undefined ? body.featuredImage : existing.featuredImage,
logoUrl: body.logoUrl !== undefined ? body.logoUrl : existing.logoUrl, logoUrl: body.logoUrl !== undefined ? body.logoUrl : existing.logoUrl,
gallery: body.gallery !== undefined ? body.gallery : existing.gallery, gallery: body.gallery !== undefined ? body.gallery : existing.gallery,
externalUrl: body.externalUrl !== undefined ? body.externalUrl : existing.externalUrl, externalUrl: body.externalUrl !== undefined ? body.externalUrl : existing.externalUrl,
caseStudyContent: body.caseStudyContent !== undefined ? body.caseStudyContent : existing.caseStudyContent, caseStudyContent:
backgroundColor: body.backgroundColor !== undefined ? body.backgroundColor : existing.backgroundColor, body.caseStudyContent !== undefined ? body.caseStudyContent : existing.caseStudyContent,
highlightColor: body.highlightColor !== undefined ? body.highlightColor : existing.highlightColor, backgroundColor:
body.backgroundColor !== undefined ? body.backgroundColor : existing.backgroundColor,
highlightColor:
body.highlightColor !== undefined ? body.highlightColor : existing.highlightColor,
projectType: body.projectType !== undefined ? body.projectType : existing.projectType, projectType: body.projectType !== undefined ? body.projectType : existing.projectType,
displayOrder: body.displayOrder !== undefined ? body.displayOrder : existing.displayOrder, displayOrder: body.displayOrder !== undefined ? body.displayOrder : existing.displayOrder,
status: body.status !== undefined ? body.status : existing.status, status: body.status !== undefined ? body.status : existing.status,
@ -195,7 +199,7 @@ export const PATCH: RequestHandler = async (event) => {
// Build update data object with only provided fields // Build update data object with only provided fields
const updateData: any = {} const updateData: any = {}
// Handle status update specially // Handle status update specially
if (body.status !== undefined) { if (body.status !== undefined) {
updateData.status = body.status updateData.status = body.status