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`
@ -38,18 +40,19 @@ import Vibrant from 'node-vibrant'
// Extract colors from uploaded image
const palette = await Vibrant.from(buffer).getPalette()
const dominantColors = {
vibrant: palette.Vibrant?.hex,
darkVibrant: palette.DarkVibrant?.hex,
lightVibrant: palette.LightVibrant?.hex,
muted: palette.Muted?.hex,
darkMuted: palette.DarkMuted?.hex,
lightMuted: palette.LightMuted?.hex
vibrant: palette.Vibrant?.hex,
darkVibrant: palette.DarkVibrant?.hex,
lightVibrant: palette.LightVibrant?.hex,
muted: palette.Muted?.hex,
darkMuted: palette.DarkMuted?.hex,
lightMuted: palette.LightMuted?.hex
}
```
## 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
@ -74,19 +78,19 @@ Update the upload handler to extract colors:
```typescript
// After successful upload to Cloudinary
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
const buffer = await file.arrayBuffer()
// Extract EXIF data (existing)
const exifData = await extractExifData(file)
// Extract dominant colors (new)
const colorData = await extractDominantColors(buffer)
// Combine data
const metadata = {
...exifData,
colors: colorData
}
const buffer = await file.arrayBuffer()
// Extract EXIF data (existing)
const exifData = await extractExifData(file)
// Extract dominant colors (new)
const colorData = await extractDominantColors(buffer)
// Combine data
const metadata = {
...exifData,
colors: colorData
}
}
```
@ -94,36 +98,40 @@ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
```json
{
"id": 123,
"url": "...",
"dominantColors": {
"vibrant": "#4285f4",
"darkVibrant": "#1a73e8",
"lightVibrant": "#8ab4f8",
"muted": "#5f6368",
"darkMuted": "#3c4043",
"lightMuted": "#e8eaed"
}
"id": 123,
"url": "...",
"dominantColors": {
"vibrant": "#4285f4",
"darkVibrant": "#1a73e8",
"lightVibrant": "#8ab4f8",
"muted": "#5f6368",
"darkMuted": "#3c4043",
"lightMuted": "#e8eaed"
}
}
```
## 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.)
@ -184,4 +196,4 @@ if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
- Total effort: 2-3 days
- Can be implemented incrementally
- No breaking changes to existing functionality
- No breaking changes to existing functionality

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,8 +33,9 @@ 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**:
- **Data needed**:
- Project logo URL (`logoUrl`)
- Brand background color (`backgroundColor`)
- Avatar image (use existing `src/assets/illos/jedmund.svg`)
@ -41,9 +44,10 @@ 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**:
- **Styling**:
- 32px padding on all edges
- Universe icon (24x24) + 8px gap + "Universe" label (smaller font)
- Essay title below (larger font, max 2 lines with ellipsis)
@ -54,9 +58,10 @@ 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**:
- **Styling**:
- 32px padding on all edges
- Labs icon (24x24) + 8px gap + "Labs" label (smaller font)
- Project title below (larger font, max 2 lines with ellipsis)
@ -67,16 +72,18 @@ 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**:
- **Styling**:
- Photo scaled to fit within 1200x630 bounds
- Avatar (48x48) in bottom right corner
- **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**:
- **Styling**:
- First photo as blurred background (using CSS filter or canvas blur)
- 32px padding on all edges
- Photos icon (24x24) + 8px gap + "Photos" label (smaller font)
@ -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
@ -185,18 +199,18 @@ This PRD outlines the implementation of a comprehensive OpenGraph image generati
```typescript
function generateOgImageId(type: string, data: any): string {
const content = {
type,
// Include only content that affects the image
...(type === 'work' && { title: data.title, logo: data.logoUrl, bg: data.backgroundColor }),
...(type === 'essay' && { title: data.title }),
...(type === 'labs' && { title: data.title }),
...(type === 'photo' && { url: data.url }),
...(type === 'album' && { title: data.title, firstPhoto: data.photos[0].src })
}
const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex').slice(0, 8)
return `og-${type}-${hash}`
const content = {
type,
// Include only content that affects the image
...(type === 'work' && { title: data.title, logo: data.logoUrl, bg: data.backgroundColor }),
...(type === 'essay' && { title: data.title }),
...(type === 'labs' && { title: data.title }),
...(type === 'photo' && { url: data.url }),
...(type === 'album' && { title: data.title, firstPhoto: data.photos[0].src })
}
const hash = createHash('sha256').update(JSON.stringify(content)).digest('hex').slice(0, 8)
return `og-${type}-${hash}`
}
```
@ -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**:
**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,18 +444,21 @@ 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
- Labs icon SVG
- Photos icon SVG
- 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)
- 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) => {
const albumTitle = photo.album?.title || 'Unknown'
acc[albumTitle] = (acc[albumTitle] || 0) + 1
return acc
}, {} as Record<string, number>)
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>
)
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) => {
acc[photo.status] = (acc[photo.status] || 0) + 1
return acc
}, {} as Record<string, number>)
const statusCounts = allPhotos.reduce(
(acc, photo) => {
acc[photo.status] = (acc[photo.status] || 0) + 1
return acc
},
{} 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 {
@ -110,4 +119,4 @@ async function checkPhotosDisplay() {
}
}
checkPhotosDisplay()
checkPhotosDisplay()

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
@ -39,4 +43,4 @@ This script will show:
- Photos might be in 'draft' status instead of 'published'
- Photos might have showInPhotos=false
- Photos might be associated with an album (albumId is not null)
- Photos might be associated with an album (albumId is not null)

View file

@ -59,7 +59,7 @@ async function main() {
}
})
createdMediaCount++
// Update the photo to reference the new media
await prisma.photo.update({
where: { id: photo.id },
@ -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({
where: { id: photo.id },
select: { mediaId: true }
}))?.mediaId
const mediaId =
photo.mediaId ||
(
await prisma.photo.findUnique({
where: { id: photo.id },
select: { mediaId: true }
})
)?.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)
@ -130,4 +133,4 @@ async function main() {
}
}
main()
main()

View file

@ -133,7 +133,7 @@ async function testMediaSharing() {
console.log(`✓ Album 1 has ${verifyAlbum1?.photos.length} photo(s)`)
console.log(` - Photo mediaId: ${verifyAlbum1?.photos[0]?.mediaId}`)
console.log(` - Media filename: ${verifyAlbum1?.photos[0]?.media?.filename}`)
console.log(`✓ Album 2 has ${verifyAlbum2?.photos.length} photo(s)`)
console.log(` - Photo mediaId: ${verifyAlbum2?.photos[0]?.mediaId}`)
console.log(` - Media filename: ${verifyAlbum2?.photos[0]?.media?.filename}\n`)
@ -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)
@ -196,4 +195,4 @@ async function testMediaSharing() {
}
// Run the test
testMediaSharing()
testMediaSharing()

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 {
@ -103,4 +106,4 @@ async function testPhotoQueries() {
}
// Run the test
testPhotoQueries()
testPhotoQueries()

View file

@ -52,4 +52,4 @@
:global([data-smiz-btn-unzoom]) {
display: none !important;
}
</style>
</style>

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,10 +157,10 @@
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
// Only pass the newly uploaded media, not the entire gallery
onUpload(uploadedMedia)
@ -224,7 +224,6 @@
uploadError = null
}
// Drag and drop reordering handlers
function handleImageDragStart(event: DragEvent, index: number) {
// Prevent reordering while uploading or disabled
@ -232,12 +231,12 @@
event.preventDefault()
return
}
draggedIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
// Debug logging
console.log('[GalleryUploader] Drag start:', {
index,
@ -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],
@ -395,7 +394,7 @@
}
value = [...value] // Trigger reactivity
}
// Update selectedImage for the modal
selectedImage = updatedMedia
}
@ -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"
@ -760,7 +759,7 @@
&.disabled {
opacity: 0.6;
cursor: not-allowed;
&:hover {
border-color: $grey-80;
background-color: $grey-97;
@ -940,7 +939,7 @@
background: none;
cursor: pointer;
transition: transform 0.2s ease;
&:hover:not(:disabled) {
transform: scale(1.02);
}
@ -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

@ -43,13 +43,13 @@
if (isOpen) {
// Save current scroll position
const scrollY = window.scrollY
// Lock body scroll
document.body.style.position = 'fixed'
document.body.style.top = `-${scrollY}px`
document.body.style.width = '100%'
document.body.style.overflow = 'hidden'
return () => {
// Restore body scroll
const scrollY = document.body.style.top
@ -57,7 +57,7 @@
document.body.style.top = ''
document.body.style.width = ''
document.body.style.overflow = ''
// Restore scroll position
if (scrollY) {
window.scrollTo(0, parseInt(scrollY || '0') * -1)

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,18 +272,16 @@
</div>
<!-- Case Study Panel -->
<div class="panel case-study-wrapper" class:active={activeTab === 'case-study'}>
<div class="editor-content">
<Editor
bind:this={editorRef}
bind:data={formData.caseStudyContent}
onChange={handleEditorChange}
placeholder="Write your case study here..."
minHeight={400}
autofocus={false}
class="case-study-editor"
/>
</div>
<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}
mode="default"
/>
</div>
</div>
{/if}
@ -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

@ -9,7 +9,7 @@ if (typeof window !== 'undefined') {
window.addEventListener('mousemove', (e) => {
mousePosition.set({ x: e.clientX, y: e.clientY })
})
// Also capture initial position if mouse is already over window
document.addEventListener('DOMContentLoaded', () => {
// Force a mouse event to get initial position
@ -18,7 +18,7 @@ if (typeof window !== 'undefined') {
clientY: 0,
bubbles: true
})
// If the mouse is already over the document, this will update
document.dispatchEvent(event)
})
@ -27,4 +27,4 @@ if (typeof window !== 'undefined') {
// Helper function to get current mouse position
export function getCurrentMousePosition() {
return get(mousePosition)
}
}

View file

@ -7,17 +7,19 @@
const isAdminRoute = $derived($page.url.pathname.startsWith('/admin'))
// Generate person structured data for the site
const personJsonLd = $derived(generatePersonJsonLd({
name: 'Justin Edmund',
jobTitle: 'Software Designer',
description: 'Software designer based in San Francisco',
url: 'https://jedmund.com',
sameAs: [
'https://twitter.com/jedmund',
'https://github.com/jedmund',
'https://www.linkedin.com/in/jedmund'
]
}))
const personJsonLd = $derived(
generatePersonJsonLd({
name: 'Justin Edmund',
jobTitle: 'Software Designer',
description: 'Software designer based in San Francisco',
url: 'https://jedmund.com',
sameAs: [
'https://twitter.com/jedmund',
'https://github.com/jedmund',
'https://www.linkedin.com/in/jedmund'
]
})
)
</script>
<svelte:head>

View file

@ -394,22 +394,22 @@
try {
console.log('[Album Edit] handlePhotoReorder called:', {
reorderedCount: reorderedPhotos.length,
photos: reorderedPhotos.map((p, i) => ({
photos: reorderedPhotos.map((p, i) => ({
index: i,
id: p.id,
mediaId: p.mediaId,
filename: p.filename
id: p.id,
mediaId: p.mediaId,
filename: p.filename
}))
})
// Prevent concurrent reordering
if (isManagingPhotos) {
console.warn('[Album Edit] Skipping reorder - another operation in progress')
return
}
isManagingPhotos = true
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
@ -435,7 +435,7 @@
// Update local state only after successful API calls
albumPhotos = [...reorderedPhotos]
console.log('[Album Edit] Reorder completed successfully')
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to reorder photos'
@ -495,15 +495,15 @@
try {
console.log('[Album Edit] handleGalleryAdd called:', {
newPhotosCount: newPhotos.length,
newPhotos: newPhotos.map(p => ({
id: p.id,
mediaId: p.mediaId,
newPhotos: newPhotos.map((p) => ({
id: p.id,
mediaId: p.mediaId,
filename: p.filename,
isFile: p instanceof File
isFile: p instanceof File
})),
currentPhotosCount: albumPhotos.length
})
if (newPhotos.length > 0) {
// All items from GalleryUploader should be media objects, not Files
// They either come from uploads (already processed to Media) or library selections
@ -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>
@ -394,11 +424,11 @@
&.has-files {
padding: $unit-4x;
}
&.compact {
padding: $unit-3x;
min-height: auto;
.drop-zone-content {
.compact-content {
display: flex;
@ -407,7 +437,7 @@
gap: $unit-2x;
color: $grey-40;
font-size: 0.875rem;
.add-icon {
color: $grey-50;
}
@ -419,7 +449,7 @@
border-color: $grey-60;
background: $grey-90;
}
&.uploading {
border-color: #3b82f6;
border-style: solid;
@ -574,7 +604,7 @@
background: #3b82f6;
transition: width 0.3s ease;
position: relative;
&::after {
content: '';
position: absolute;
@ -592,7 +622,7 @@
}
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
@ -601,19 +631,19 @@
transform: translateX(100%);
}
}
.upload-status {
font-size: 0.75rem;
font-weight: 500;
.status-complete {
color: #16a34a;
}
.status-uploading {
color: #3b82f6;
}
.status-waiting {
color: $grey-50;
}

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

@ -12,17 +12,17 @@ async function extractExifData(file: File) {
size: file.size,
type: file.type
})
const buffer = await file.arrayBuffer()
logger.info(`Buffer created for ${file.name}`, { bufferSize: buffer.byteLength })
// Try parsing without pick first to see all available data
const fullExif = await exifr.parse(buffer)
logger.info(`Full EXIF data available for ${file.name}:`, {
hasData: !!fullExif,
availableFields: fullExif ? Object.keys(fullExif).slice(0, 10) : [] // First 10 fields
})
// Now parse with specific fields
const exif = await exifr.parse(buffer, {
pick: [
@ -43,7 +43,7 @@ async function extractExifData(file: File) {
]
})
logger.info(`EXIF parse result for ${file.name}:`, {
logger.info(`EXIF parse result for ${file.name}:`, {
hasExif: !!exif,
exifKeys: exif ? Object.keys(exif) : []
})
@ -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

@ -12,7 +12,7 @@ export const GET: RequestHandler = async (event) => {
try {
logger.info('Fetching photo', { mediaId: id })
const media = await prisma.media.findUnique({
where: { id },
include: {
@ -30,9 +30,9 @@ export const GET: RequestHandler = async (event) => {
logger.warn('Media not found', { mediaId: id })
return errorResponse('Photo not found', 404)
}
logger.info('Media found', {
mediaId: id,
logger.info('Media found', {
mediaId: id,
isPhotography: media.isPhotography,
albumCount: media.albums.length
})
@ -40,7 +40,7 @@ export const GET: RequestHandler = async (event) => {
// For public access, only return media marked as photography
const isAdminRequest = checkAdminAuth(event)
logger.info('Authorization check', { isAdmin: isAdminRequest })
if (!isAdminRequest) {
if (!media.isPhotography) {
logger.warn('Media not marked as photography', { mediaId: id })
@ -52,7 +52,7 @@ export const GET: RequestHandler = async (event) => {
const fullAlbum = await prisma.album.findUnique({
where: { id: album.id }
})
logger.info('Album check', {
logger.info('Album check', {
albumId: album.id,
status: fullAlbum?.status,
isPhotography: fullAlbum?.isPhotography
@ -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,47 +172,55 @@ 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 => ({
filename: p.filename,
showInPhotos: p.showInPhotos,
status: p.status,
albumId: p.albumId,
albumTitle: p.album?.title
}))
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,
albumId: p.albumId,
albumTitle: p.album?.title
}))
},
albums: {
totalAlbums: allAlbums.length,
photographyAlbums: allAlbums.filter(a => a.isPhotography).map(a => ({
id: a.id,
title: a.title,
slug: a.slug,
isPhotography: a.isPhotography,
status: a.status,
photoCount: a._count.photos
})),
nonPhotographyAlbums: allAlbums.filter(a => !a.isPhotography).map(a => ({
id: a.id,
title: a.title,
slug: a.slug,
isPhotography: a.isPhotography,
status: a.status,
photoCount: a._count.photos
})),
albumFive: albumFive ? {
id: albumFive.id,
title: albumFive.title,
slug: albumFive.slug,
isPhotography: albumFive.isPhotography,
status: albumFive.status,
publishedAt: albumFive.publishedAt,
photoCount: albumFive.photos.length,
photos: albumFive.photos
} : null,
photographyAlbums: allAlbums
.filter((a) => a.isPhotography)
.map((a) => ({
id: a.id,
title: a.title,
slug: a.slug,
isPhotography: a.isPhotography,
status: a.status,
photoCount: a._count.photos
})),
nonPhotographyAlbums: allAlbums
.filter((a) => !a.isPhotography)
.map((a) => ({
id: a.id,
title: a.title,
slug: a.slug,
isPhotography: a.isPhotography,
status: a.status,
photoCount: a._count.photos
})),
albumFive: albumFive
? {
id: albumFive.id,
title: albumFive.title,
slug: albumFive.slug,
isPhotography: albumFive.isPhotography,
status: albumFive.status,
publishedAt: albumFive.publishedAt,
photoCount: albumFive.photos.length,
photos: albumFive.photos
}
: 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

@ -21,25 +21,30 @@
// Hover tracking for arrow buttons
let isHoveringLeft = $state(false)
let isHoveringRight = $state(false)
// Spring stores for smooth button movement
const leftButtonCoords = spring({ x: 0, y: 0 }, {
stiffness: 0.3,
damping: 0.8
})
const rightButtonCoords = spring({ x: 0, y: 0 }, {
stiffness: 0.3,
damping: 0.8
})
const leftButtonCoords = spring(
{ x: 0, y: 0 },
{
stiffness: 0.3,
damping: 0.8
}
)
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
const exifData = $derived(
photo?.exifData && typeof photo.exifData === 'object' ? photo.exifData : null
@ -88,26 +93,26 @@
// Set default button positions when component mounts
$effect(() => {
if (!photo) return
// Wait for DOM to update and image to load
const checkAndSetPositions = () => {
const pageContainer = document.querySelector('.photo-page') as HTMLElement
const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement
if (photoImage && photoImage.complete) {
const imageRect = photoImage.getBoundingClientRect()
const pageRect = pageContainer.getBoundingClientRect()
// 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 })
// Check if mouse is already in a hover zone
// Small delay to ensure mouse store is initialized
setTimeout(() => {
@ -118,15 +123,19 @@
setTimeout(checkAndSetPositions, 50)
}
}
checkAndSetPositions()
})
// 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()
// If no mouse position tracked yet, try to trigger one
if (currentPos.x === 0 && currentPos.y === 0) {
// Set up a one-time listener for the first mouse move
@ -134,7 +143,7 @@
const x = e.clientX
const mouseX = e.clientX - pageRect.left
const mouseY = e.clientY - pageRect.top
// Check if mouse is in hover zones
if (x < imageRect.left) {
isHoveringLeft = true
@ -143,24 +152,24 @@
isHoveringRight = true
rightButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
}
// Remove the listener
window.removeEventListener('mousemove', handleFirstMove)
}
window.addEventListener('mousemove', handleFirstMove)
return
}
// We have a mouse position, check if it's in a hover zone
const x = currentPos.x
const mouseX = currentPos.x - pageRect.left
const mouseY = currentPos.y - pageRect.top
// Store client coordinates for scroll updates
lastClientX = currentPos.x
lastClientY = currentPos.y
// Check if mouse is in hover zones
if (x < imageRect.left) {
isHoveringLeft = true
@ -174,62 +183,62 @@
// Store last mouse client position for scroll updates
let lastClientX = 0
let lastClientY = 0
// Update button positions during scroll
function handleScroll() {
if (!isHoveringLeft && !isHoveringRight) return
const pageContainer = document.querySelector('.photo-page') as HTMLElement
if (!pageContainer) return
// Use last known mouse position (which is viewport-relative)
// and recalculate relative to the page container's new position
const pageRect = pageContainer.getBoundingClientRect()
const mouseX = lastClientX - pageRect.left
const mouseY = lastClientY - pageRect.top
// Update button positions
if (isHoveringLeft) {
leftButtonCoords.set({ x: mouseX, y: mouseY })
}
if (isHoveringRight) {
rightButtonCoords.set({ x: mouseX, y: mouseY })
}
}
// Mouse tracking for hover areas
function handleMouseMove(event: MouseEvent) {
const pageContainer = event.currentTarget as HTMLElement
const photoWrapper = pageContainer.querySelector('.photo-content-wrapper') as HTMLElement
if (!photoWrapper) return
// Get the actual image element inside PhotoView
const photoImage = photoWrapper.querySelector('img') as HTMLElement
if (!photoImage) return
const pageRect = pageContainer.getBoundingClientRect()
const photoRect = photoImage.getBoundingClientRect()
const x = event.clientX
const mouseX = event.clientX - pageRect.left
const mouseY = event.clientY - pageRect.top
// Store last mouse position for scroll updates
lastClientX = event.clientX
lastClientY = event.clientY
// Check if mouse is in the left or right margin (outside the photo)
const wasHoveringLeft = isHoveringLeft
const wasHoveringRight = isHoveringRight
isHoveringLeft = x < photoRect.left
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) {
leftButtonCoords.set({ x: mouseX, y: mouseY })
@ -237,7 +246,7 @@
// Reset left button to default
leftButtonCoords.set({ x: defaultLeftX, y: imageCenterY })
}
if (isHoveringRight) {
rightButtonCoords.set({ x: mouseX, y: mouseY })
} else if (wasHoveringRight && !isHoveringRight) {
@ -249,16 +258,16 @@
function handleMouseLeave() {
isHoveringLeft = false
isHoveringRight = false
// Reset buttons to default positions
const pageContainer = document.querySelector('.photo-page') as HTMLElement
const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement
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 })
}
@ -277,7 +286,7 @@
$effect(() => {
window.addEventListener('keydown', handleKeydown)
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('scroll', handleScroll)
@ -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,15 +480,17 @@
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;
}
&.hovering {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}

View file

@ -4,14 +4,14 @@ export const load: PageLoad = async ({ params, fetch }) => {
try {
const { albumSlug, photoId } = params
const mediaId = parseInt(photoId)
if (isNaN(mediaId)) {
throw new Error('Invalid photo ID')
}
// Fetch the photo and album data with navigation
const response = await fetch(`/api/photos/${albumSlug}/${mediaId}`)
if (!response.ok) {
if (response.status === 404) {
throw new Error('Photo or album not found')
@ -35,4 +35,4 @@ export const load: PageLoad = async ({ params, fetch }) => {
error: error instanceof Error ? error.message : 'Failed to load photo'
}
}
}
}

View file

@ -6,7 +6,7 @@ export const load: PageLoad = async ({ params, fetch }) => {
const albumResponse = await fetch(`/api/albums/by-slug/${params.slug}`)
if (albumResponse.ok) {
const album = await albumResponse.json()
// Check if this is a photography album and published
if (album.isPhotography && album.status === 'published') {
return {

View file

@ -23,18 +23,24 @@
// Hover tracking for arrow buttons
let isHoveringLeft = $state(false)
let isHoveringRight = $state(false)
// Spring stores for smooth button movement
const leftButtonCoords = spring({ x: 0, y: 0 }, {
stiffness: 0.3,
damping: 0.8
})
const rightButtonCoords = spring({ x: 0, y: 0 }, {
stiffness: 0.3,
damping: 0.8
})
const leftButtonCoords = spring(
{ x: 0, y: 0 },
{
stiffness: 0.3,
damping: 0.8
}
)
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
@ -119,26 +125,26 @@
// Set default button positions when component mounts
$effect(() => {
if (!photo) return
// Wait for DOM to update and image to load
const checkAndSetPositions = () => {
const pageContainer = document.querySelector('.photo-page') as HTMLElement
const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement
if (photoImage && photoImage.complete) {
const imageRect = photoImage.getBoundingClientRect()
const pageRect = pageContainer.getBoundingClientRect()
// 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 })
// Check if mouse is already in a hover zone
// Small delay to ensure mouse store is initialized
setTimeout(() => {
@ -149,15 +155,19 @@
setTimeout(checkAndSetPositions, 50)
}
}
checkAndSetPositions()
})
// 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()
// If no mouse position tracked yet, try to trigger one
if (currentPos.x === 0 && currentPos.y === 0) {
// Set up a one-time listener for the first mouse move
@ -165,7 +175,7 @@
const x = e.clientX
const mouseX = e.clientX - pageRect.left
const mouseY = e.clientY - pageRect.top
// Check if mouse is in hover zones
if (x < imageRect.left) {
isHoveringLeft = true
@ -174,24 +184,24 @@
isHoveringRight = true
rightButtonCoords.set({ x: mouseX, y: mouseY }, { hard: true })
}
// Remove the listener
window.removeEventListener('mousemove', handleFirstMove)
}
window.addEventListener('mousemove', handleFirstMove)
return
}
// We have a mouse position, check if it's in a hover zone
const x = currentPos.x
const mouseX = currentPos.x - pageRect.left
const mouseY = currentPos.y - pageRect.top
// Store client coordinates for scroll updates
lastClientX = currentPos.x
lastClientY = currentPos.y
// Check if mouse is in hover zones
if (x < imageRect.left) {
isHoveringLeft = true
@ -205,66 +215,66 @@
// Track last known mouse position for scroll updates
let lastMouseX = 0
let lastMouseY = 0
// Store last mouse client position for scroll updates
let lastClientX = 0
let lastClientY = 0
// Update button positions during scroll
function handleScroll() {
if (!isHoveringLeft && !isHoveringRight) return
const pageContainer = document.querySelector('.photo-page') as HTMLElement
if (!pageContainer) return
// Use last known mouse position (which is viewport-relative)
// and recalculate relative to the page container's new position
const pageRect = pageContainer.getBoundingClientRect()
const mouseX = lastClientX - pageRect.left
const mouseY = lastClientY - pageRect.top
// Update button positions
if (isHoveringLeft) {
leftButtonCoords.set({ x: mouseX, y: mouseY })
}
if (isHoveringRight) {
rightButtonCoords.set({ x: mouseX, y: mouseY })
}
}
// Mouse tracking for hover areas
function handleMouseMove(event: MouseEvent) {
const pageContainer = event.currentTarget as HTMLElement
const photoWrapper = pageContainer.querySelector('.photo-content-wrapper') as HTMLElement
if (!photoWrapper) return
// Get the actual image element inside PhotoView
const photoImage = photoWrapper.querySelector('img') as HTMLElement
if (!photoImage) return
const pageRect = pageContainer.getBoundingClientRect()
const photoRect = photoImage.getBoundingClientRect()
const x = event.clientX
const mouseX = event.clientX - pageRect.left
const mouseY = event.clientY - pageRect.top
// Store last mouse position for scroll updates
lastClientX = event.clientX
lastClientY = event.clientY
// Check if mouse is in the left or right margin (outside the photo)
const wasHoveringLeft = isHoveringLeft
const wasHoveringRight = isHoveringRight
isHoveringLeft = x < photoRect.left
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) {
leftButtonCoords.set({ x: mouseX, y: mouseY })
@ -272,7 +282,7 @@
// Reset left button to default
leftButtonCoords.set({ x: defaultLeftX, y: imageCenterY })
}
if (isHoveringRight) {
rightButtonCoords.set({ x: mouseX, y: mouseY })
} else if (wasHoveringRight && !isHoveringRight) {
@ -284,16 +294,16 @@
function handleMouseLeave() {
isHoveringLeft = false
isHoveringRight = false
// Reset buttons to default positions
const pageContainer = document.querySelector('.photo-page') as HTMLElement
const photoImage = pageContainer?.querySelector('.photo-content-wrapper img') as HTMLElement
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 })
}
@ -303,7 +313,7 @@
$effect(() => {
window.addEventListener('keydown', handleKeydown)
window.addEventListener('scroll', handleScroll)
return () => {
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('scroll', handleScroll)
@ -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,15 +501,17 @@
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;
}
&.hovering {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
&:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}

View file

@ -39,4 +39,4 @@ export const load: PageLoad = async ({ params, fetch }) => {
error: error instanceof Error ? error.message : 'Failed to load photo'
}
}
}
}

View file

@ -3,13 +3,13 @@ import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function testDb() {
try {
const count = await prisma.media.count()
console.log('Total media entries:', count)
await prisma.$disconnect()
} catch (error) {
console.error('Database error:', error)
}
try {
const count = await prisma.media.count()
console.log('Total media entries:', count)
await prisma.$disconnect()
} catch (error) {
console.error('Database error:', error)
}
}
testDb()
testDb()