Unify fullscreen editors

This commit is contained in:
Justin Edmund 2025-06-13 14:17:26 -04:00
parent f1ab953b89
commit f753d5fb8b
36 changed files with 705 additions and 504 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -167,7 +167,6 @@
}
}
function handleCancel() {
if (hasChanges() && !confirm('Are you sure you want to cancel? Your changes will be lost.')) {
return

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -336,7 +336,7 @@
.title-input {
width: 100%;
padding: $unit-3x;
padding: $unit-4x;
border: none;
background: transparent;
font-size: 1rem;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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