Unify fullscreen editors
This commit is contained in:
parent
f1ab953b89
commit
f753d5fb8b
36 changed files with 705 additions and 504 deletions
|
|
@ -16,11 +16,13 @@ This PRD outlines the implementation of automatic dominant color extraction for
|
|||
### Color Extraction Library Options
|
||||
|
||||
1. **node-vibrant** (Recommended)
|
||||
|
||||
- Pros: Lightweight, fast, good algorithm, actively maintained
|
||||
- Cons: Node.js only (server-side processing)
|
||||
- NPM: `node-vibrant`
|
||||
|
||||
2. **color-thief-node**
|
||||
|
||||
- Pros: Simple API, battle-tested algorithm
|
||||
- Cons: Less feature-rich than vibrant
|
||||
- NPM: `colorthief`
|
||||
|
|
@ -50,6 +52,7 @@ const dominantColors = {
|
|||
## Database Schema Changes
|
||||
|
||||
### Option 1: Add to Existing exifData JSON (Recommended)
|
||||
|
||||
```prisma
|
||||
model Media {
|
||||
// ... existing fields
|
||||
|
|
@ -58,6 +61,7 @@ model Media {
|
|||
```
|
||||
|
||||
### Option 2: Separate Colors Field
|
||||
|
||||
```prisma
|
||||
model Media {
|
||||
// ... existing fields
|
||||
|
|
@ -110,20 +114,24 @@ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
|
|||
## UI/UX Considerations
|
||||
|
||||
### 1. Media Library Display
|
||||
|
||||
- Show color swatches on hover/focus
|
||||
- Optional: Color-based filtering or sorting
|
||||
|
||||
### 2. Gallery Image Modal
|
||||
|
||||
- Display color palette in metadata section
|
||||
- Show hex values for each color
|
||||
- Copy-to-clipboard functionality for colors
|
||||
|
||||
### 3. Album/Gallery Views
|
||||
|
||||
- Use dominant color for background accents
|
||||
- Create dynamic gradients from extracted colors
|
||||
- Enhance loading states with color placeholders
|
||||
|
||||
### 4. Potential Future Features
|
||||
|
||||
- Color-based search ("find blue images")
|
||||
- Automatic theme generation for albums
|
||||
- Color harmony analysis for galleries
|
||||
|
|
@ -131,6 +139,7 @@ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
|
|||
## Implementation Plan
|
||||
|
||||
### Phase 1: Backend Implementation (1 day)
|
||||
|
||||
1. Install and configure node-vibrant
|
||||
2. Create color extraction utility function
|
||||
3. Integrate into upload pipeline
|
||||
|
|
@ -138,16 +147,19 @@ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
|
|||
5. Update API responses
|
||||
|
||||
### Phase 2: Basic Frontend Display (0.5 day)
|
||||
|
||||
1. Update Media type definitions
|
||||
2. Display colors in GalleryImageModal
|
||||
3. Add color swatches to media details
|
||||
|
||||
### Phase 3: Enhanced UI Features (1 day)
|
||||
|
||||
1. Implement color-based backgrounds
|
||||
2. Add loading placeholders with colors
|
||||
3. Create color palette component
|
||||
|
||||
### Phase 4: Testing & Optimization (0.5 day)
|
||||
|
||||
1. Test with various image types
|
||||
2. Optimize for performance
|
||||
3. Handle edge cases (B&W images, etc.)
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
|
|||
## Problem Statement
|
||||
|
||||
### Current State
|
||||
|
||||
- Most pages use a static default OG image
|
||||
- Dynamic content (projects, essays, photos) doesn't have representative imagery when shared
|
||||
- No visual differentiation between content types in social previews
|
||||
- Missed opportunity for branding and engagement
|
||||
|
||||
### Impact
|
||||
|
||||
- Poor social media engagement rates
|
||||
- Generic appearance when content is shared
|
||||
- Lost opportunity to showcase project visuals and branding
|
||||
|
|
@ -31,6 +33,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
|
|||
### Content Type Requirements
|
||||
|
||||
#### 1. Work Projects
|
||||
|
||||
- **Format**: [Avatar] + [Logo] (with "+" symbol) centered on brand background color
|
||||
- **Data needed**:
|
||||
- Project logo URL (`logoUrl`)
|
||||
|
|
@ -41,6 +44,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
|
|||
- **Font**: cstd Regular for any text
|
||||
|
||||
#### 2. Essays (Universe)
|
||||
|
||||
- **Format**: Universe icon + "Universe" label above essay title
|
||||
- **Layout**: Left-aligned, vertically centered content block
|
||||
- **Styling**:
|
||||
|
|
@ -54,6 +58,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
|
|||
- **Font**: cstd Regular for all text
|
||||
|
||||
#### 3. Labs Projects
|
||||
|
||||
- **Format**: Labs icon + "Labs" label above project title
|
||||
- **Layout**: Same as Essays - left-aligned, vertically centered
|
||||
- **Styling**:
|
||||
|
|
@ -67,6 +72,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
|
|||
- **Font**: cstd Regular for all text
|
||||
|
||||
#### 4. Photos
|
||||
|
||||
- **Format**: The photo itself, fitted within frame
|
||||
- **Styling**:
|
||||
- Photo scaled to fit within 1200x630 bounds
|
||||
|
|
@ -74,6 +80,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
|
|||
- **Data needed**: Photo URL
|
||||
|
||||
#### 5. Albums
|
||||
|
||||
- **Format**: First photo (blurred) as background + Photos format overlay
|
||||
- **Layout**: Same as Essays/Labs - left-aligned, vertically centered
|
||||
- **Styling**:
|
||||
|
|
@ -86,6 +93,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
|
|||
- **Font**: cstd Regular for all text
|
||||
|
||||
#### 6. Root Pages (Homepage, Universe, Photos, Labs, About)
|
||||
|
||||
- **No change**: Continue using existing static OG image
|
||||
|
||||
### Technical Requirements
|
||||
|
|
@ -141,6 +149,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
|
|||
### Implementation Details
|
||||
|
||||
#### 1. API Endpoint Structure
|
||||
|
||||
```typescript
|
||||
/api/og-image?type=work&title=Project&logo=url&bg=color
|
||||
/api/og-image?type=essay&title=Essay+Title
|
||||
|
|
@ -150,6 +159,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
|
|||
```
|
||||
|
||||
#### 2. Hybrid Template System
|
||||
|
||||
- SVG templates for text-based layouts (work, essays, labs, photos)
|
||||
- Canvas/Sharp for blur effects (albums)
|
||||
- Use template literals for dynamic content injection
|
||||
|
|
@ -157,6 +167,7 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
|
|||
- All text rendered in cstd Regular font
|
||||
|
||||
#### 3. Asset Management
|
||||
|
||||
- Avatar: Use existing SVG at src/assets/illos/jedmund.svg, convert to base64
|
||||
- Icons: Convert Universe, Labs, Photos icons to base64
|
||||
- Fonts: Embed cstd Regular font for consistent rendering
|
||||
|
|
@ -167,17 +178,20 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
|
|||
##### Multi-Level Caching Architecture
|
||||
|
||||
**Level 1: Cloudinary CDN (Permanent Storage)**
|
||||
|
||||
- Upload generated images to `jedmund/og-images/` folder
|
||||
- Use content-based public IDs: `og-{type}-{contentHash}`
|
||||
- Leverage Cloudinary's global CDN for distribution
|
||||
- Automatic format optimization and responsive delivery
|
||||
|
||||
**Level 2: Redis Cache (Fast Lookups)**
|
||||
|
||||
- Cache mapping: content ID → Cloudinary public ID
|
||||
- TTL: 24 hours for quick access
|
||||
- Key structure: `og:{type}:{id}:{version}` → `cloudinary_public_id`
|
||||
|
||||
**Level 3: Browser Cache (Client-side)**
|
||||
|
||||
- Set long cache headers on Cloudinary URLs
|
||||
- Immutable URLs with content-based versioning
|
||||
|
||||
|
|
@ -242,6 +256,7 @@ src/
|
|||
## Implementation Plan
|
||||
|
||||
### Phase 1: Foundation (Day 1)
|
||||
|
||||
- [ ] Install dependencies (sharp for image processing)
|
||||
- [ ] Create API endpoint structure
|
||||
- [ ] Set up Cloudinary integration for og-images folder
|
||||
|
|
@ -249,6 +264,7 @@ src/
|
|||
- [ ] Implement basic SVG to PNG conversion
|
||||
|
||||
### Phase 2: Asset Preparation (Day 2)
|
||||
|
||||
- [ ] Load Avatar SVG from src/assets/illos/jedmund.svg
|
||||
- [ ] Convert Avatar SVG to base64 for embedding
|
||||
- [ ] Convert Universe, Labs, Photos icons to base64
|
||||
|
|
@ -257,6 +273,7 @@ src/
|
|||
- [ ] Test asset embedding in SVGs
|
||||
|
||||
### Phase 3: Template Development (Days 3-4)
|
||||
|
||||
- [ ] Create Work project template
|
||||
- [ ] Create Essay/Universe template
|
||||
- [ ] Create Labs template (reuse Essay structure)
|
||||
|
|
@ -264,6 +281,7 @@ src/
|
|||
- [ ] Create Album template
|
||||
|
||||
### Phase 4: Integration (Day 5)
|
||||
|
||||
- [ ] Update metadata utils to generate OG image URLs
|
||||
- [ ] Implement Cloudinary upload pipeline
|
||||
- [ ] Set up Redis caching for Cloudinary URLs
|
||||
|
|
@ -272,6 +290,7 @@ src/
|
|||
- [ ] Test all content types
|
||||
|
||||
### Phase 5: Optimization (Day 6)
|
||||
|
||||
- [ ] Performance testing
|
||||
- [ ] Add rate limiting
|
||||
- [ ] Optimize SVG generation
|
||||
|
|
@ -280,38 +299,48 @@ src/
|
|||
## Potential Pitfalls & Mitigations
|
||||
|
||||
### 1. Performance Issues
|
||||
|
||||
**Risk**: SVG to PNG conversion could be slow, especially with blur effects
|
||||
**Mitigation**:
|
||||
|
||||
- Pre-generate common images
|
||||
- Use efficient SVG structures for text-based layouts
|
||||
- Use Sharp's built-in blur capabilities for album backgrounds
|
||||
- Implement request coalescing
|
||||
|
||||
### 2. Memory Usage
|
||||
|
||||
**Risk**: Image processing could consume significant memory
|
||||
**Mitigation**:
|
||||
|
||||
- Stream processing where possible
|
||||
- Implement memory limits
|
||||
- Use worker threads if needed
|
||||
|
||||
### 3. Font Rendering
|
||||
|
||||
**Risk**: cstd Regular font may not render consistently
|
||||
**Mitigation**:
|
||||
|
||||
- Embed cstd Regular font as base64 in SVG
|
||||
- Use font subsetting to reduce size
|
||||
- Test rendering across different platforms
|
||||
- Fallback to similar web-safe fonts if needed
|
||||
|
||||
### 4. Asset Loading
|
||||
|
||||
**Risk**: External assets could fail to load
|
||||
**Mitigation**:
|
||||
|
||||
- Embed all assets as base64
|
||||
- No external dependencies
|
||||
- Graceful fallbacks
|
||||
|
||||
### 5. Cache Invalidation
|
||||
|
||||
**Risk**: Updated content shows old OG images
|
||||
**Mitigation**:
|
||||
|
||||
- Include version/timestamp in URL params
|
||||
- Use content-based cache keys
|
||||
- Provide manual cache purge option
|
||||
|
|
@ -337,16 +366,19 @@ src/
|
|||
### Admin UI for OG Image Management
|
||||
|
||||
1. **OG Image Viewer**
|
||||
|
||||
- Display current OG image for each content type
|
||||
- Show Cloudinary URL and metadata
|
||||
- Preview how it appears on social platforms
|
||||
|
||||
2. **Manual Regeneration**
|
||||
|
||||
- "Regenerate OG Image" button per content item
|
||||
- Preview new image before confirming
|
||||
- Bulk regeneration tools for content types
|
||||
|
||||
3. **Analytics Dashboard**
|
||||
|
||||
- Track generation frequency
|
||||
- Monitor cache hit rates
|
||||
- Show most viewed OG images
|
||||
|
|
@ -359,6 +391,7 @@ src/
|
|||
## Task Checklist
|
||||
|
||||
### High Priority
|
||||
|
||||
- [ ] Set up API endpoint with proper routing
|
||||
- [ ] Install sharp and @resvg/resvg-js for image processing
|
||||
- [ ] Configure Cloudinary og-images folder
|
||||
|
|
@ -377,6 +410,7 @@ src/
|
|||
- [ ] Test end-to-end caching flow
|
||||
|
||||
### Medium Priority
|
||||
|
||||
- [ ] Add comprehensive error handling
|
||||
- [ ] Implement rate limiting
|
||||
- [ ] Add request logging
|
||||
|
|
@ -384,12 +418,14 @@ src/
|
|||
- [ ] Performance optimization
|
||||
|
||||
### Low Priority
|
||||
|
||||
- [ ] Add monitoring dashboard
|
||||
- [ ] Create manual regeneration endpoint
|
||||
- [ ] Add A/B testing capability
|
||||
- [ ] Documentation
|
||||
|
||||
### Stretch Goals
|
||||
|
||||
- [ ] Admin UI: OG image viewer
|
||||
- [ ] Admin UI: Manual regeneration button
|
||||
- [ ] Admin UI: Bulk regeneration tools
|
||||
|
|
@ -400,6 +436,7 @@ src/
|
|||
## Dependencies
|
||||
|
||||
### Required Packages
|
||||
|
||||
- `sharp`: For SVG to PNG conversion and blur effects
|
||||
- `@resvg/resvg-js`: Alternative high-quality SVG to PNG converter
|
||||
- `cloudinary`: Already installed, for image storage and CDN
|
||||
|
|
@ -407,6 +444,7 @@ src/
|
|||
- Built-in Node.js modules for base64 encoding
|
||||
|
||||
### External Assets Needed
|
||||
|
||||
- Avatar SVG (existing at src/assets/illos/jedmund.svg)
|
||||
- Universe icon SVG
|
||||
- Labs icon SVG
|
||||
|
|
@ -414,11 +452,13 @@ src/
|
|||
- cstd Regular font file
|
||||
|
||||
### API Requirements
|
||||
|
||||
- Access to project data (logo, colors)
|
||||
- Access to photo URLs
|
||||
- Access to content titles and descriptions
|
||||
|
||||
### Infrastructure Requirements
|
||||
|
||||
- Cloudinary account with og-images folder configured
|
||||
- Redis instance for caching (already available)
|
||||
- Railway deployment (no local disk storage)
|
||||
|
|
@ -22,7 +22,7 @@ async function checkPhotosDisplay() {
|
|||
})
|
||||
|
||||
console.log(`Found ${photographyAlbums.length} published photography albums:`)
|
||||
photographyAlbums.forEach(album => {
|
||||
photographyAlbums.forEach((album) => {
|
||||
console.log(`- "${album.title}" (${album.slug}): ${album.photos.length} published photos`)
|
||||
})
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ async function checkPhotosDisplay() {
|
|||
})
|
||||
|
||||
console.log(`\nFound ${individualPhotos.length} individual photos marked to show in Photos`)
|
||||
individualPhotos.forEach(photo => {
|
||||
individualPhotos.forEach((photo) => {
|
||||
console.log(`- Photo ID ${photo.id}: ${photo.filename}`)
|
||||
})
|
||||
|
||||
|
|
@ -52,12 +52,17 @@ async function checkPhotosDisplay() {
|
|||
}
|
||||
})
|
||||
|
||||
console.log(`\nFound ${photosInAlbums.length} published photos in albums with showInPhotos=true`)
|
||||
const albumGroups = photosInAlbums.reduce((acc, photo) => {
|
||||
console.log(
|
||||
`\nFound ${photosInAlbums.length} published photos in albums with showInPhotos=true`
|
||||
)
|
||||
const albumGroups = photosInAlbums.reduce(
|
||||
(acc, photo) => {
|
||||
const albumTitle = photo.album?.title || 'Unknown'
|
||||
acc[albumTitle] = (acc[albumTitle] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)
|
||||
|
||||
Object.entries(albumGroups).forEach(([album, count]) => {
|
||||
console.log(`- Album "${album}": ${count} photos`)
|
||||
|
|
@ -80,10 +85,13 @@ async function checkPhotosDisplay() {
|
|||
})
|
||||
|
||||
console.log(`\nTotal photos in database: ${allPhotos.length}`)
|
||||
const statusCounts = allPhotos.reduce((acc, photo) => {
|
||||
const statusCounts = allPhotos.reduce(
|
||||
(acc, photo) => {
|
||||
acc[photo.status] = (acc[photo.status] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
},
|
||||
{} as Record<string, number>
|
||||
)
|
||||
|
||||
Object.entries(statusCounts).forEach(([status, count]) => {
|
||||
console.log(`- Status "${status}": ${count} photos`)
|
||||
|
|
@ -99,10 +107,11 @@ async function checkPhotosDisplay() {
|
|||
})
|
||||
|
||||
console.log(`\nTotal albums in database: ${allAlbums.length}`)
|
||||
allAlbums.forEach(album => {
|
||||
console.log(`- "${album.title}" (${album.slug}): status=${album.status}, isPhotography=${album.isPhotography}, photos=${album._count.photos}`)
|
||||
allAlbums.forEach((album) => {
|
||||
console.log(
|
||||
`- "${album.title}" (${album.slug}): status=${album.status}, isPhotography=${album.isPhotography}, photos=${album._count.photos}`
|
||||
)
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking photos:', error)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ This directory contains tools to debug why photos aren't appearing on the photos
|
|||
## API Test Endpoint
|
||||
|
||||
Visit the following URL in your browser while the dev server is running:
|
||||
|
||||
```
|
||||
http://localhost:5173/api/test-photos
|
||||
```
|
||||
|
||||
This endpoint will return detailed information about:
|
||||
|
||||
- All photos with showInPhotos=true and albumId=null
|
||||
- Status distribution of these photos
|
||||
- Raw SQL query results
|
||||
|
|
@ -18,11 +20,13 @@ This endpoint will return detailed information about:
|
|||
## Database Query Script
|
||||
|
||||
Run the following command to query the database directly:
|
||||
|
||||
```bash
|
||||
npx tsx scripts/test-photos-query.ts
|
||||
```
|
||||
|
||||
This script will show:
|
||||
|
||||
- Total photos in the database
|
||||
- Photos matching the criteria (showInPhotos=true, albumId=null)
|
||||
- Status distribution
|
||||
|
|
|
|||
|
|
@ -69,10 +69,14 @@ async function main() {
|
|||
|
||||
// Create AlbumMedia record if photo belongs to an album
|
||||
if (photo.albumId) {
|
||||
const mediaId = photo.mediaId || (await prisma.photo.findUnique({
|
||||
const mediaId =
|
||||
photo.mediaId ||
|
||||
(
|
||||
await prisma.photo.findUnique({
|
||||
where: { id: photo.id },
|
||||
select: { mediaId: true }
|
||||
}))?.mediaId
|
||||
})
|
||||
)?.mediaId
|
||||
|
||||
if (mediaId) {
|
||||
// Check if AlbumMedia already exists
|
||||
|
|
@ -121,7 +125,6 @@ async function main() {
|
|||
console.log(`\nVerification:`)
|
||||
console.log(`- Media records with photo data: ${mediaWithPhotoData}`)
|
||||
console.log(`- Album-media relationships: ${albumMediaRelations}`)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Migration failed:', error)
|
||||
process.exit(1)
|
||||
|
|
|
|||
|
|
@ -186,7 +186,6 @@ async function testMediaSharing() {
|
|||
where: { id: media.id }
|
||||
})
|
||||
console.log('✓ Test data cleaned up')
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ ERROR:', error)
|
||||
process.exit(1)
|
||||
|
|
|
|||
|
|
@ -30,8 +30,10 @@ async function testPhotoQueries() {
|
|||
})
|
||||
|
||||
console.log(`\nPhotos with showInPhotos=true and albumId=null: ${photosForDisplay.length}`)
|
||||
photosForDisplay.forEach(photo => {
|
||||
console.log(` - ID: ${photo.id}, Status: ${photo.status}, Slug: ${photo.slug || 'none'}, File: ${photo.filename}`)
|
||||
photosForDisplay.forEach((photo) => {
|
||||
console.log(
|
||||
` - ID: ${photo.id}, Status: ${photo.status}, Slug: ${photo.slug || 'none'}, File: ${photo.filename}`
|
||||
)
|
||||
})
|
||||
|
||||
// Query 3: Check status distribution
|
||||
|
|
@ -60,8 +62,10 @@ async function testPhotoQueries() {
|
|||
}
|
||||
})
|
||||
|
||||
console.log(`\nPublished photos (status='published', showInPhotos=true, albumId=null): ${publishedPhotos.length}`)
|
||||
publishedPhotos.forEach(photo => {
|
||||
console.log(
|
||||
`\nPublished photos (status='published', showInPhotos=true, albumId=null): ${publishedPhotos.length}`
|
||||
)
|
||||
publishedPhotos.forEach((photo) => {
|
||||
console.log(` - ID: ${photo.id}, File: ${photo.filename}, Published: ${photo.publishedAt}`)
|
||||
})
|
||||
|
||||
|
|
@ -76,7 +80,7 @@ async function testPhotoQueries() {
|
|||
|
||||
if (draftPhotos.length > 0) {
|
||||
console.log(`\n⚠️ Found ${draftPhotos.length} draft photos with showInPhotos=true:`)
|
||||
draftPhotos.forEach(photo => {
|
||||
draftPhotos.forEach((photo) => {
|
||||
console.log(` - ID: ${photo.id}, File: ${photo.filename}`)
|
||||
})
|
||||
console.log('These photos need to be published to appear in the photos page!')
|
||||
|
|
@ -94,7 +98,6 @@ async function testPhotoQueries() {
|
|||
uniqueStatuses.forEach(({ status }) => {
|
||||
console.log(` - "${status}"`)
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error running queries:', error)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -167,7 +167,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
function handleCancel() {
|
||||
if (hasChanges() && !confirm('Are you sure you want to cancel? Your changes will be lost.')) {
|
||||
return
|
||||
|
|
|
|||
162
src/lib/components/admin/CaseStudyEditor.svelte
Normal file
162
src/lib/components/admin/CaseStudyEditor.svelte
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<script lang="ts">
|
||||
import Editor from './Editor.svelte'
|
||||
import type { JSONContent } from '@tiptap/core'
|
||||
|
||||
interface Props {
|
||||
data?: JSONContent
|
||||
onChange?: (content: JSONContent) => void
|
||||
placeholder?: string
|
||||
minHeight?: number
|
||||
autofocus?: boolean
|
||||
mode?: 'default' | 'inline'
|
||||
showToolbar?: boolean
|
||||
class?: string
|
||||
}
|
||||
|
||||
let {
|
||||
data = $bindable(),
|
||||
onChange = () => {},
|
||||
placeholder = 'Write your content here...',
|
||||
minHeight = 400,
|
||||
autofocus = false,
|
||||
mode = 'default',
|
||||
showToolbar = true,
|
||||
class: className = ''
|
||||
}: Props = $props()
|
||||
|
||||
let editorRef: Editor | undefined = $state()
|
||||
|
||||
// Forward editor methods if needed
|
||||
export function focus() {
|
||||
editorRef?.focus()
|
||||
}
|
||||
|
||||
export function blur() {
|
||||
editorRef?.blur()
|
||||
}
|
||||
|
||||
export function getContent() {
|
||||
return editorRef?.getContent()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={`case-study-editor-wrapper ${mode} ${className}`}>
|
||||
<Editor
|
||||
bind:this={editorRef}
|
||||
bind:data
|
||||
{onChange}
|
||||
{placeholder}
|
||||
{minHeight}
|
||||
{autofocus}
|
||||
{showToolbar}
|
||||
class="case-study-editor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@import '$styles/variables.scss';
|
||||
|
||||
.case-study-editor-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Default mode - used in ProjectForm */
|
||||
.case-study-editor-wrapper.default {
|
||||
:global(.case-study-editor) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(.case-study-editor .edra) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:global(.case-study-editor .editor-toolbar) {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 0 16px rgba(0, 0, 0, 0.12);
|
||||
background: $grey-95;
|
||||
}
|
||||
|
||||
:global(.case-study-editor .edra-editor) {
|
||||
padding: 0 $unit-4x;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(.case-study-editor .ProseMirror) {
|
||||
min-height: calc(100% - 80px);
|
||||
}
|
||||
|
||||
:global(.case-study-editor .ProseMirror:focus) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.case-study-editor .ProseMirror > * + *) {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
:global(.case-study-editor .ProseMirror p.is-editor-empty:first-child::before) {
|
||||
color: #adb5bd;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline mode - used in UniverseComposer */
|
||||
.case-study-editor-wrapper.inline {
|
||||
:global(.case-study-editor) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:global(.case-study-editor .edra-editor) {
|
||||
padding: $unit-2x 0;
|
||||
}
|
||||
|
||||
:global(.case-study-editor .editor-container) {
|
||||
padding: 0 $unit-3x;
|
||||
}
|
||||
|
||||
:global(.case-study-editor .editor-content) {
|
||||
padding: 0;
|
||||
min-height: 80px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:global(.case-study-editor .ProseMirror) {
|
||||
padding: 0;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
:global(.case-study-editor .ProseMirror:focus) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.case-study-editor .ProseMirror p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(
|
||||
.case-study-editor .ProseMirror.ProseMirror-focused .is-editor-empty:first-child::before
|
||||
) {
|
||||
color: $grey-40;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -131,10 +131,12 @@
|
|||
}
|
||||
|
||||
:global(.editor-content .editor-toolbar) {
|
||||
border-radius: $card-corner-radius;
|
||||
border-radius: $corner-radius-full;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
box-shadow: 0 0 16px rgba(0, 0, 0, 0.12);
|
||||
box-sizing: border-box;
|
||||
background: $grey-95;
|
||||
padding: $unit-2x;
|
||||
padding: $unit $unit-2x;
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
overflow-x: auto;
|
||||
|
|
|
|||
|
|
@ -251,7 +251,11 @@
|
|||
const editorInstance = (view as any).editor
|
||||
if (editorInstance) {
|
||||
// Use pasteHTML to let Tiptap process the HTML and apply configured extensions
|
||||
editorInstance.chain().focus().insertContent(htmlData, { parseOptions: { preserveWhitespace: false } }).run()
|
||||
editorInstance
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent(htmlData, { parseOptions: { preserveWhitespace: false } })
|
||||
.run()
|
||||
} else {
|
||||
// Fallback to plain text if editor instance not available
|
||||
const { state, dispatch } = view
|
||||
|
|
@ -506,6 +510,7 @@
|
|||
}
|
||||
}}
|
||||
class="edra-editor"
|
||||
class:with-toolbar={showToolbar}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -724,7 +729,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
<style lang="scss">
|
||||
.edra {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
|
@ -736,9 +741,10 @@
|
|||
.editor-toolbar {
|
||||
background: var(--edra-button-bg-color);
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem;
|
||||
padding: $unit ($unit-2x + $unit);
|
||||
position: sticky;
|
||||
top: 68px;
|
||||
box-sizing: border-box;
|
||||
top: 75px;
|
||||
z-index: 10;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
|
@ -758,6 +764,10 @@
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// .edra-editor.with-toolbar {
|
||||
// padding-top: 52px; /* Account for sticky toolbar height */
|
||||
// }
|
||||
|
||||
:global(.ProseMirror) {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
|
|
|
|||
|
|
@ -157,8 +157,8 @@
|
|||
setTimeout(() => {
|
||||
console.log('[GalleryUploader] Upload completed:', {
|
||||
uploadedCount: uploadedMedia.length,
|
||||
uploaded: uploadedMedia.map(m => ({ id: m.id, filename: m.filename })),
|
||||
currentValue: value?.map(v => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
|
||||
uploaded: uploadedMedia.map((m) => ({ id: m.id, filename: m.filename })),
|
||||
currentValue: value?.map((v) => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
|
||||
})
|
||||
|
||||
// Don't update value here - let parent handle it through API response
|
||||
|
|
@ -224,7 +224,6 @@
|
|||
uploadError = null
|
||||
}
|
||||
|
||||
|
||||
// Drag and drop reordering handlers
|
||||
function handleImageDragStart(event: DragEvent, index: number) {
|
||||
// Prevent reordering while uploading or disabled
|
||||
|
|
@ -324,8 +323,8 @@
|
|||
// Debug logging
|
||||
console.log('[GalleryUploader] Media selected from library:', {
|
||||
selectedCount: mediaArray.length,
|
||||
selected: mediaArray.map(m => ({ id: m.id, filename: m.filename })),
|
||||
currentValue: value?.map(v => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
|
||||
selected: mediaArray.map((m) => ({ id: m.id, filename: m.filename })),
|
||||
currentValue: value?.map((v) => ({ id: v.id, mediaId: v.mediaId, filename: v.filename }))
|
||||
})
|
||||
|
||||
// Filter out duplicates before passing to parent
|
||||
|
|
@ -343,7 +342,7 @@
|
|||
|
||||
console.log('[GalleryUploader] Filtered new media:', {
|
||||
newCount: newMedia.length,
|
||||
newMedia: newMedia.map(m => ({ id: m.id, filename: m.filename }))
|
||||
newMedia: newMedia.map((m) => ({ id: m.id, filename: m.filename }))
|
||||
})
|
||||
|
||||
if (newMedia.length > 0) {
|
||||
|
|
@ -384,7 +383,7 @@
|
|||
// Handle updates from the media details modal
|
||||
function handleImageUpdate(updatedMedia: any) {
|
||||
// Update the media in our value array
|
||||
const index = value.findIndex(m => (m.mediaId || m.id) === updatedMedia.id)
|
||||
const index = value.findIndex((m) => (m.mediaId || m.id) === updatedMedia.id)
|
||||
if (index !== -1) {
|
||||
value[index] = {
|
||||
...value[index],
|
||||
|
|
@ -409,7 +408,7 @@
|
|||
class:drag-over={isDragOver}
|
||||
class:uploading={isUploading}
|
||||
class:has-error={!!uploadError}
|
||||
class:disabled={disabled}
|
||||
class:disabled
|
||||
ondragover={disabled ? undefined : handleDragOver}
|
||||
ondragleave={disabled ? undefined : handleDragLeave}
|
||||
ondrop={disabled ? undefined : handleDrop}
|
||||
|
|
@ -524,12 +523,12 @@
|
|||
<!-- Action Buttons -->
|
||||
{#if !isUploading && canAddMore}
|
||||
<div class="action-buttons">
|
||||
<Button variant="primary" onclick={handleBrowseClick} disabled={disabled}>
|
||||
<Button variant="primary" onclick={handleBrowseClick} {disabled}>
|
||||
{hasImages ? 'Add More Images' : 'Choose Images'}
|
||||
</Button>
|
||||
|
||||
{#if showBrowseLibrary}
|
||||
<Button variant="ghost" onclick={handleBrowseLibrary} disabled={disabled}>Browse Library</Button>
|
||||
<Button variant="ghost" onclick={handleBrowseLibrary} {disabled}>Browse Library</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -542,7 +541,7 @@
|
|||
class="gallery-item"
|
||||
class:dragging={draggedIndex === index}
|
||||
class:drag-over={draggedOverIndex === index}
|
||||
class:disabled={disabled}
|
||||
class:disabled
|
||||
draggable={!disabled}
|
||||
ondragstart={(e) => handleImageDragStart(e, index)}
|
||||
ondragover={(e) => handleImageDragOver(e, index)}
|
||||
|
|
@ -575,7 +574,7 @@
|
|||
type="button"
|
||||
onclick={() => handleImageClick(media)}
|
||||
aria-label="Edit image {media.filename}"
|
||||
disabled={disabled}
|
||||
{disabled}
|
||||
>
|
||||
<SmartImage
|
||||
media={{
|
||||
|
|
@ -611,7 +610,7 @@
|
|||
}}
|
||||
type="button"
|
||||
aria-label="Remove image"
|
||||
disabled={disabled}
|
||||
{disabled}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
|
|
@ -991,7 +990,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.file-info {
|
||||
padding: $unit-2x;
|
||||
padding-top: $unit;
|
||||
|
|
|
|||
|
|
@ -205,7 +205,11 @@
|
|||
<div class="image-pane">
|
||||
{#if media.mimeType.startsWith('image/')}
|
||||
<div class="image-container">
|
||||
<SmartImage {media} alt={media.description || media.altText || media.filename} class="preview-image" />
|
||||
<SmartImage
|
||||
{media}
|
||||
alt={media.description || media.altText || media.filename}
|
||||
class="preview-image"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="file-placeholder">
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import AdminPage from './AdminPage.svelte'
|
||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import CaseStudyEditor from './CaseStudyEditor.svelte'
|
||||
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
|
||||
import ProjectBrandingForm from './ProjectBrandingForm.svelte'
|
||||
import ProjectImagesForm from './ProjectImagesForm.svelte'
|
||||
|
|
@ -272,20 +272,18 @@
|
|||
</div>
|
||||
|
||||
<!-- Case Study Panel -->
|
||||
<div class="panel case-study-wrapper" class:active={activeTab === 'case-study'}>
|
||||
<div class="editor-content">
|
||||
<Editor
|
||||
<div class="panel panel-case-study" class:active={activeTab === 'case-study'}>
|
||||
<CaseStudyEditor
|
||||
bind:this={editorRef}
|
||||
bind:data={formData.caseStudyContent}
|
||||
onChange={handleEditorChange}
|
||||
placeholder="Write your case study here..."
|
||||
minHeight={400}
|
||||
autofocus={false}
|
||||
class="case-study-editor"
|
||||
mode="default"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</AdminPage>
|
||||
|
|
@ -338,7 +336,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.admin-container {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
|
|
@ -414,31 +411,16 @@
|
|||
gap: $unit-6x;
|
||||
}
|
||||
|
||||
.case-study-wrapper {
|
||||
.panel-case-study {
|
||||
background: white;
|
||||
padding: 0;
|
||||
min-height: 80vh;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
@include breakpoint('phone') {
|
||||
height: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
|
||||
/* The editor component will handle its own padding and scrolling */
|
||||
:global(.case-study-editor) {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 600px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -336,7 +336,7 @@
|
|||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: $unit-3x;
|
||||
padding: $unit-4x;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
|
|
|
|||
|
|
@ -109,7 +109,12 @@
|
|||
{#if availableActions.length > 0}
|
||||
<div class="dropdown-divider"></div>
|
||||
{/if}
|
||||
<a href={viewUrl} target="_blank" rel="noopener noreferrer" class="dropdown-item view-link">
|
||||
<a
|
||||
href={viewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="dropdown-item view-link"
|
||||
>
|
||||
View on site
|
||||
</a>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { createEventDispatcher } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import Modal from './Modal.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import CaseStudyEditor from './CaseStudyEditor.svelte'
|
||||
import AdminSegmentedControl from './AdminSegmentedControl.svelte'
|
||||
import FormFieldWrapper from './FormFieldWrapper.svelte'
|
||||
import Button from './Button.svelte'
|
||||
|
|
@ -288,7 +288,7 @@
|
|||
</div>
|
||||
|
||||
<div class="composer-body">
|
||||
<Editor
|
||||
<CaseStudyEditor
|
||||
bind:this={editorInstance}
|
||||
bind:data={content}
|
||||
onChange={(newContent) => {
|
||||
|
|
@ -296,11 +296,10 @@
|
|||
characterCount = getTextFromContent(newContent)
|
||||
}}
|
||||
placeholder="What's on your mind?"
|
||||
simpleMode={true}
|
||||
autofocus={true}
|
||||
minHeight={80}
|
||||
autofocus={true}
|
||||
mode="inline"
|
||||
showToolbar={false}
|
||||
class="composer-editor"
|
||||
/>
|
||||
|
||||
{#if attachedPhotos.length > 0}
|
||||
|
|
@ -440,7 +439,7 @@
|
|||
</div>
|
||||
{:else}
|
||||
<div class="content-section">
|
||||
<Editor
|
||||
<CaseStudyEditor
|
||||
bind:this={editorInstance}
|
||||
bind:data={content}
|
||||
onChange={(newContent) => {
|
||||
|
|
@ -448,9 +447,9 @@
|
|||
characterCount = getTextFromContent(newContent)
|
||||
}}
|
||||
placeholder="Start writing your essay..."
|
||||
simpleMode={false}
|
||||
autofocus={true}
|
||||
minHeight={500}
|
||||
autofocus={true}
|
||||
mode="default"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -484,7 +483,7 @@
|
|||
</svg>
|
||||
</Button>
|
||||
<div class="composer-body">
|
||||
<Editor
|
||||
<CaseStudyEditor
|
||||
bind:this={editorInstance}
|
||||
bind:data={content}
|
||||
onChange={(newContent) => {
|
||||
|
|
@ -492,11 +491,10 @@
|
|||
characterCount = getTextFromContent(newContent)
|
||||
}}
|
||||
placeholder="What's on your mind?"
|
||||
simpleMode={true}
|
||||
autofocus={true}
|
||||
minHeight={120}
|
||||
autofocus={true}
|
||||
mode="inline"
|
||||
showToolbar={false}
|
||||
class="inline-composer-editor"
|
||||
/>
|
||||
|
||||
{#if attachedPhotos.length > 0}
|
||||
|
|
@ -651,47 +649,6 @@
|
|||
.composer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global(.edra-editor) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.composer-editor) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
:global(.editor-container) {
|
||||
padding: 0 $unit-3x;
|
||||
}
|
||||
|
||||
:global(.editor-content) {
|
||||
padding: 0;
|
||||
min-height: 80px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:global(.ProseMirror) {
|
||||
padding: 0;
|
||||
min-height: 80px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.ProseMirror-focused .is-editor-empty:first-child::before {
|
||||
color: $grey-40;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.link-fields {
|
||||
|
|
@ -790,10 +747,6 @@
|
|||
.composer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:global(.edra-editor) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -811,44 +764,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
:global(.inline-composer-editor) {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
|
||||
:global(.editor-container) {
|
||||
padding: $unit * 1.5 $unit-3x 0;
|
||||
}
|
||||
|
||||
:global(.editor-content) {
|
||||
padding: 0;
|
||||
min-height: 120px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:global(.ProseMirror) {
|
||||
padding: 0;
|
||||
min-height: 120px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&.ProseMirror-focused .is-editor-empty:first-child::before {
|
||||
color: $grey-40;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.inline-composer .link-fields {
|
||||
padding: 0 $unit-3x;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
const isAdminRoute = $derived($page.url.pathname.startsWith('/admin'))
|
||||
|
||||
// Generate person structured data for the site
|
||||
const personJsonLd = $derived(generatePersonJsonLd({
|
||||
const personJsonLd = $derived(
|
||||
generatePersonJsonLd({
|
||||
name: 'Justin Edmund',
|
||||
jobTitle: 'Software Designer',
|
||||
description: 'Software designer based in San Francisco',
|
||||
|
|
@ -17,7 +18,8 @@
|
|||
'https://github.com/jedmund',
|
||||
'https://www.linkedin.com/in/jedmund'
|
||||
]
|
||||
}))
|
||||
})
|
||||
)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
|
|
|||
|
|
@ -495,7 +495,7 @@
|
|||
try {
|
||||
console.log('[Album Edit] handleGalleryAdd called:', {
|
||||
newPhotosCount: newPhotos.length,
|
||||
newPhotos: newPhotos.map(p => ({
|
||||
newPhotos: newPhotos.map((p) => ({
|
||||
id: p.id,
|
||||
mediaId: p.mediaId,
|
||||
filename: p.filename,
|
||||
|
|
@ -696,7 +696,9 @@
|
|||
onRemove={handleGalleryRemove}
|
||||
showBrowseLibrary={true}
|
||||
placeholder="Add photos to this album by uploading or selecting from your media library"
|
||||
helpText={isManagingPhotos ? "Processing photos..." : "Drag photos to reorder them. Click on photos to edit metadata."}
|
||||
helpText={isManagingPhotos
|
||||
? 'Processing photos...'
|
||||
: 'Drag photos to reorder them. Click on photos to edit metadata.'}
|
||||
disabled={isManagingPhotos}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -818,7 +820,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
|
|
|||
|
|
@ -480,7 +480,9 @@
|
|||
Alt
|
||||
</span>
|
||||
{:else}
|
||||
<span class="indicator-pill no-alt-text" title="No description"> No Alt </span>
|
||||
<span class="indicator-pill no-alt-text" title="No description">
|
||||
No Alt
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="filesize">{formatFileSize(item.size)}</span>
|
||||
|
|
|
|||
|
|
@ -146,7 +146,6 @@
|
|||
</header>
|
||||
|
||||
<div class="upload-container">
|
||||
|
||||
<!-- File List -->
|
||||
{#if files.length > 0}
|
||||
<div class="file-list">
|
||||
|
|
@ -160,7 +159,9 @@
|
|||
disabled={isUploading || files.length === 0}
|
||||
loading={isUploading}
|
||||
>
|
||||
{isUploading ? 'Uploading...' : `Upload ${files.length} File${files.length !== 1 ? 's' : ''}`}
|
||||
{isUploading
|
||||
? 'Uploading...'
|
||||
: `Upload ${files.length} File${files.length !== 1 ? 's' : ''}`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -169,7 +170,15 @@
|
|||
disabled={isUploading}
|
||||
title="Clear all files"
|
||||
>
|
||||
<svg slot="icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
slot="icon"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="8" y1="8" x2="16" y2="16"></line>
|
||||
<line x1="16" y1="8" x2="8" y2="16"></line>
|
||||
|
|
@ -195,13 +204,18 @@
|
|||
|
||||
{#if isUploading}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {uploadProgress[file.name] || 0}%"></div>
|
||||
<div
|
||||
class="progress-fill"
|
||||
style="width: {uploadProgress[file.name] || 0}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="upload-status">
|
||||
{#if uploadProgress[file.name] === 100}
|
||||
<span class="status-complete">✓ Complete</span>
|
||||
{:else if uploadProgress[file.name] > 0}
|
||||
<span class="status-uploading">{Math.round(uploadProgress[file.name] || 0)}%</span>
|
||||
<span class="status-uploading"
|
||||
>{Math.round(uploadProgress[file.name] || 0)}%</span
|
||||
>
|
||||
{:else}
|
||||
<span class="status-waiting">Waiting...</span>
|
||||
{/if}
|
||||
|
|
@ -312,8 +326,24 @@
|
|||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<line
|
||||
x1="12"
|
||||
y1="5"
|
||||
x2="12"
|
||||
y2="19"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="5"
|
||||
y1="12"
|
||||
x2="19"
|
||||
y2="12"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>Add more files or drop them here</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { goto } from '$app/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Editor from '$lib/components/admin/Editor.svelte'
|
||||
import CaseStudyEditor from '$lib/components/admin/CaseStudyEditor.svelte'
|
||||
import LoadingSpinner from '$lib/components/admin/LoadingSpinner.svelte'
|
||||
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
|
||||
import DeleteConfirmationModal from '$lib/components/admin/DeleteConfirmationModal.svelte'
|
||||
|
|
@ -135,7 +135,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
onMount(async () => {
|
||||
// Wait a tick to ensure page params are loaded
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
|
@ -391,7 +390,7 @@
|
|||
|
||||
{#if config?.showContent && contentReady}
|
||||
<div class="editor-wrapper">
|
||||
<Editor bind:data={content} placeholder="Continue writing..." />
|
||||
<CaseStudyEditor bind:data={content} placeholder="Continue writing..." mode="default" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -490,7 +489,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + $unit);
|
||||
|
|
@ -533,13 +531,13 @@
|
|||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
gap: $unit-2x;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: 0 $unit-2x;
|
||||
padding: 0 $unit-4x;
|
||||
border: none;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { goto } from '$app/navigation'
|
||||
import { onMount } from 'svelte'
|
||||
import AdminPage from '$lib/components/admin/AdminPage.svelte'
|
||||
import Editor from '$lib/components/admin/Editor.svelte'
|
||||
import CaseStudyEditor from '$lib/components/admin/CaseStudyEditor.svelte'
|
||||
import PostMetadataPopover from '$lib/components/admin/PostMetadataPopover.svelte'
|
||||
import Button from '$lib/components/admin/Button.svelte'
|
||||
import PublishDropdown from '$lib/components/admin/PublishDropdown.svelte'
|
||||
|
|
@ -195,7 +195,7 @@
|
|||
|
||||
{#if config?.showContent}
|
||||
<div class="editor-wrapper">
|
||||
<Editor bind:data={content} placeholder="Start writing..." />
|
||||
<CaseStudyEditor bind:data={content} placeholder="Start writing..." mode="default" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -314,13 +314,13 @@
|
|||
.main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $unit-3x;
|
||||
gap: $unit-2x;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
padding: 0 $unit-2x;
|
||||
padding: 0 $unit-4x;
|
||||
border: none;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
|
|
|
|||
|
|
@ -74,16 +74,16 @@ async function extractExifData(file: File) {
|
|||
|
||||
if (exif.ExposureTime) {
|
||||
formattedExif.shutterSpeed =
|
||||
exif.ExposureTime < 1
|
||||
? `1/${Math.round(1 / exif.ExposureTime)}`
|
||||
: `${exif.ExposureTime}s`
|
||||
exif.ExposureTime < 1 ? `1/${Math.round(1 / exif.ExposureTime)}` : `${exif.ExposureTime}s`
|
||||
}
|
||||
|
||||
if (exif.ISO) {
|
||||
formattedExif.iso = `ISO ${exif.ISO}`
|
||||
} else if (exif.ISOSpeedRatings) {
|
||||
// Handle alternative ISO field
|
||||
const iso = Array.isArray(exif.ISOSpeedRatings) ? exif.ISOSpeedRatings[0] : exif.ISOSpeedRatings
|
||||
const iso = Array.isArray(exif.ISOSpeedRatings)
|
||||
? exif.ISOSpeedRatings[0]
|
||||
: exif.ISOSpeedRatings
|
||||
formattedExif.iso = `ISO ${iso}`
|
||||
}
|
||||
|
||||
|
|
@ -105,7 +105,8 @@ async function extractExifData(file: File) {
|
|||
|
||||
// Additional metadata
|
||||
if (exif.Orientation) {
|
||||
formattedExif.orientation = exif.Orientation === 1 ? 'Horizontal (normal)' : `Rotated (${exif.Orientation})`
|
||||
formattedExif.orientation =
|
||||
exif.Orientation === 1 ? 'Horizontal (normal)' : `Rotated (${exif.Orientation})`
|
||||
}
|
||||
|
||||
if (exif.ColorSpace) {
|
||||
|
|
|
|||
|
|
@ -60,7 +60,8 @@ export const GET: RequestHandler = async (event) => {
|
|||
|
||||
// Get navigation info
|
||||
const prevMedia = albumMediaIndex > 0 ? album.media[albumMediaIndex - 1].media : null
|
||||
const nextMedia = albumMediaIndex < album.media.length - 1 ? album.media[albumMediaIndex + 1].media : null
|
||||
const nextMedia =
|
||||
albumMediaIndex < album.media.length - 1 ? album.media[albumMediaIndex + 1].media : null
|
||||
|
||||
// Transform to photo format for compatibility
|
||||
const photo = {
|
||||
|
|
|
|||
|
|
@ -169,9 +169,11 @@ export const PUT: RequestHandler = async (event) => {
|
|||
data: {
|
||||
photoCaption: body.caption !== undefined ? body.caption : existing.photoCaption,
|
||||
photoTitle: body.title !== undefined ? body.title : existing.photoTitle,
|
||||
photoDescription: body.description !== undefined ? body.description : existing.photoDescription,
|
||||
photoDescription:
|
||||
body.description !== undefined ? body.description : existing.photoDescription,
|
||||
isPhotography: body.showInPhotos !== undefined ? body.showInPhotos : existing.isPhotography,
|
||||
photoPublishedAt: body.publishedAt !== undefined ? body.publishedAt : existing.photoPublishedAt
|
||||
photoPublishedAt:
|
||||
body.publishedAt !== undefined ? body.publishedAt : existing.photoPublishedAt
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -172,12 +172,14 @@ export const GET: RequestHandler = async () => {
|
|||
totalPublishedPhotos: publishedPhotos.length,
|
||||
totalPhotosNoAlbum: allPhotosNoAlbum.length,
|
||||
totalPhotosInDatabase: allPhotos.length,
|
||||
photosByStatus: photosByStatus.map(item => ({
|
||||
photosByStatus: photosByStatus.map((item) => ({
|
||||
status: item.status,
|
||||
count: item._count.id
|
||||
})),
|
||||
photosWithShowInPhotosFlag: allPhotos.filter(p => p.showInPhotos).length,
|
||||
photosByFilename: allPhotos.filter(p => p.filename?.includes('B0000057')).map(p => ({
|
||||
photosWithShowInPhotosFlag: allPhotos.filter((p) => p.showInPhotos).length,
|
||||
photosByFilename: allPhotos
|
||||
.filter((p) => p.filename?.includes('B0000057'))
|
||||
.map((p) => ({
|
||||
filename: p.filename,
|
||||
showInPhotos: p.showInPhotos,
|
||||
status: p.status,
|
||||
|
|
@ -187,7 +189,9 @@ export const GET: RequestHandler = async () => {
|
|||
},
|
||||
albums: {
|
||||
totalAlbums: allAlbums.length,
|
||||
photographyAlbums: allAlbums.filter(a => a.isPhotography).map(a => ({
|
||||
photographyAlbums: allAlbums
|
||||
.filter((a) => a.isPhotography)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
slug: a.slug,
|
||||
|
|
@ -195,7 +199,9 @@ export const GET: RequestHandler = async () => {
|
|||
status: a.status,
|
||||
photoCount: a._count.photos
|
||||
})),
|
||||
nonPhotographyAlbums: allAlbums.filter(a => !a.isPhotography).map(a => ({
|
||||
nonPhotographyAlbums: allAlbums
|
||||
.filter((a) => !a.isPhotography)
|
||||
.map((a) => ({
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
slug: a.slug,
|
||||
|
|
@ -203,7 +209,8 @@ export const GET: RequestHandler = async () => {
|
|||
status: a.status,
|
||||
photoCount: a._count.photos
|
||||
})),
|
||||
albumFive: albumFive ? {
|
||||
albumFive: albumFive
|
||||
? {
|
||||
id: albumFive.id,
|
||||
title: albumFive.title,
|
||||
slug: albumFive.slug,
|
||||
|
|
@ -212,7 +219,8 @@ export const GET: RequestHandler = async () => {
|
|||
publishedAt: albumFive.publishedAt,
|
||||
photoCount: albumFive.photos.length,
|
||||
photos: albumFive.photos
|
||||
} : null,
|
||||
}
|
||||
: null,
|
||||
photosFromPhotographyAlbums: photosFromPhotographyAlbums.length,
|
||||
photosFromPhotographyAlbumsSample: photosFromPhotographyAlbums.slice(0, 5)
|
||||
},
|
||||
|
|
@ -227,7 +235,9 @@ export const GET: RequestHandler = async () => {
|
|||
debug: {
|
||||
expectedQuery: 'WHERE status = "published" AND showInPhotos = true AND albumId = null',
|
||||
actualPhotosEndpointQuery: '/api/photos uses this exact query',
|
||||
albumsWithPhotographyFlagTrue: allAlbums.filter(a => a.isPhotography).map(a => `${a.id}: ${a.title}`)
|
||||
albumsWithPhotographyFlagTrue: allAlbums
|
||||
.filter((a) => a.isPhotography)
|
||||
.map((a) => `${a.id}: ${a.title}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -236,6 +246,9 @@ export const GET: RequestHandler = async () => {
|
|||
return jsonResponse(response)
|
||||
} catch (error) {
|
||||
logger.error('Failed to run test photos query', error as Error)
|
||||
return errorResponse(`Failed to run test query: ${error instanceof Error ? error.message : 'Unknown error'}`, 500)
|
||||
return errorResponse(
|
||||
`Failed to run test query: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
500
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -23,21 +23,26 @@
|
|||
let isHoveringRight = $state(false)
|
||||
|
||||
// Spring stores for smooth button movement
|
||||
const leftButtonCoords = spring({ x: 0, y: 0 }, {
|
||||
const leftButtonCoords = spring(
|
||||
{ x: 0, y: 0 },
|
||||
{
|
||||
stiffness: 0.3,
|
||||
damping: 0.8
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const rightButtonCoords = spring({ x: 0, y: 0 }, {
|
||||
const rightButtonCoords = spring(
|
||||
{ x: 0, y: 0 },
|
||||
{
|
||||
stiffness: 0.3,
|
||||
damping: 0.8
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Default button positions (will be set once photo loads)
|
||||
let defaultLeftX = 0
|
||||
let defaultRightX = 0
|
||||
|
||||
|
||||
const pageUrl = $derived($page.url.href)
|
||||
|
||||
// Parse EXIF data if available
|
||||
|
|
@ -100,11 +105,11 @@
|
|||
|
||||
// Calculate default positions relative to the image
|
||||
// Add 24px (half button width) since we're using translate(-50%, -50%)
|
||||
defaultLeftX = (imageRect.left - pageRect.left) - 24 - 16 // half button width + gap
|
||||
defaultRightX = (imageRect.right - pageRect.left) + 24 + 16 // half button width + gap
|
||||
defaultLeftX = imageRect.left - pageRect.left - 24 - 16 // half button width + gap
|
||||
defaultRightX = imageRect.right - pageRect.left + 24 + 16 // half button width + gap
|
||||
|
||||
// Set initial positions at the vertical center of the image
|
||||
const centerY = (imageRect.top - pageRect.top) + (imageRect.height / 2)
|
||||
const centerY = imageRect.top - pageRect.top + imageRect.height / 2
|
||||
leftButtonCoords.set({ x: defaultLeftX, y: centerY }, { hard: true })
|
||||
rightButtonCoords.set({ x: defaultRightX, y: centerY }, { hard: true })
|
||||
|
||||
|
|
@ -123,7 +128,11 @@
|
|||
})
|
||||
|
||||
// Check mouse position on load
|
||||
function checkInitialMousePosition(pageContainer: HTMLElement, imageRect: DOMRect, pageRect: DOMRect) {
|
||||
function checkInitialMousePosition(
|
||||
pageContainer: HTMLElement,
|
||||
imageRect: DOMRect,
|
||||
pageRect: DOMRect
|
||||
) {
|
||||
// Get current mouse position from store
|
||||
const currentPos = getCurrentMousePosition()
|
||||
|
||||
|
|
@ -228,7 +237,7 @@
|
|||
isHoveringRight = x > photoRect.right
|
||||
|
||||
// Calculate image center Y position
|
||||
const imageCenterY = (photoRect.top - pageRect.top) + (photoRect.height / 2)
|
||||
const imageCenterY = photoRect.top - pageRect.top + photoRect.height / 2
|
||||
|
||||
// Update button positions
|
||||
if (isHoveringLeft) {
|
||||
|
|
@ -257,7 +266,7 @@
|
|||
if (photoImage && pageContainer) {
|
||||
const imageRect = photoImage.getBoundingClientRect()
|
||||
const pageRect = pageContainer.getBoundingClientRect()
|
||||
const centerY = (imageRect.top - pageRect.top) + (imageRect.height / 2)
|
||||
const centerY = imageRect.top - pageRect.top + imageRect.height / 2
|
||||
|
||||
leftButtonCoords.set({ x: defaultLeftX, y: centerY })
|
||||
rightButtonCoords.set({ x: defaultRightX, y: centerY })
|
||||
|
|
@ -322,18 +331,9 @@
|
|||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="photo-page"
|
||||
onmousemove={handleMouseMove}
|
||||
onmouseleave={handleMouseLeave}
|
||||
>
|
||||
<div class="photo-page" onmousemove={handleMouseMove} onmouseleave={handleMouseLeave}>
|
||||
<div class="photo-content-wrapper">
|
||||
<PhotoView
|
||||
src={photo.url}
|
||||
alt={photo.caption}
|
||||
title={photo.title}
|
||||
id={photo.id}
|
||||
/>
|
||||
<PhotoView src={photo.url} alt={photo.caption} title={photo.title} id={photo.id} />
|
||||
</div>
|
||||
|
||||
<!-- Adjacent Photos Navigation -->
|
||||
|
|
@ -480,7 +480,9 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-95;
|
||||
|
|
|
|||
|
|
@ -25,15 +25,21 @@
|
|||
let isHoveringRight = $state(false)
|
||||
|
||||
// Spring stores for smooth button movement
|
||||
const leftButtonCoords = spring({ x: 0, y: 0 }, {
|
||||
const leftButtonCoords = spring(
|
||||
{ x: 0, y: 0 },
|
||||
{
|
||||
stiffness: 0.3,
|
||||
damping: 0.8
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const rightButtonCoords = spring({ x: 0, y: 0 }, {
|
||||
const rightButtonCoords = spring(
|
||||
{ x: 0, y: 0 },
|
||||
{
|
||||
stiffness: 0.3,
|
||||
damping: 0.8
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Default button positions (will be set once photo loads)
|
||||
let defaultLeftX = 0
|
||||
|
|
@ -131,11 +137,11 @@
|
|||
|
||||
// Calculate default positions relative to the image
|
||||
// Add 24px (half button width) since we're using translate(-50%, -50%)
|
||||
defaultLeftX = (imageRect.left - pageRect.left) - 24 - 16 // half button width + gap
|
||||
defaultRightX = (imageRect.right - pageRect.left) + 24 + 16 // half button width + gap
|
||||
defaultLeftX = imageRect.left - pageRect.left - 24 - 16 // half button width + gap
|
||||
defaultRightX = imageRect.right - pageRect.left + 24 + 16 // half button width + gap
|
||||
|
||||
// Set initial positions at the vertical center of the image
|
||||
const centerY = (imageRect.top - pageRect.top) + (imageRect.height / 2)
|
||||
const centerY = imageRect.top - pageRect.top + imageRect.height / 2
|
||||
leftButtonCoords.set({ x: defaultLeftX, y: centerY }, { hard: true })
|
||||
rightButtonCoords.set({ x: defaultRightX, y: centerY }, { hard: true })
|
||||
|
||||
|
|
@ -154,7 +160,11 @@
|
|||
})
|
||||
|
||||
// Check mouse position on load
|
||||
function checkInitialMousePosition(pageContainer: HTMLElement, imageRect: DOMRect, pageRect: DOMRect) {
|
||||
function checkInitialMousePosition(
|
||||
pageContainer: HTMLElement,
|
||||
imageRect: DOMRect,
|
||||
pageRect: DOMRect
|
||||
) {
|
||||
// Get current mouse position from store
|
||||
const currentPos = getCurrentMousePosition()
|
||||
|
||||
|
|
@ -263,7 +273,7 @@
|
|||
isHoveringRight = x > photoRect.right
|
||||
|
||||
// Calculate image center Y position
|
||||
const imageCenterY = (photoRect.top - pageRect.top) + (photoRect.height / 2)
|
||||
const imageCenterY = photoRect.top - pageRect.top + photoRect.height / 2
|
||||
|
||||
// Update button positions
|
||||
if (isHoveringLeft) {
|
||||
|
|
@ -292,7 +302,7 @@
|
|||
if (photoImage && pageContainer) {
|
||||
const imageRect = photoImage.getBoundingClientRect()
|
||||
const pageRect = pageContainer.getBoundingClientRect()
|
||||
const centerY = (imageRect.top - pageRect.top) + (imageRect.height / 2)
|
||||
const centerY = imageRect.top - pageRect.top + imageRect.height / 2
|
||||
|
||||
leftButtonCoords.set({ x: defaultLeftX, y: centerY })
|
||||
rightButtonCoords.set({ x: defaultRightX, y: centerY })
|
||||
|
|
@ -343,18 +353,9 @@
|
|||
</div>
|
||||
</div>
|
||||
{:else if photo}
|
||||
<div
|
||||
class="photo-page"
|
||||
onmousemove={handleMouseMove}
|
||||
onmouseleave={handleMouseLeave}
|
||||
>
|
||||
<div class="photo-page" onmousemove={handleMouseMove} onmouseleave={handleMouseLeave}>
|
||||
<div class="photo-content-wrapper">
|
||||
<PhotoView
|
||||
src={photo.url}
|
||||
alt={photo.caption}
|
||||
title={photo.title}
|
||||
id={photo.id}
|
||||
/>
|
||||
<PhotoView src={photo.url} alt={photo.caption} title={photo.title} id={photo.id} />
|
||||
</div>
|
||||
|
||||
<!-- Adjacent Photos Navigation -->
|
||||
|
|
@ -468,7 +469,6 @@
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
|
||||
// Adjacent Navigation
|
||||
.adjacent-navigation {
|
||||
position: absolute;
|
||||
|
|
@ -501,7 +501,9 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: $grey-95;
|
||||
|
|
|
|||
Loading…
Reference in a new issue