Fix Edit Post and Album list
This commit is contained in:
parent
cc09c9cd3f
commit
0d90981de0
15 changed files with 1704 additions and 555 deletions
610
PRD-enhanced-tag-system.md
Normal file
610
PRD-enhanced-tag-system.md
Normal file
|
|
@ -0,0 +1,610 @@
|
||||||
|
# Product Requirements Document: Enhanced Tag System
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Upgrade the current JSON-based tag system to a relational database model with advanced tagging features including tag filtering, related posts, tag management, and an improved tag input UI with typeahead functionality.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Enable efficient querying and filtering of posts by tags
|
||||||
|
- Provide tag management capabilities for content curation
|
||||||
|
- Show related posts based on shared tags
|
||||||
|
- Implement intuitive tag input with typeahead and keyboard shortcuts
|
||||||
|
- Build analytics and insights around tag usage
|
||||||
|
- Maintain backward compatibility during migration
|
||||||
|
|
||||||
|
## Technical Constraints
|
||||||
|
|
||||||
|
- **Framework**: SvelteKit with Svelte 5 runes mode
|
||||||
|
- **Database**: PostgreSQL with Prisma ORM
|
||||||
|
- **Hosting**: Railway (existing infrastructure)
|
||||||
|
- **Design System**: Use existing admin component library
|
||||||
|
- **Performance**: Tag operations should be sub-100ms
|
||||||
|
|
||||||
|
## Current State vs Target State
|
||||||
|
|
||||||
|
### Current Implementation
|
||||||
|
- Tags stored as JSON arrays: `tags: ['announcement', 'meta', 'cms']`
|
||||||
|
- Simple display-only functionality
|
||||||
|
- No querying capabilities
|
||||||
|
- Manual tag input with Add button
|
||||||
|
|
||||||
|
### Target Implementation
|
||||||
|
- Relational many-to-many tag system
|
||||||
|
- Full CRUD operations for tags
|
||||||
|
- Advanced filtering and search
|
||||||
|
- Typeahead tag input with keyboard navigation
|
||||||
|
- Tag analytics and management interface
|
||||||
|
|
||||||
|
## Database Schema Changes
|
||||||
|
|
||||||
|
### New Tables
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Tags table
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
color VARCHAR(7), -- Hex color for tag styling
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Post-Tag junction table
|
||||||
|
CREATE TABLE post_tags (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(post_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tag usage analytics (optional)
|
||||||
|
CREATE TABLE tag_analytics (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
usage_count INTEGER DEFAULT 1,
|
||||||
|
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prisma Schema Updates
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Tag {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique @db.VarChar(100)
|
||||||
|
slug String @unique @db.VarChar(100)
|
||||||
|
description String? @db.Text
|
||||||
|
color String? @db.VarChar(7) // Hex color
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
posts PostTag[]
|
||||||
|
|
||||||
|
@@index([name])
|
||||||
|
@@index([slug])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PostTag {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
postId Int
|
||||||
|
tagId Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||||
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([postId, tagId])
|
||||||
|
@@index([postId])
|
||||||
|
@@index([tagId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing Post model
|
||||||
|
model Post {
|
||||||
|
// ... existing fields
|
||||||
|
tags PostTag[] // Replace: tags Json?
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
### 1. Tag Management Interface
|
||||||
|
|
||||||
|
#### Admin Tag Manager (`/admin/tags`)
|
||||||
|
- **Tag List View**
|
||||||
|
- DataTable with tag name, usage count, created date
|
||||||
|
- Search and filter capabilities
|
||||||
|
- Bulk operations (delete, merge, rename)
|
||||||
|
- Color coding and visual indicators
|
||||||
|
|
||||||
|
- **Tag Detail/Edit View**
|
||||||
|
- Edit tag name, description, color
|
||||||
|
- View all posts using this tag
|
||||||
|
- Usage analytics and trends
|
||||||
|
- Merge with other tags functionality
|
||||||
|
|
||||||
|
#### Tag Analytics Dashboard
|
||||||
|
- **Usage Statistics**
|
||||||
|
- Most/least used tags
|
||||||
|
- Tag usage trends over time
|
||||||
|
- Orphaned tags (no posts)
|
||||||
|
- Tag co-occurrence patterns
|
||||||
|
|
||||||
|
- **Tag Insights**
|
||||||
|
- Suggested tag consolidations
|
||||||
|
- Similar tags detection
|
||||||
|
- Tag performance metrics
|
||||||
|
|
||||||
|
### 2. Enhanced Tag Input Component (`TagInput.svelte`)
|
||||||
|
|
||||||
|
#### Features
|
||||||
|
- **Typeahead Search**: Real-time search of existing tags
|
||||||
|
- **Keyboard Navigation**: Arrow keys to navigate suggestions
|
||||||
|
- **Instant Add**: Press Enter to add tag without button click
|
||||||
|
- **Visual Feedback**: Highlight matching text in suggestions
|
||||||
|
- **Tag Validation**: Prevent duplicates and invalid characters
|
||||||
|
- **Quick Actions**: Backspace to remove last tag
|
||||||
|
|
||||||
|
#### Component API
|
||||||
|
```typescript
|
||||||
|
interface TagInputProps {
|
||||||
|
tags: string[] | Tag[] // Current tags
|
||||||
|
suggestions?: Tag[] // Available tags for typeahead
|
||||||
|
placeholder?: string // Input placeholder text
|
||||||
|
maxTags?: number // Maximum number of tags
|
||||||
|
allowNew?: boolean // Allow creating new tags
|
||||||
|
size?: 'small' | 'medium' | 'large'
|
||||||
|
disabled?: boolean
|
||||||
|
onTagAdd?: (tag: Tag) => void
|
||||||
|
onTagRemove?: (tag: Tag) => void
|
||||||
|
onTagCreate?: (name: string) => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Svelte 5 Implementation
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
tags = $bindable([]),
|
||||||
|
suggestions = [],
|
||||||
|
placeholder = "Add tags...",
|
||||||
|
maxTags = 10,
|
||||||
|
allowNew = true,
|
||||||
|
size = 'medium',
|
||||||
|
disabled = false,
|
||||||
|
onTagAdd,
|
||||||
|
onTagRemove,
|
||||||
|
onTagCreate
|
||||||
|
}: TagInputProps = $props()
|
||||||
|
|
||||||
|
let inputValue = $state('')
|
||||||
|
let showSuggestions = $state(false)
|
||||||
|
let selectedIndex = $state(-1)
|
||||||
|
let inputElement: HTMLInputElement
|
||||||
|
|
||||||
|
// Filtered suggestions based on input
|
||||||
|
let filteredSuggestions = $derived(
|
||||||
|
suggestions.filter(tag =>
|
||||||
|
tag.name.toLowerCase().includes(inputValue.toLowerCase()) &&
|
||||||
|
!tags.some(t => t.id === tag.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle keyboard navigation
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault()
|
||||||
|
if (selectedIndex >= 0) {
|
||||||
|
addExistingTag(filteredSuggestions[selectedIndex])
|
||||||
|
} else if (inputValue.trim() && allowNew) {
|
||||||
|
createNewTag(inputValue.trim())
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
selectedIndex = Math.min(selectedIndex + 1, filteredSuggestions.length - 1)
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
selectedIndex = Math.max(selectedIndex - 1, -1)
|
||||||
|
break
|
||||||
|
case 'Backspace':
|
||||||
|
if (!inputValue && tags.length > 0) {
|
||||||
|
removeTag(tags[tags.length - 1])
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
showSuggestions = false
|
||||||
|
selectedIndex = -1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Post Filtering by Tags
|
||||||
|
|
||||||
|
#### Frontend Components
|
||||||
|
- **Tag Filter Bar**: Multi-select tag filtering
|
||||||
|
- **Tag Cloud**: Visual tag representation with usage counts
|
||||||
|
- **Search Integration**: Combine text search with tag filters
|
||||||
|
|
||||||
|
#### API Endpoints
|
||||||
|
```typescript
|
||||||
|
// GET /api/posts?tags=javascript,react&operation=AND
|
||||||
|
// GET /api/posts?tags=design,ux&operation=OR
|
||||||
|
interface PostsQueryParams {
|
||||||
|
tags?: string[] // Tag names or IDs
|
||||||
|
operation?: 'AND' | 'OR' // How to combine multiple tags
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
status?: 'published' | 'draft'
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tags/suggest?q=java
|
||||||
|
interface TagSuggestResponse {
|
||||||
|
tags: Array<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
usageCount: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Related Posts Feature
|
||||||
|
|
||||||
|
#### Implementation
|
||||||
|
- **Algorithm**: Find posts sharing the most tags
|
||||||
|
- **Weighting**: Consider tag importance and recency
|
||||||
|
- **Exclusions**: Don't show current post in related list
|
||||||
|
- **Limit**: Show 3-6 related posts maximum
|
||||||
|
|
||||||
|
#### Component (`RelatedPosts.svelte`)
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
let { postId, tags, limit = 4 }: {
|
||||||
|
postId: number
|
||||||
|
tags: Tag[]
|
||||||
|
limit?: number
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
let relatedPosts = $state<Post[]>([])
|
||||||
|
|
||||||
|
$effect(async () => {
|
||||||
|
const tagIds = tags.map(t => t.id)
|
||||||
|
const response = await fetch(`/api/posts/related?postId=${postId}&tagIds=${tagIds.join(',')}&limit=${limit}`)
|
||||||
|
relatedPosts = await response.json()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Specification
|
||||||
|
|
||||||
|
### Tag Management APIs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// GET /api/tags - List all tags
|
||||||
|
interface TagsResponse {
|
||||||
|
tags: Tag[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/tags - Create new tag
|
||||||
|
interface CreateTagRequest {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/tags/[id] - Update tag
|
||||||
|
interface UpdateTagRequest {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/tags/[id] - Delete tag
|
||||||
|
// Returns: 204 No Content
|
||||||
|
|
||||||
|
// POST /api/tags/merge - Merge tags
|
||||||
|
interface MergeTagsRequest {
|
||||||
|
sourceTagIds: number[]
|
||||||
|
targetTagId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tags/[id]/posts - Get posts for tag
|
||||||
|
interface TagPostsResponse {
|
||||||
|
posts: Post[]
|
||||||
|
tag: Tag
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/tags/analytics - Tag usage analytics
|
||||||
|
interface TagAnalyticsResponse {
|
||||||
|
mostUsed: Array<{ tag: Tag; count: number }>
|
||||||
|
leastUsed: Array<{ tag: Tag; count: number }>
|
||||||
|
trending: Array<{ tag: Tag; growth: number }>
|
||||||
|
orphaned: Tag[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enhanced Post APIs
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// GET /api/posts/related?postId=123&tagIds=1,2,3&limit=4
|
||||||
|
interface RelatedPostsResponse {
|
||||||
|
posts: Array<{
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
slug: string
|
||||||
|
excerpt?: string
|
||||||
|
publishedAt: string
|
||||||
|
tags: Tag[]
|
||||||
|
sharedTagsCount: number // Number of tags in common
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/posts/[id]/tags - Update post tags
|
||||||
|
interface UpdatePostTagsRequest {
|
||||||
|
tagIds: number[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Interface Components
|
||||||
|
|
||||||
|
### 1. TagInput Component Features
|
||||||
|
|
||||||
|
#### Visual States
|
||||||
|
- **Default**: Clean input with placeholder
|
||||||
|
- **Focused**: Show suggestions dropdown
|
||||||
|
- **Typing**: Filter and highlight matches
|
||||||
|
- **Selected**: Navigate with keyboard
|
||||||
|
- **Adding**: Smooth animation for new tags
|
||||||
|
- **Full**: Disable input when max tags reached
|
||||||
|
|
||||||
|
#### Accessibility
|
||||||
|
- **ARIA Labels**: Proper labeling for screen readers
|
||||||
|
- **Keyboard Navigation**: Full keyboard accessibility
|
||||||
|
- **Focus Management**: Logical tab order
|
||||||
|
- **Announcements**: Screen reader feedback for actions
|
||||||
|
|
||||||
|
### 2. Tag Display Components
|
||||||
|
|
||||||
|
#### TagPill Component
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
tag,
|
||||||
|
size = 'medium',
|
||||||
|
removable = false,
|
||||||
|
clickable = false,
|
||||||
|
showCount = false,
|
||||||
|
onRemove,
|
||||||
|
onClick
|
||||||
|
}: TagPillProps = $props()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="tag-pill tag-pill-{size}"
|
||||||
|
style="--tag-color: {tag.color}"
|
||||||
|
class:clickable
|
||||||
|
class:removable
|
||||||
|
onclick={onClick}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
{#if showCount}
|
||||||
|
<span class="tag-count">({tag.usageCount})</span>
|
||||||
|
{/if}
|
||||||
|
{#if removable}
|
||||||
|
<button onclick={onRemove} class="tag-remove">×</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TagCloud Component
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
tags,
|
||||||
|
maxTags = 50,
|
||||||
|
minFontSize = 12,
|
||||||
|
maxFontSize = 24,
|
||||||
|
onClick
|
||||||
|
}: TagCloudProps = $props()
|
||||||
|
|
||||||
|
// Calculate font sizes based on usage
|
||||||
|
let tagSizes = $derived(calculateTagSizes(tags, minFontSize, maxFontSize))
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Admin Interface Updates
|
||||||
|
|
||||||
|
#### Posts List with Tag Filtering
|
||||||
|
- **Filter Bar**: Multi-select tag filter above posts list
|
||||||
|
- **Tag Pills**: Show tags on each post item
|
||||||
|
- **Quick Filter**: Click tag to filter by that tag
|
||||||
|
- **Clear Filters**: Easy way to reset all filters
|
||||||
|
|
||||||
|
#### Posts Edit Form Integration
|
||||||
|
- **Replace Current**: Swap existing tag input with new TagInput
|
||||||
|
- **Preserve UX**: Maintain current metadata popover
|
||||||
|
- **Tag Management**: Quick access to create/edit tags
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
### Phase 1: Database Migration (Week 1)
|
||||||
|
1. **Create Migration Script**
|
||||||
|
- Create new tables (tags, post_tags)
|
||||||
|
- Migrate existing JSON tags to relational format
|
||||||
|
- Create indexes for performance
|
||||||
|
|
||||||
|
2. **Data Migration**
|
||||||
|
- Extract unique tags from existing posts
|
||||||
|
- Create tag records with auto-generated slugs
|
||||||
|
- Create post_tag relationships
|
||||||
|
- Validate data integrity
|
||||||
|
|
||||||
|
3. **Backward Compatibility**
|
||||||
|
- Keep original tags JSON field temporarily
|
||||||
|
- Dual-write to both systems during transition
|
||||||
|
|
||||||
|
### Phase 2: API Development (Week 1-2)
|
||||||
|
1. **Tag Management APIs**
|
||||||
|
- CRUD operations for tags
|
||||||
|
- Tag suggestions and search
|
||||||
|
- Analytics endpoints
|
||||||
|
|
||||||
|
2. **Enhanced Post APIs**
|
||||||
|
- Update post endpoints for relational tags
|
||||||
|
- Related posts algorithm
|
||||||
|
- Tag filtering capabilities
|
||||||
|
|
||||||
|
3. **Testing & Validation**
|
||||||
|
- Unit tests for all endpoints
|
||||||
|
- Performance testing for queries
|
||||||
|
- Data consistency checks
|
||||||
|
|
||||||
|
### Phase 3: Frontend Components (Week 2-3)
|
||||||
|
1. **Core Components**
|
||||||
|
- TagInput with typeahead
|
||||||
|
- TagPill and TagCloud
|
||||||
|
- Tag management interface
|
||||||
|
|
||||||
|
2. **Integration**
|
||||||
|
- Update MetadataPopover
|
||||||
|
- Add tag filtering to posts list
|
||||||
|
- Implement related posts component
|
||||||
|
|
||||||
|
3. **Admin Interface**
|
||||||
|
- Tag management dashboard
|
||||||
|
- Analytics views
|
||||||
|
- Bulk operations interface
|
||||||
|
|
||||||
|
### Phase 4: Features & Polish (Week 3-4)
|
||||||
|
1. **Advanced Features**
|
||||||
|
- Tag merging functionality
|
||||||
|
- Usage analytics
|
||||||
|
- Tag suggestions based on content
|
||||||
|
|
||||||
|
2. **Performance Optimization**
|
||||||
|
- Query optimization
|
||||||
|
- Caching strategies
|
||||||
|
- Load testing
|
||||||
|
|
||||||
|
3. **Cleanup**
|
||||||
|
- Remove JSON tags field
|
||||||
|
- Documentation updates
|
||||||
|
- Final testing
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Tag search responses under 50ms
|
||||||
|
- Post filtering responses under 100ms
|
||||||
|
- Page load times maintained or improved
|
||||||
|
|
||||||
|
### Usability
|
||||||
|
- Reduced clicks to add tags (eliminate Add button)
|
||||||
|
- Faster tag input with typeahead
|
||||||
|
- Improved content discovery through related posts
|
||||||
|
|
||||||
|
### Content Management
|
||||||
|
- Ability to merge duplicate tags
|
||||||
|
- Insights into tag usage patterns
|
||||||
|
- Better content organization capabilities
|
||||||
|
|
||||||
|
### Analytics
|
||||||
|
- Track tag usage growth over time
|
||||||
|
- Identify content gaps through tag analysis
|
||||||
|
- Measure impact on content engagement
|
||||||
|
|
||||||
|
## Technical Considerations
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- **Database Indexes**: Proper indexing on tag names and relationships
|
||||||
|
- **Query Optimization**: Efficient joins for tag filtering
|
||||||
|
- **Caching**: Cache popular tag lists and related posts
|
||||||
|
- **Pagination**: Handle large tag lists efficiently
|
||||||
|
|
||||||
|
### Data Integrity
|
||||||
|
- **Constraints**: Prevent duplicate tag names
|
||||||
|
- **Cascading Deletes**: Properly handle tag/post deletions
|
||||||
|
- **Validation**: Ensure tag names follow naming conventions
|
||||||
|
- **Backup Strategy**: Safe migration with rollback capability
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- **Progressive Enhancement**: Graceful degradation if JS fails
|
||||||
|
- **Loading States**: Smooth loading indicators
|
||||||
|
- **Error Handling**: Clear error messages for users
|
||||||
|
- **Responsive Design**: Works well on all device sizes
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Advanced Features (Post-MVP)
|
||||||
|
- **Hierarchical Tags**: Parent/child tag relationships
|
||||||
|
- **Tag Synonyms**: Alternative names for the same concept
|
||||||
|
- **Auto-tagging**: ML-based tag suggestions from content
|
||||||
|
- **Tag Templates**: Predefined tag sets for different content types
|
||||||
|
|
||||||
|
### Integrations
|
||||||
|
- **External APIs**: Import tags from external sources
|
||||||
|
- **Search Integration**: Enhanced search with tag faceting
|
||||||
|
- **Analytics**: Deep tag performance analytics
|
||||||
|
- **Content Recommendations**: AI-powered related content
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
### High Risk
|
||||||
|
- **Data Migration**: Complex migration of existing tag data
|
||||||
|
- **Performance Impact**: New queries might affect page load times
|
||||||
|
- **User Adoption**: Users need to learn new tag input interface
|
||||||
|
|
||||||
|
### Mitigation Strategies
|
||||||
|
- **Staged Rollout**: Deploy to staging first, then gradual production rollout
|
||||||
|
- **Performance Monitoring**: Continuous monitoring during migration
|
||||||
|
- **User Training**: Clear documentation and smooth UX transitions
|
||||||
|
- **Rollback Plan**: Ability to revert to JSON tags if needed
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
### Must Have
|
||||||
|
- ✅ All existing tags migrated successfully
|
||||||
|
- ✅ Tag input works with keyboard-only navigation
|
||||||
|
- ✅ Posts can be filtered by single or multiple tags
|
||||||
|
- ✅ Related posts show based on shared tags
|
||||||
|
- ✅ Performance remains acceptable (< 100ms for most operations)
|
||||||
|
|
||||||
|
### Should Have
|
||||||
|
- ✅ Tag management interface for admins
|
||||||
|
- ✅ Tag usage analytics and insights
|
||||||
|
- ✅ Ability to merge duplicate tags
|
||||||
|
- ✅ Tag color coding and visual improvements
|
||||||
|
|
||||||
|
### Could Have
|
||||||
|
- Tag auto-suggestions based on post content
|
||||||
|
- Tag trending and popularity metrics
|
||||||
|
- Advanced tag analytics and reporting
|
||||||
|
- Integration with external tag sources
|
||||||
|
|
||||||
|
## Timeline
|
||||||
|
|
||||||
|
**Total Duration**: 4 weeks
|
||||||
|
|
||||||
|
- **Week 1**: Database migration and API development
|
||||||
|
- **Week 2**: Core frontend components and basic integration
|
||||||
|
- **Week 3**: Advanced features and admin interface
|
||||||
|
- **Week 4**: Polish, testing, and production deployment
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
@ -169,24 +169,28 @@ async function main() {
|
||||||
|
|
||||||
console.log(`✅ Created ${labsProjects.length} labs projects`)
|
console.log(`✅ Created ${labsProjects.length} labs projects`)
|
||||||
|
|
||||||
// Create test posts
|
// Create test posts using new simplified types
|
||||||
const posts = await Promise.all([
|
const posts = await Promise.all([
|
||||||
prisma.post.create({
|
prisma.post.create({
|
||||||
data: {
|
data: {
|
||||||
slug: 'hello-world',
|
slug: 'hello-world',
|
||||||
postType: 'blog',
|
postType: 'essay',
|
||||||
title: 'Hello World',
|
title: 'Hello World',
|
||||||
content: {
|
content: {
|
||||||
blocks: [
|
blocks: [
|
||||||
{ type: 'paragraph', content: 'This is my first blog post on the new CMS!' },
|
{ type: 'paragraph', content: 'This is my first essay on the new CMS!' },
|
||||||
{
|
{
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
content: 'The system supports multiple post types and rich content editing.'
|
content: 'The system now uses a simplified post type system with just essays and posts.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Essays are perfect for longer-form content with titles and excerpts, while posts are great for quick thoughts and updates.'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
excerpt: 'Welcome to my new blog powered by a custom CMS.',
|
excerpt: 'Welcome to my new blog powered by a custom CMS with simplified post types.',
|
||||||
tags: ['announcement', 'meta'],
|
tags: ['announcement', 'meta', 'cms'],
|
||||||
status: 'published',
|
status: 'published',
|
||||||
publishedAt: new Date()
|
publishedAt: new Date()
|
||||||
}
|
}
|
||||||
|
|
@ -194,30 +198,79 @@ async function main() {
|
||||||
prisma.post.create({
|
prisma.post.create({
|
||||||
data: {
|
data: {
|
||||||
slug: 'quick-thought',
|
slug: 'quick-thought',
|
||||||
postType: 'microblog',
|
postType: 'post',
|
||||||
content: {
|
content: {
|
||||||
blocks: [
|
blocks: [
|
||||||
{
|
{
|
||||||
type: 'paragraph',
|
type: 'paragraph',
|
||||||
content: 'Just pushed a major update to the site. Feeling good about the progress!'
|
content: 'Just pushed a major update to the site. The new simplified post types are working great! 🎉'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
tags: ['update', 'development'],
|
||||||
status: 'published',
|
status: 'published',
|
||||||
publishedAt: new Date(Date.now() - 86400000) // Yesterday
|
publishedAt: new Date(Date.now() - 86400000) // Yesterday
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
prisma.post.create({
|
prisma.post.create({
|
||||||
data: {
|
data: {
|
||||||
slug: 'great-article',
|
slug: 'design-systems-thoughts',
|
||||||
postType: 'link',
|
postType: 'essay',
|
||||||
title: 'Great Article on Web Performance',
|
title: 'Thoughts on Design Systems',
|
||||||
linkUrl: 'https://web.dev/performance',
|
content: {
|
||||||
linkDescription:
|
blocks: [
|
||||||
'This article perfectly explains the core web vitals and how to optimize for them.',
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Design systems have become essential for maintaining consistency across large products.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'The key is finding the right balance between flexibility and constraints.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Too rigid, and designers feel boxed in. Too flexible, and you lose consistency.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
excerpt: 'Exploring the balance between flexibility and constraints in design systems.',
|
||||||
|
tags: ['design', 'systems', 'ux'],
|
||||||
status: 'published',
|
status: 'published',
|
||||||
publishedAt: new Date(Date.now() - 172800000) // 2 days ago
|
publishedAt: new Date(Date.now() - 172800000) // 2 days ago
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
prisma.post.create({
|
||||||
|
data: {
|
||||||
|
slug: 'morning-coffee',
|
||||||
|
postType: 'post',
|
||||||
|
content: {
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Perfect morning for coding with a fresh cup of coffee ☕'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
tags: ['life', 'coffee'],
|
||||||
|
status: 'published',
|
||||||
|
publishedAt: new Date(Date.now() - 259200000) // 3 days ago
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.post.create({
|
||||||
|
data: {
|
||||||
|
slug: 'weekend-project',
|
||||||
|
postType: 'post',
|
||||||
|
content: {
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: 'Built a small CLI tool over the weekend. Sometimes the best projects come from scratching your own itch.'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
tags: ['projects', 'cli', 'weekend'],
|
||||||
|
status: 'draft'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
|
||||||
3
src/assets/icons/metadata.svg
Normal file
3
src/assets/icons/metadata.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg fill="currentColor" viewBox="0 0 56 56" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M 36.4023 19.3164 C 38.8398 19.3164 40.9257 17.7461 41.6992 15.5898 L 49.8085 15.5898 C 50.7695 15.5898 51.6133 14.7461 51.6133 13.6914 C 51.6133 12.6367 50.7695 11.8164 49.8085 11.8164 L 41.7226 11.8164 C 40.9257 9.6367 38.8398 8.0430 36.4023 8.0430 C 33.9648 8.0430 31.8789 9.6367 31.1054 11.8164 L 6.2851 11.8164 C 5.2304 11.8164 4.3867 12.6367 4.3867 13.6914 C 4.3867 14.7461 5.2304 15.5898 6.2851 15.5898 L 31.1054 15.5898 C 31.8789 17.7461 33.9648 19.3164 36.4023 19.3164 Z M 6.1913 26.1133 C 5.2304 26.1133 4.3867 26.9570 4.3867 28.0117 C 4.3867 29.0664 5.2304 29.8867 6.1913 29.8867 L 14.5586 29.8867 C 15.3320 32.0898 17.4179 33.6601 19.8554 33.6601 C 22.3164 33.6601 24.4023 32.0898 25.1757 29.8867 L 49.7149 29.8867 C 50.7695 29.8867 51.6133 29.0664 51.6133 28.0117 C 51.6133 26.9570 50.7695 26.1133 49.7149 26.1133 L 25.1757 26.1133 C 24.3789 23.9570 22.2929 22.3867 19.8554 22.3867 C 17.4413 22.3867 15.3554 23.9570 14.5586 26.1133 Z M 36.4023 47.9570 C 38.8398 47.9570 40.9257 46.3867 41.6992 44.2070 L 49.8085 44.2070 C 50.7695 44.2070 51.6133 43.3867 51.6133 42.3320 C 51.6133 41.2773 50.7695 40.4336 49.8085 40.4336 L 41.6992 40.4336 C 40.9257 38.2539 38.8398 36.7070 36.4023 36.7070 C 33.9648 36.7070 31.8789 38.2539 31.1054 40.4336 L 6.2851 40.4336 C 5.2304 40.4336 4.3867 41.2773 4.3867 42.3320 C 4.3867 43.3867 5.2304 44.2070 6.2851 44.2070 L 31.1054 44.2070 C 31.8789 46.3867 33.9648 47.9570 36.4023 47.9570 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -12,11 +12,24 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heading font weights
|
// Heading font weights
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Button and input font inheritance
|
// Button and input font inheritance
|
||||||
button, input, textarea, select {
|
button,
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ $yellow-30: #cc9900;
|
||||||
$yellow-20: #996600;
|
$yellow-20: #996600;
|
||||||
$yellow-10: #664400;
|
$yellow-10: #664400;
|
||||||
|
|
||||||
$salmon-pink: #ffbdb3; // Desaturated salmon pink for hover states
|
$salmon-pink: #ffd5cf; // Desaturated salmon pink for hover states
|
||||||
|
|
||||||
$bg-color: #e8e8e8;
|
$bg-color: #e8e8e8;
|
||||||
$page-color: #ffffff;
|
$page-color: #ffffff;
|
||||||
|
|
|
||||||
|
|
@ -191,13 +191,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $red-60;
|
background-color: $grey-70;
|
||||||
background-color: $salmon-pink;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
color: white;
|
color: $red-60;
|
||||||
background-color: $red-60;
|
background-color: $salmon-pink;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
|
|
|
||||||
303
src/lib/components/admin/AlbumListItem.svelte
Normal file
303
src/lib/components/admin/AlbumListItem.svelte
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
import AdminByline from './AdminByline.svelte'
|
||||||
|
|
||||||
|
interface Photo {
|
||||||
|
id: number
|
||||||
|
url: string
|
||||||
|
thumbnailUrl: string | null
|
||||||
|
caption: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Album {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
date: string | null
|
||||||
|
location: string | null
|
||||||
|
coverPhotoId: number | null
|
||||||
|
isPhotography: boolean
|
||||||
|
status: string
|
||||||
|
showInUniverse: boolean
|
||||||
|
publishedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
photos: Photo[]
|
||||||
|
_count: {
|
||||||
|
photos: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
album: Album
|
||||||
|
isDropdownActive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let { album, isDropdownActive = false }: Props = $props()
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{
|
||||||
|
toggleDropdown: { albumId: number; event: MouseEvent }
|
||||||
|
edit: { album: Album; event: MouseEvent }
|
||||||
|
togglePublish: { album: Album; event: MouseEvent }
|
||||||
|
delete: { album: Album; event: MouseEvent }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function formatRelativeTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||||
|
|
||||||
|
if (diffInSeconds < 60) return 'just now'
|
||||||
|
|
||||||
|
const minutes = Math.floor(diffInSeconds / 60)
|
||||||
|
if (diffInSeconds < 3600) return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'} ago`
|
||||||
|
|
||||||
|
const hours = Math.floor(diffInSeconds / 3600)
|
||||||
|
if (diffInSeconds < 86400) return `${hours} ${hours === 1 ? 'hour' : 'hours'} ago`
|
||||||
|
|
||||||
|
const days = Math.floor(diffInSeconds / 86400)
|
||||||
|
if (diffInSeconds < 2592000) return `${days} ${days === 1 ? 'day' : 'days'} ago`
|
||||||
|
|
||||||
|
const months = Math.floor(diffInSeconds / 2592000)
|
||||||
|
if (diffInSeconds < 31536000) return `${months} ${months === 1 ? 'month' : 'months'} ago`
|
||||||
|
|
||||||
|
const years = Math.floor(diffInSeconds / 31536000)
|
||||||
|
return `${years} ${years === 1 ? 'year' : 'years'} ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAlbumClick() {
|
||||||
|
goto(`/admin/albums/${album.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleDropdown(event: MouseEvent) {
|
||||||
|
dispatch('toggleDropdown', { albumId: album.id, event })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(event: MouseEvent) {
|
||||||
|
dispatch('edit', { album, event })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTogglePublish(event: MouseEvent) {
|
||||||
|
dispatch('togglePublish', { album, event })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(event: MouseEvent) {
|
||||||
|
dispatch('delete', { album, event })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get thumbnail - try cover photo first, then first photo
|
||||||
|
function getThumbnailUrl(): string | null {
|
||||||
|
if (album.coverPhotoId && album.photos.length > 0) {
|
||||||
|
const coverPhoto = album.photos.find(p => p.id === album.coverPhotoId)
|
||||||
|
if (coverPhoto) {
|
||||||
|
return coverPhoto.thumbnailUrl || coverPhoto.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to first photo
|
||||||
|
if (album.photos.length > 0) {
|
||||||
|
return album.photos[0].thumbnailUrl || album.photos[0].url
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPhotoCount(): number {
|
||||||
|
return album._count?.photos || 0
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="album-item"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onclick={handleAlbumClick}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && handleAlbumClick()}
|
||||||
|
>
|
||||||
|
<div class="album-thumbnail">
|
||||||
|
{#if getThumbnailUrl()}
|
||||||
|
<img src={getThumbnailUrl()} alt="{album.title} thumbnail" class="thumbnail-image" />
|
||||||
|
{:else}
|
||||||
|
<div class="thumbnail-placeholder">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M19 3H5C3.89 3 3 3.9 3 5V19C3 20.1 3.89 21 5 21H19C20.11 21 21 20.1 21 19V5C21 3.9 20.11 3 19 3ZM19 19H5V5H19V19ZM13.96 12.29L11.21 15.83L9.25 13.47L6.5 17H17.5L13.96 12.29Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="album-info">
|
||||||
|
<h3 class="album-title">{album.title}</h3>
|
||||||
|
<AdminByline
|
||||||
|
sections={[
|
||||||
|
album.isPhotography ? 'Photography' : 'Album',
|
||||||
|
album.status === 'published' ? 'Published' : 'Draft',
|
||||||
|
`${getPhotoCount()} ${getPhotoCount() === 1 ? 'photo' : 'photos'}`,
|
||||||
|
album.status === 'published' && album.publishedAt
|
||||||
|
? `Published ${formatRelativeTime(album.publishedAt)}`
|
||||||
|
: `Created ${formatRelativeTime(album.createdAt)}`
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown-container">
|
||||||
|
<button class="action-button" onclick={handleToggleDropdown} aria-label="Album actions">
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="4" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="10" cy="10" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="10" cy="16" r="1.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isDropdownActive}
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<button class="dropdown-item" onclick={handleEdit}> Edit album </button>
|
||||||
|
<button class="dropdown-item" onclick={handleTogglePublish}>
|
||||||
|
{album.status === 'published' ? 'Unpublish' : 'Publish'} album
|
||||||
|
</button>
|
||||||
|
<div class="dropdown-divider"></div>
|
||||||
|
<button class="dropdown-item delete" onclick={handleDelete}> Delete album </button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.album-item {
|
||||||
|
display: flex;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
padding: $unit-2x;
|
||||||
|
background: white;
|
||||||
|
border-radius: $unit-2x;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $grey-95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-thumbnail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: $unit;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: $grey-90;
|
||||||
|
|
||||||
|
.thumbnail-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbnail-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: $grey-50;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-half;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $grey-10;
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-container {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: $unit;
|
||||||
|
cursor: pointer;
|
||||||
|
color: $grey-30;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: $unit-half;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid $grey-85;
|
||||||
|
border-radius: $unit;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 180px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: $unit-2x $unit-3x;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: $grey-20;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $grey-95;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.delete {
|
||||||
|
color: $red-60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background-color: $grey-90;
|
||||||
|
margin: $unit-half 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -348,6 +348,7 @@
|
||||||
.input-large {
|
.input-large {
|
||||||
padding: $unit-2x $unit-3x;
|
padding: $unit-2x $unit-3x;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shape variants - pill vs rounded
|
// Shape variants - pill vs rounded
|
||||||
|
|
|
||||||
315
src/lib/components/admin/MetadataPopover.svelte
Normal file
315
src/lib/components/admin/MetadataPopover.svelte
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import Input from './Input.svelte'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
post: any
|
||||||
|
postType: 'post' | 'essay'
|
||||||
|
slug: string
|
||||||
|
excerpt: string
|
||||||
|
tags: string[]
|
||||||
|
tagInput: string
|
||||||
|
triggerElement: HTMLElement
|
||||||
|
onAddTag: () => void
|
||||||
|
onRemoveTag: (tag: string) => void
|
||||||
|
onDelete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
post,
|
||||||
|
postType,
|
||||||
|
slug = $bindable(),
|
||||||
|
excerpt = $bindable(),
|
||||||
|
tags = $bindable(),
|
||||||
|
tagInput = $bindable(),
|
||||||
|
triggerElement,
|
||||||
|
onAddTag,
|
||||||
|
onRemoveTag,
|
||||||
|
onDelete
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
let popoverElement: HTMLDivElement
|
||||||
|
let portalTarget: HTMLElement
|
||||||
|
|
||||||
|
function updatePosition() {
|
||||||
|
if (!popoverElement || !triggerElement) return
|
||||||
|
|
||||||
|
const triggerRect = triggerElement.getBoundingClientRect()
|
||||||
|
const popoverRect = popoverElement.getBoundingClientRect()
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
|
||||||
|
// Find the AdminPage container to align with its right edge
|
||||||
|
const adminPage =
|
||||||
|
document.querySelector('.admin-page') || document.querySelector('[data-admin-page]')
|
||||||
|
const adminPageRect = adminPage?.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Position below the trigger button
|
||||||
|
let top = triggerRect.bottom + 8
|
||||||
|
|
||||||
|
// Align closer to the right edge of AdminPage, with some padding
|
||||||
|
let left: number
|
||||||
|
if (adminPageRect) {
|
||||||
|
// Position to align with AdminPage right edge minus padding
|
||||||
|
left = adminPageRect.right - popoverRect.width - 24
|
||||||
|
} else {
|
||||||
|
// Fallback to viewport-based positioning
|
||||||
|
left = triggerRect.right - popoverRect.width
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we don't go off-screen horizontally
|
||||||
|
if (left < 16) {
|
||||||
|
left = 16
|
||||||
|
} else if (left + popoverRect.width > viewportWidth - 16) {
|
||||||
|
left = viewportWidth - popoverRect.width - 16
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust if would go off-screen vertically
|
||||||
|
if (top + popoverRect.height > viewportHeight - 16) {
|
||||||
|
top = triggerRect.top - popoverRect.height - 8
|
||||||
|
}
|
||||||
|
|
||||||
|
popoverElement.style.position = 'fixed'
|
||||||
|
popoverElement.style.top = `${top}px`
|
||||||
|
popoverElement.style.left = `${left}px`
|
||||||
|
popoverElement.style.zIndex = '1000'
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Create portal target
|
||||||
|
portalTarget = document.createElement('div')
|
||||||
|
portalTarget.style.position = 'absolute'
|
||||||
|
portalTarget.style.top = '0'
|
||||||
|
portalTarget.style.left = '0'
|
||||||
|
portalTarget.style.pointerEvents = 'none'
|
||||||
|
document.body.appendChild(portalTarget)
|
||||||
|
|
||||||
|
// Initial positioning
|
||||||
|
updatePosition()
|
||||||
|
|
||||||
|
// Update position on scroll/resize
|
||||||
|
const handleUpdate = () => updatePosition()
|
||||||
|
window.addEventListener('scroll', handleUpdate, true)
|
||||||
|
window.addEventListener('resize', handleUpdate)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleUpdate, true)
|
||||||
|
window.removeEventListener('resize', handleUpdate)
|
||||||
|
if (portalTarget) {
|
||||||
|
document.body.removeChild(portalTarget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (popoverElement && portalTarget && triggerElement) {
|
||||||
|
portalTarget.appendChild(popoverElement)
|
||||||
|
portalTarget.style.pointerEvents = 'auto'
|
||||||
|
updatePosition()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="metadata-popover" bind:this={popoverElement}>
|
||||||
|
<div class="popover-content">
|
||||||
|
<h3>Post Settings</h3>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="Slug"
|
||||||
|
bind:value={slug}
|
||||||
|
placeholder="post-slug"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if postType === 'essay'}
|
||||||
|
<Input
|
||||||
|
type="textarea"
|
||||||
|
label="Excerpt"
|
||||||
|
bind:value={excerpt}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Brief description..."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="tags-section">
|
||||||
|
<Input
|
||||||
|
label="Tags"
|
||||||
|
bind:value={tagInput}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), onAddTag())}
|
||||||
|
placeholder="Add tags..."
|
||||||
|
/>
|
||||||
|
<button type="button" onclick={onAddTag} class="add-tag-btn">Add</button>
|
||||||
|
|
||||||
|
{#if tags.length > 0}
|
||||||
|
<div class="tags">
|
||||||
|
{#each tags as tag}
|
||||||
|
<span class="tag">
|
||||||
|
{tag}
|
||||||
|
<button onclick={() => onRemoveTag(tag)}>×</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata">
|
||||||
|
<p>Created: {new Date(post.createdAt).toLocaleString()}</p>
|
||||||
|
<p>Updated: {new Date(post.updatedAt).toLocaleString()}</p>
|
||||||
|
{#if post.publishedAt}
|
||||||
|
<p>Published: {new Date(post.publishedAt).toLocaleString()}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="popover-footer">
|
||||||
|
<button class="btn btn-danger" onclick={onDelete}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path
|
||||||
|
d="M4 4L12 12M4 12L12 4"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Delete Post
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@import '$styles/variables.scss';
|
||||||
|
|
||||||
|
.metadata-popover {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid $grey-80;
|
||||||
|
border-radius: $card-corner-radius;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
min-width: 420px;
|
||||||
|
max-width: 480px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-content {
|
||||||
|
padding: $unit-3x;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit-3x;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-footer {
|
||||||
|
padding: $unit-3x;
|
||||||
|
border-top: 1px solid $grey-90;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-tag-btn {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: $unit-half;
|
||||||
|
padding: $unit $unit-2x;
|
||||||
|
background: $grey-10;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $grey-20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: $unit;
|
||||||
|
margin-top: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px $unit-2x;
|
||||||
|
background: $grey-80;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: $grey-40;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: $grey-40;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: $unit-half 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: $unit-2x $unit-3x;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 0.925rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-small {
|
||||||
|
padding: $unit $unit-2x;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-danger {
|
||||||
|
background-color: #dc2626;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: #b91c1c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include breakpoint('phone') {
|
||||||
|
.metadata-popover {
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -223,7 +223,17 @@
|
||||||
<AdminPage>
|
<AdminPage>
|
||||||
<header slot="header">
|
<header slot="header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<!-- Empty spacer for balance -->
|
<button class="btn-icon" onclick={() => goto('/admin/projects')}>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path
|
||||||
|
d="M12.5 15L7.5 10L12.5 5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-center">
|
<div class="header-center">
|
||||||
<AdminSegmentedControl
|
<AdminSegmentedControl
|
||||||
|
|
@ -356,6 +366,9 @@
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $unit-2x;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-center {
|
.header-center {
|
||||||
|
|
@ -371,6 +384,25 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: $grey-40;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: $grey-90;
|
||||||
|
color: $grey-10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.admin-container {
|
.admin-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ export function isValidStatus(status: any): status is Status {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post type validation
|
// Post type validation
|
||||||
export const VALID_POST_TYPES = ['blog', 'microblog', 'link', 'photo', 'album'] as const
|
export const VALID_POST_TYPES = ['post', 'essay'] as const
|
||||||
export type PostType = (typeof VALID_POST_TYPES)[number]
|
export type PostType = (typeof VALID_POST_TYPES)[number]
|
||||||
|
|
||||||
export function isValidPostType(type: any): type is PostType {
|
export function isValidPostType(type: any): type is PostType {
|
||||||
|
|
|
||||||
|
|
@ -1,62 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
|
||||||
import Button from '$lib/components/admin/Button.svelte'
|
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
function goToMediaLibraryTest() {
|
onMount(() => {
|
||||||
goto('/admin/media-library-test')
|
goto('/admin/projects', { replaceState: true })
|
||||||
}
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdminPage title="Dashboard" subtitle="Admin overview and quick actions">
|
|
||||||
<div class="dashboard-content">
|
|
||||||
<section class="quick-actions">
|
|
||||||
<h2>Quick Actions</h2>
|
|
||||||
<div class="actions-grid">
|
|
||||||
<Button variant="primary" onclick={() => goto('/admin/projects/new')}>
|
|
||||||
New Project
|
|
||||||
</Button>
|
|
||||||
<Button variant="primary" onclick={() => goto('/admin/posts/new')}>
|
|
||||||
New Post
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" onclick={() => goto('/admin/media')}>
|
|
||||||
Browse Media
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" onclick={goToMediaLibraryTest}>
|
|
||||||
Test Media Library
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" onclick={() => goto('/admin/form-components-test')}>
|
|
||||||
Test Form Components
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</AdminPage>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.dashboard-content {
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: $unit-4x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quick-actions {
|
|
||||||
background: white;
|
|
||||||
padding: $unit-4x;
|
|
||||||
border-radius: $card-corner-radius;
|
|
||||||
border: 1px solid $grey-90;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0 0 $unit-3x 0;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $grey-10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
||||||
gap: $unit-2x;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -4,17 +4,48 @@
|
||||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||||
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'
|
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'
|
||||||
import AdminFilters from '$lib/components/admin/AdminFilters.svelte'
|
import AdminFilters from '$lib/components/admin/AdminFilters.svelte'
|
||||||
import DataTable from '$lib/components/admin/DataTable.svelte'
|
import AlbumListItem from '$lib/components/admin/AlbumListItem.svelte'
|
||||||
|
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||||
import Button from '$lib/components/admin/Button.svelte'
|
import Button from '$lib/components/admin/Button.svelte'
|
||||||
import Select from '$lib/components/admin/Select.svelte'
|
import Select from '$lib/components/admin/Select.svelte'
|
||||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
|
||||||
|
interface Photo {
|
||||||
|
id: number
|
||||||
|
url: string
|
||||||
|
thumbnailUrl: string | null
|
||||||
|
caption: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Album {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
description: string | null
|
||||||
|
date: string | null
|
||||||
|
location: string | null
|
||||||
|
coverPhotoId: number | null
|
||||||
|
isPhotography: boolean
|
||||||
|
status: string
|
||||||
|
showInUniverse: boolean
|
||||||
|
publishedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
photos: Photo[]
|
||||||
|
_count: {
|
||||||
|
photos: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let albums = $state<any[]>([])
|
let albums = $state<Album[]>([])
|
||||||
|
let filteredAlbums = $state<Album[]>([])
|
||||||
let isLoading = $state(true)
|
let isLoading = $state(true)
|
||||||
let error = $state('')
|
let error = $state('')
|
||||||
let total = $state(0)
|
let total = $state(0)
|
||||||
let albumTypeCounts = $state<Record<string, number>>({})
|
let albumTypeCounts = $state<Record<string, number>>({})
|
||||||
|
let showDeleteModal = $state(false)
|
||||||
|
let albumToDelete = $state<Album | null>(null)
|
||||||
|
let activeDropdown = $state<number | null>(null)
|
||||||
|
|
||||||
// Filter state
|
// Filter state
|
||||||
let photographyFilter = $state<string>('all')
|
let photographyFilter = $state<string>('all')
|
||||||
|
|
@ -26,72 +57,29 @@
|
||||||
{ value: 'false', label: 'Regular albums' }
|
{ value: 'false', label: 'Regular albums' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{
|
|
||||||
key: 'title',
|
|
||||||
label: 'Title',
|
|
||||||
width: '40%',
|
|
||||||
render: (album: any) => {
|
|
||||||
return album.title || '(Untitled Album)'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'type',
|
|
||||||
label: 'Type',
|
|
||||||
width: '20%',
|
|
||||||
render: (album: any) => {
|
|
||||||
const baseType = '🖼️ Album'
|
|
||||||
if (album.isPhotography) {
|
|
||||||
return `${baseType} 📸`
|
|
||||||
}
|
|
||||||
return baseType
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'photoCount',
|
|
||||||
label: 'Photos',
|
|
||||||
width: '15%',
|
|
||||||
render: (album: any) => {
|
|
||||||
return album._count?.photos || 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status',
|
|
||||||
label: 'Status',
|
|
||||||
width: '15%',
|
|
||||||
render: (album: any) => {
|
|
||||||
return album.status === 'published' ? '🟢 Published' : '⚪ Draft'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'updatedAt',
|
|
||||||
label: 'Updated',
|
|
||||||
width: '10%',
|
|
||||||
render: (album: any) => {
|
|
||||||
return new Date(album.updatedAt).toLocaleDateString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadAlbums()
|
await loadAlbums()
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', handleOutsideClick)
|
||||||
|
return () => document.removeEventListener('click', handleOutsideClick)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function handleOutsideClick(event: MouseEvent) {
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
if (!target.closest('.dropdown-container')) {
|
||||||
|
activeDropdown = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAlbums() {
|
async function loadAlbums() {
|
||||||
try {
|
try {
|
||||||
isLoading = true
|
|
||||||
const auth = localStorage.getItem('admin_auth')
|
const auth = localStorage.getItem('admin_auth')
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
goto('/admin/login')
|
goto('/admin/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let url = '/api/albums'
|
const response = await fetch('/api/albums', {
|
||||||
if (photographyFilter !== 'all') {
|
|
||||||
url += `?isPhotography=${photographyFilter}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
headers: { Authorization: `Basic ${auth}` }
|
headers: { Authorization: `Basic ${auth}` }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -114,6 +102,9 @@
|
||||||
regular: albums.filter((a) => !a.isPhotography).length
|
regular: albums.filter((a) => !a.isPhotography).length
|
||||||
}
|
}
|
||||||
albumTypeCounts = counts
|
albumTypeCounts = counts
|
||||||
|
|
||||||
|
// Apply initial filter
|
||||||
|
applyFilter()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = 'Failed to load albums'
|
error = 'Failed to load albums'
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|
@ -122,12 +113,89 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRowClick(album: any) {
|
function applyFilter() {
|
||||||
goto(`/admin/albums/${album.id}/edit`)
|
if (photographyFilter === 'all') {
|
||||||
|
filteredAlbums = albums
|
||||||
|
} else if (photographyFilter === 'true') {
|
||||||
|
filteredAlbums = albums.filter((album) => album.isPhotography === true)
|
||||||
|
} else if (photographyFilter === 'false') {
|
||||||
|
filteredAlbums = albums.filter((album) => album.isPhotography === false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggleDropdown(event: CustomEvent<{ albumId: number; event: MouseEvent }>) {
|
||||||
|
event.detail.event.stopPropagation()
|
||||||
|
activeDropdown = activeDropdown === event.detail.albumId ? null : event.detail.albumId
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(event: CustomEvent<{ album: Album; event: MouseEvent }>) {
|
||||||
|
event.detail.event.stopPropagation()
|
||||||
|
goto(`/admin/albums/${event.detail.album.id}/edit`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTogglePublish(event: CustomEvent<{ album: Album; event: MouseEvent }>) {
|
||||||
|
event.detail.event.stopPropagation()
|
||||||
|
activeDropdown = null
|
||||||
|
|
||||||
|
const album = event.detail.album
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = localStorage.getItem('admin_auth')
|
||||||
|
const newStatus = album.status === 'published' ? 'draft' : 'published'
|
||||||
|
|
||||||
|
const response = await fetch(`/api/albums/${album.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Basic ${auth}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ status: newStatus })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await loadAlbums()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update album status:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(event: CustomEvent<{ album: Album; event: MouseEvent }>) {
|
||||||
|
event.detail.event.stopPropagation()
|
||||||
|
activeDropdown = null
|
||||||
|
albumToDelete = event.detail.album
|
||||||
|
showDeleteModal = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!albumToDelete) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = localStorage.getItem('admin_auth')
|
||||||
|
|
||||||
|
const response = await fetch(`/api/albums/${albumToDelete.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Basic ${auth}` }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await loadAlbums()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete album:', err)
|
||||||
|
} finally {
|
||||||
|
showDeleteModal = false
|
||||||
|
albumToDelete = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDelete() {
|
||||||
|
showDeleteModal = false
|
||||||
|
albumToDelete = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFilterChange() {
|
function handleFilterChange() {
|
||||||
loadAlbums()
|
applyFilter()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleNewAlbum() {
|
function handleNewAlbum() {
|
||||||
|
|
@ -158,41 +226,94 @@
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</AdminFilters>
|
</AdminFilters>
|
||||||
|
|
||||||
<!-- Albums Table -->
|
<!-- Albums List -->
|
||||||
{#if isLoading}
|
{#if isLoading}
|
||||||
<div class="loading-container">
|
<div class="loading">
|
||||||
<LoadingSpinner />
|
<div class="spinner"></div>
|
||||||
|
<p>Loading albums...</p>
|
||||||
|
</div>
|
||||||
|
{:else if filteredAlbums.length === 0}
|
||||||
|
<div class="empty-state">
|
||||||
|
<p>
|
||||||
|
{#if photographyFilter === 'all'}
|
||||||
|
No albums found. Create your first album!
|
||||||
|
{:else}
|
||||||
|
No albums found matching the current filters. Try adjusting your filters or create a new album.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<DataTable
|
<div class="albums-list">
|
||||||
data={albums}
|
{#each filteredAlbums as album}
|
||||||
{columns}
|
<AlbumListItem
|
||||||
loading={isLoading}
|
{album}
|
||||||
emptyMessage="No albums found. Create your first album!"
|
isDropdownActive={activeDropdown === album.id}
|
||||||
onRowClick={handleRowClick}
|
ontoggleDropdown={handleToggleDropdown}
|
||||||
/>
|
onedit={handleEdit}
|
||||||
|
ontogglePublish={handleTogglePublish}
|
||||||
|
ondelete={handleDelete}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</AdminPage>
|
</AdminPage>
|
||||||
|
|
||||||
|
<DeleteConfirmationModal
|
||||||
|
bind:isOpen={showDeleteModal}
|
||||||
|
title="Delete album?"
|
||||||
|
message={albumToDelete
|
||||||
|
? `Are you sure you want to delete "${albumToDelete.title}"? This action cannot be undone.`
|
||||||
|
: ''}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={cancelDelete}
|
||||||
|
/>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import '$styles/variables.scss';
|
|
||||||
|
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
text-align: center;
|
||||||
color: #dc2626;
|
padding: $unit-6x;
|
||||||
padding: $unit-3x;
|
color: #d33;
|
||||||
border-radius: $unit-2x;
|
|
||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
||||||
margin-bottom: $unit-4x;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
padding: $unit-8x;
|
||||||
|
text-align: center;
|
||||||
|
color: $grey-40;
|
||||||
|
|
||||||
.loading-container {
|
.spinner {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid $grey-80;
|
||||||
|
border-top-color: $primary-color;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: 0 auto $unit-2x;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: $unit-8x;
|
||||||
|
text-align: center;
|
||||||
|
color: $grey-40;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.albums-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,72 +3,86 @@
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||||
import FormField from '$lib/components/admin/FormField.svelte'
|
|
||||||
import FormFieldWrapper from '$lib/components/admin/FormFieldWrapper.svelte'
|
|
||||||
import Editor from '$lib/components/admin/Editor.svelte'
|
import Editor from '$lib/components/admin/Editor.svelte'
|
||||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||||
import PhotoPostForm from '$lib/components/admin/PhotoPostForm.svelte'
|
import MetadataPopover from '$lib/components/admin/MetadataPopover.svelte'
|
||||||
import AlbumForm from '$lib/components/admin/AlbumForm.svelte'
|
|
||||||
import type { JSONContent } from '@tiptap/core'
|
import type { JSONContent } from '@tiptap/core'
|
||||||
|
|
||||||
let post: any = null
|
let post = $state<any>(null)
|
||||||
let loading = true
|
let loading = $state(true)
|
||||||
let saving = false
|
let saving = $state(false)
|
||||||
|
let loadError = $state('')
|
||||||
|
|
||||||
let title = ''
|
let title = $state('')
|
||||||
let postType: 'blog' | 'microblog' | 'link' | 'photo' | 'album' = 'blog'
|
let postType = $state<'post' | 'essay'>('post')
|
||||||
let status: 'draft' | 'published' = 'draft'
|
let status = $state<'draft' | 'published'>('draft')
|
||||||
let slug = ''
|
let slug = $state('')
|
||||||
let excerpt = ''
|
let excerpt = $state('')
|
||||||
let linkUrl = ''
|
let content = $state<JSONContent>({ type: 'doc', content: [] })
|
||||||
let linkDescription = ''
|
let tags = $state<string[]>([])
|
||||||
let content: JSONContent = { type: 'doc', content: [] }
|
let tagInput = $state('')
|
||||||
let tags: string[] = []
|
let showMetadata = $state(false)
|
||||||
let tagInput = ''
|
let isPublishDropdownOpen = $state(false)
|
||||||
let showMetadata = false
|
|
||||||
let isPublishDropdownOpen = false
|
|
||||||
let publishButtonRef: HTMLButtonElement
|
let publishButtonRef: HTMLButtonElement
|
||||||
|
let metadataButtonRef: HTMLButtonElement
|
||||||
|
|
||||||
const postTypeConfig = {
|
const postTypeConfig = {
|
||||||
blog: { icon: '📝', label: 'Blog Post', showTitle: true, showContent: true },
|
post: { icon: '💭', label: 'Post', showTitle: false, showContent: true },
|
||||||
microblog: { icon: '💭', label: 'Microblog', showTitle: false, showContent: true },
|
essay: { icon: '📝', label: 'Essay', showTitle: true, showContent: true }
|
||||||
link: { icon: '🔗', label: 'Link', showTitle: true, showContent: false },
|
|
||||||
photo: { icon: '📷', label: 'Photo', showTitle: true, showContent: false },
|
|
||||||
album: { icon: '🖼️', label: 'Album', showTitle: true, showContent: false }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = $derived(postTypeConfig[postType])
|
let config = $derived(postTypeConfig[postType])
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
|
// Wait a tick to ensure page params are loaded
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
await loadPost()
|
await loadPost()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadPost() {
|
async function loadPost() {
|
||||||
|
const postId = $page.params.id
|
||||||
|
|
||||||
|
if (!postId) {
|
||||||
|
loadError = 'No post ID provided'
|
||||||
|
loading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const auth = localStorage.getItem('admin_auth')
|
const auth = localStorage.getItem('admin_auth')
|
||||||
|
|
||||||
if (!auth) {
|
if (!auth) {
|
||||||
goto('/admin/login')
|
goto('/admin/login')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/posts/${$page.params.id}`, {
|
const response = await fetch(`/api/posts/${postId}`, {
|
||||||
headers: { Authorization: `Basic ${auth}` }
|
headers: { Authorization: `Basic ${auth}` }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
post = await response.json()
|
post = await response.json()
|
||||||
|
|
||||||
// Populate form fields
|
// Populate form fields
|
||||||
title = post.title || ''
|
title = post.title || ''
|
||||||
postType = post.type || post.postType
|
postType = post.postType || 'post'
|
||||||
status = post.status
|
status = post.status || 'draft'
|
||||||
slug = post.slug || ''
|
slug = post.slug || ''
|
||||||
excerpt = post.excerpt || ''
|
excerpt = post.excerpt || ''
|
||||||
linkUrl = post.link_url || post.linkUrl || ''
|
|
||||||
linkDescription = post.link_description || post.linkDescription || ''
|
|
||||||
content = post.content || { type: 'doc', content: [] }
|
content = post.content || { type: 'doc', content: [] }
|
||||||
tags = post.tags || []
|
tags = post.tags || []
|
||||||
|
} else {
|
||||||
|
if (response.status === 404) {
|
||||||
|
loadError = 'Post not found'
|
||||||
|
} else if (response.status === 401) {
|
||||||
|
goto('/admin/login')
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
loadError = `Failed to load post: ${response.status} ${response.statusText}`
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load post:', error)
|
loadError = 'Network error occurred while loading post'
|
||||||
} finally {
|
} finally {
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
|
|
@ -94,14 +108,14 @@
|
||||||
|
|
||||||
saving = true
|
saving = true
|
||||||
const postData = {
|
const postData = {
|
||||||
title: config.showTitle ? title : null,
|
title: config?.showTitle ? title : null,
|
||||||
slug,
|
slug,
|
||||||
type: postType,
|
type: postType,
|
||||||
status: publishStatus || status,
|
status: publishStatus || status,
|
||||||
content: config.showContent ? content : null,
|
content: config?.showContent ? content : null,
|
||||||
excerpt: postType === 'blog' ? excerpt : undefined,
|
excerpt: postType === 'essay' ? excerpt : undefined,
|
||||||
link_url: postType === 'link' ? linkUrl : undefined,
|
link_url: undefined,
|
||||||
link_description: postType === 'link' ? linkDescription : undefined,
|
linkDescription: undefined,
|
||||||
tags
|
tags
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,12 +171,31 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleMetadataPopover(event: MouseEvent) {
|
||||||
|
const target = event.target as Node
|
||||||
|
// Don't close if clicking inside the metadata button or anywhere in a metadata popover
|
||||||
|
if (
|
||||||
|
metadataButtonRef?.contains(target) ||
|
||||||
|
document.querySelector('.metadata-popover')?.contains(target)
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showMetadata = false
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isPublishDropdownOpen) {
|
if (isPublishDropdownOpen) {
|
||||||
document.addEventListener('click', handlePublishDropdown)
|
document.addEventListener('click', handlePublishDropdown)
|
||||||
return () => document.removeEventListener('click', handlePublishDropdown)
|
return () => document.removeEventListener('click', handlePublishDropdown)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (showMetadata) {
|
||||||
|
document.addEventListener('click', handleMetadataPopover)
|
||||||
|
return () => document.removeEventListener('click', handleMetadataPopover)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<AdminPage>
|
<AdminPage>
|
||||||
|
|
@ -180,33 +213,41 @@
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h1>{config.icon} Edit {config.label}</h1>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="btn btn-text" onclick={handleDelete}>
|
<div class="metadata-popover-container">
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
<button
|
||||||
<path
|
class="btn btn-text"
|
||||||
d="M4 4L12 12M4 12L12 4"
|
onclick={(e) => {
|
||||||
stroke="currentColor"
|
e.stopPropagation()
|
||||||
stroke-width="1.5"
|
showMetadata = !showMetadata
|
||||||
stroke-linecap="round"
|
}}
|
||||||
stroke-linejoin="round"
|
bind:this={metadataButtonRef}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 56 56" fill="none">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M 36.4023 19.3164 C 38.8398 19.3164 40.9257 17.7461 41.6992 15.5898 L 49.8085 15.5898 C 50.7695 15.5898 51.6133 14.7461 51.6133 13.6914 C 51.6133 12.6367 50.7695 11.8164 49.8085 11.8164 L 41.7226 11.8164 C 40.9257 9.6367 38.8398 8.0430 36.4023 8.0430 C 33.9648 8.0430 31.8789 9.6367 31.1054 11.8164 L 6.2851 11.8164 C 5.2304 11.8164 4.3867 12.6367 4.3867 13.6914 C 4.3867 14.7461 5.2304 15.5898 6.2851 15.5898 L 31.1054 15.5898 C 31.8789 17.7461 33.9648 19.3164 36.4023 19.3164 Z M 6.1913 26.1133 C 5.2304 26.1133 4.3867 26.9570 4.3867 28.0117 C 4.3867 29.0664 5.2304 29.8867 6.1913 29.8867 L 14.5586 29.8867 C 15.3320 32.0898 17.4179 33.6601 19.8554 33.6601 C 22.3164 33.6601 24.4023 32.0898 25.1757 29.8867 L 49.7149 29.8867 C 50.7695 29.8867 51.6133 29.0664 51.6133 28.0117 C 51.6133 26.9570 50.7695 26.1133 49.7149 26.1133 L 25.1757 26.1133 C 24.3789 23.9570 22.2929 22.3867 19.8554 22.3867 C 17.4413 22.3867 15.3554 23.9570 14.5586 26.1133 Z M 36.4023 47.9570 C 38.8398 47.9570 40.9257 46.3867 41.6992 44.2070 L 49.8085 44.2070 C 50.7695 44.2070 51.6133 43.3867 51.6133 42.3320 C 51.6133 41.2773 50.7695 40.4336 49.8085 40.4336 L 41.6992 40.4336 C 40.9257 38.2539 38.8398 36.7070 36.4023 36.7070 C 33.9648 36.7070 31.8789 38.2539 31.1054 40.4336 L 6.2851 40.4336 C 5.2304 40.4336 4.3867 41.2773 4.3867 42.3320 C 4.3867 43.3867 5.2304 44.2070 6.2851 44.2070 L 31.1054 44.2070 C 31.8789 46.3867 33.9648 47.9570 36.4023 47.9570 Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Metadata
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showMetadata && metadataButtonRef}
|
||||||
|
<MetadataPopover
|
||||||
|
{post}
|
||||||
|
{postType}
|
||||||
|
triggerElement={metadataButtonRef}
|
||||||
|
bind:slug
|
||||||
|
bind:excerpt
|
||||||
|
bind:tags
|
||||||
|
bind:tagInput
|
||||||
|
onAddTag={addTag}
|
||||||
|
onRemoveTag={removeTag}
|
||||||
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
</svg>
|
{/if}
|
||||||
Delete
|
</div>
|
||||||
</button>
|
|
||||||
<button class="btn btn-text" onclick={() => (showMetadata = !showMetadata)}>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
||||||
<path
|
|
||||||
d="M8 4V8L10 10M14 8C14 11.3137 11.3137 14 8 14C4.68629 14 2 11.3137 2 8C2 4.68629 4.68629 2 8 2C11.3137 2 8 4.68629 8 8Z"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Metadata
|
|
||||||
</button>
|
|
||||||
{#if status === 'draft'}
|
{#if status === 'draft'}
|
||||||
<div class="publish-dropdown">
|
<div class="publish-dropdown">
|
||||||
<button
|
<button
|
||||||
|
|
@ -253,153 +294,25 @@
|
||||||
<div class="loading-container">
|
<div class="loading-container">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
{:else if post && postType === 'photo'}
|
{:else if loadError}
|
||||||
<PhotoPostForm
|
<div class="error-container">
|
||||||
mode="edit"
|
<h2>Error Loading Post</h2>
|
||||||
postId={post.id}
|
<p>{loadError}</p>
|
||||||
initialData={{
|
<button class="btn btn-primary" onclick={() => loadPost()}>Try Again</button>
|
||||||
title: post.title,
|
</div>
|
||||||
content: post.content,
|
|
||||||
featuredImage: post.featuredImage,
|
|
||||||
status: post.status,
|
|
||||||
tags: post.tags
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{:else if post && postType === 'album'}
|
|
||||||
<AlbumForm
|
|
||||||
mode="edit"
|
|
||||||
postId={post.id}
|
|
||||||
initialData={{
|
|
||||||
title: post.title,
|
|
||||||
content: post.content,
|
|
||||||
gallery: post.gallery || [],
|
|
||||||
status: post.status,
|
|
||||||
tags: post.tags
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{:else if post}
|
{:else if post}
|
||||||
<div class="post-composer">
|
<div class="post-composer">
|
||||||
<div class="main-content">
|
<div class="main-content">
|
||||||
{#if config.showTitle}
|
{#if config?.showTitle}
|
||||||
<input type="text" bind:value={title} placeholder="Title" class="title-input" />
|
<input type="text" bind:value={title} placeholder="Title" class="title-input" />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if postType === 'link'}
|
{#if config?.showContent}
|
||||||
<div class="link-fields">
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
bind:value={linkUrl}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
class="link-url-input"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
bind:value={linkDescription}
|
|
||||||
class="link-description"
|
|
||||||
rows="3"
|
|
||||||
placeholder="What makes this link interesting?"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{:else if postType === 'photo'}
|
|
||||||
<div class="photo-upload">
|
|
||||||
<div class="photo-placeholder">
|
|
||||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
||||||
<path
|
|
||||||
d="M40 14H31.5L28 10H20L16.5 14H8C5.8 14 4 15.8 4 18V34C4 36.2 5.8 38 8 38H40C42.2 38 44 36.2 44 34V18C44 15.8 42.2 14 40 14ZM24 32C19.6 32 16 28.4 16 24C16 19.6 19.6 16 24 16C28.4 16 32 19.6 32 24C32 28.4 28.4 32 24 32Z"
|
|
||||||
fill="currentColor"
|
|
||||||
opacity="0.1"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M24 28C26.2091 28 28 26.2091 28 24C28 21.7909 26.2091 20 24 20C21.7909 20 20 21.7909 20 24C20 26.2091 21.7909 28 24 28Z"
|
|
||||||
fill="currentColor"
|
|
||||||
opacity="0.3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p>Click to upload photo</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if postType === 'album'}
|
|
||||||
<div class="album-upload">
|
|
||||||
<div class="album-placeholder">
|
|
||||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
|
||||||
<rect x="8" y="8" width="24" height="24" rx="2" fill="currentColor" opacity="0.1" />
|
|
||||||
<rect
|
|
||||||
x="16"
|
|
||||||
y="16"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
rx="2"
|
|
||||||
fill="currentColor"
|
|
||||||
opacity="0.2"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M16 24L20 20L24 24L32 16L40 24V38C40 39.1046 39.1046 40 38 40H18C16.8954 40 16 39.1046 16 38V24Z"
|
|
||||||
fill="currentColor"
|
|
||||||
opacity="0.3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p>Click to upload photos</p>
|
|
||||||
<span class="album-hint">Select multiple photos</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if config.showContent}
|
|
||||||
<div class="editor-wrapper">
|
<div class="editor-wrapper">
|
||||||
<Editor bind:data={content} placeholder="Continue writing..." />
|
<Editor bind:data={content} placeholder="Continue writing..." />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showMetadata}
|
|
||||||
<aside class="metadata-sidebar">
|
|
||||||
<h3>Post Settings</h3>
|
|
||||||
|
|
||||||
<FormField label="Slug" bind:value={slug} />
|
|
||||||
|
|
||||||
{#if postType === 'blog'}
|
|
||||||
<FormFieldWrapper label="Excerpt">
|
|
||||||
<textarea
|
|
||||||
bind:value={excerpt}
|
|
||||||
class="form-textarea"
|
|
||||||
rows="3"
|
|
||||||
placeholder="Brief description..."
|
|
||||||
/>
|
|
||||||
</FormFieldWrapper>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<FormFieldWrapper label="Tags">
|
|
||||||
<div class="tag-input-wrapper">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={tagInput}
|
|
||||||
onkeydown={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
|
||||||
placeholder="Add tags..."
|
|
||||||
class="form-input"
|
|
||||||
/>
|
|
||||||
<button type="button" onclick={addTag} class="btn btn-small">Add</button>
|
|
||||||
</div>
|
|
||||||
{#if tags.length > 0}
|
|
||||||
<div class="tags">
|
|
||||||
{#each tags as tag}
|
|
||||||
<span class="tag">
|
|
||||||
{tag}
|
|
||||||
<button onclick={() => removeTag(tag)}>×</button>
|
|
||||||
</span>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</FormFieldWrapper>
|
|
||||||
|
|
||||||
<div class="metadata">
|
|
||||||
<p>Created: {new Date(post.created_at || post.createdAt).toLocaleString()}</p>
|
|
||||||
<p>Updated: {new Date(post.updated_at || post.updatedAt).toLocaleString()}</p>
|
|
||||||
{#if post.published_at || post.publishedAt}
|
|
||||||
<p>Published: {new Date(post.published_at || post.publishedAt).toLocaleString()}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="error">Post not found</div>
|
<div class="error">Post not found</div>
|
||||||
|
|
@ -416,17 +329,30 @@
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 400px;
|
||||||
|
text-align: center;
|
||||||
|
gap: $unit-2x;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: $grey-20;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: $grey-40;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $unit-2x;
|
gap: $unit-2x;
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 0;
|
|
||||||
color: $grey-10;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
|
|
@ -545,10 +471,6 @@
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: $unit-4x;
|
gap: $unit-4x;
|
||||||
|
|
||||||
&:has(.metadata-sidebar) {
|
|
||||||
grid-template-columns: 1fr 300px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
|
|
@ -560,7 +482,7 @@
|
||||||
|
|
||||||
.title-input {
|
.title-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0;
|
padding: 0 $unit-2x;
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|
@ -576,182 +498,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-fields {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $unit-2x;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-url-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: $unit-2x $unit-3x;
|
|
||||||
border: 1px solid $grey-80;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
background-color: $grey-95;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: $grey-50;
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-description {
|
|
||||||
width: 100%;
|
|
||||||
padding: $unit-2x $unit-3x;
|
|
||||||
border: 1px solid $grey-80;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
background-color: $grey-95;
|
|
||||||
resize: vertical;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: $grey-50;
|
|
||||||
background-color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo-upload,
|
|
||||||
.album-upload {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.photo-placeholder,
|
|
||||||
.album-placeholder {
|
|
||||||
border: 2px dashed $grey-80;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: $unit-8x;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: $unit-2x;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
background: $grey-95;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: $grey-60;
|
|
||||||
background: $grey-90;
|
|
||||||
}
|
|
||||||
|
|
||||||
svg {
|
|
||||||
color: $grey-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
color: $grey-30;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.album-hint {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: $grey-50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metadata-sidebar {
|
.metadata-popover-container {
|
||||||
background: $grey-95;
|
position: relative;
|
||||||
border-radius: 12px;
|
|
||||||
padding: $unit-3x;
|
|
||||||
height: fit-content;
|
|
||||||
position: sticky;
|
|
||||||
top: $unit-3x;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 $unit-3x;
|
|
||||||
color: $grey-10;
|
|
||||||
}
|
|
||||||
|
|
||||||
> * + * {
|
|
||||||
margin-top: $unit-3x;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: $unit-2x;
|
|
||||||
border: 1px solid $grey-80;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
background-color: white;
|
|
||||||
resize: vertical;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: $grey-50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-input {
|
|
||||||
width: 100%;
|
|
||||||
padding: $unit $unit-2x;
|
|
||||||
border: 1px solid $grey-80;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
background-color: white;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: $grey-50;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-input-wrapper {
|
|
||||||
display: flex;
|
|
||||||
gap: $unit;
|
|
||||||
|
|
||||||
input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: $unit;
|
|
||||||
margin-top: $unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 4px $unit-2x;
|
|
||||||
background: $grey-80;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
|
|
||||||
button {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: $grey-40;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $grey-10;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: $grey-40;
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: $unit-half 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
|
|
@ -759,14 +512,4 @@
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include breakpoint('phone') {
|
|
||||||
.post-composer {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metadata-sidebar {
|
|
||||||
position: static;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -33,13 +33,23 @@ export const GET: RequestHandler = async (event) => {
|
||||||
// Get total count
|
// Get total count
|
||||||
const total = await prisma.album.count({ where })
|
const total = await prisma.album.count({ where })
|
||||||
|
|
||||||
// Get albums with photo count
|
// Get albums with photo count and photos for thumbnails
|
||||||
const albums = await prisma.album.findMany({
|
const albums = await prisma.album.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
include: {
|
include: {
|
||||||
|
photos: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
url: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
caption: true
|
||||||
|
},
|
||||||
|
orderBy: { displayOrder: 'asc' },
|
||||||
|
take: 5 // Only get first 5 photos for thumbnails
|
||||||
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: { photos: true }
|
select: { photos: true }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue