Merge pull request #9 from jedmund/universe/photo-modes-2

Update photo implementation
This commit is contained in:
Justin Edmund 2025-06-18 18:02:17 -07:00 committed by GitHub
commit 6c3c76be6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 3380 additions and 90 deletions

1
.gitignore vendored
View file

@ -31,3 +31,4 @@ vite.config.ts.timestamp-*
*storybook.log
storybook-static
backups/

View file

@ -16,6 +16,11 @@
"db:studio": "prisma studio",
"db:init": "tsx scripts/init-db.ts",
"db:deploy": "prisma migrate deploy",
"db:backup:local": "./scripts/backup-db.sh local",
"db:backup:remote": "./scripts/backup-db.sh remote",
"db:backup:sync": "./scripts/backup-db.sh sync",
"db:restore": "./scripts/restore-db.sh",
"db:backups": "./scripts/list-backups.sh",
"setup:local": "./scripts/setup-local.sh",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"

View file

@ -0,0 +1,9 @@
-- Add color and aspect ratio fields to Media table
ALTER TABLE "public"."Media" ADD COLUMN IF NOT EXISTS "dominantColor" VARCHAR(7);
ALTER TABLE "public"."Media" ADD COLUMN IF NOT EXISTS "colors" JSONB;
ALTER TABLE "public"."Media" ADD COLUMN IF NOT EXISTS "aspectRatio" DOUBLE PRECISION;
-- Add color and aspect ratio fields to Photo table
ALTER TABLE "public"."Photo" ADD COLUMN IF NOT EXISTS "dominantColor" VARCHAR(7);
ALTER TABLE "public"."Photo" ADD COLUMN IF NOT EXISTS "colors" JSONB;
ALTER TABLE "public"."Photo" ADD COLUMN IF NOT EXISTS "aspectRatio" DOUBLE PRECISION;

View file

@ -93,6 +93,9 @@ model Photo {
thumbnailUrl String? @db.VarChar(500)
width Int?
height Int?
dominantColor String? @db.VarChar(7) // Hex color like #FFFFFF
colors Json? // Full color palette from Cloudinary
aspectRatio Float? // Width/height ratio
exifData Json?
caption String? @db.Text
displayOrder Int @default(0)
@ -127,6 +130,9 @@ model Media {
thumbnailUrl String? @db.Text
width Int?
height Int?
dominantColor String? @db.VarChar(7) // Hex color like #FFFFFF
colors Json? // Full color palette from Cloudinary
aspectRatio Float? // Width/height ratio
exifData Json? // EXIF data for photos
description String? @db.Text // Description (used for alt text and captions)
isPhotography Boolean @default(false) // Star for photos experience

135
scripts/README.md Normal file
View file

@ -0,0 +1,135 @@
# Database Backup Scripts
This directory contains scripts for backing up and restoring the PostgreSQL database.
## Prerequisites
- PostgreSQL client tools (`pg_dump`, `psql`) must be installed
- Environment variables must be set in `.env` or `.env.local`:
- `DATABASE_URL` - Local database connection string
- `REMOTE_DATABASE_URL` or `DATABASE_URL_PRODUCTION` - Remote database connection string
## Available Commands
### Backup Commands
```bash
# Backup local database
npm run db:backup:local
# Backup remote database
npm run db:backup:remote
# Sync remote database to local (backs up both, then restores remote to local)
npm run db:backup:sync
# List all backups
npm run db:backups
```
### Restore Commands
```bash
# Restore a specific backup (interactive - will show available backups)
npm run db:restore
# Restore to local database (default)
npm run db:restore ./backups/backup_file.sql.gz
# Restore to remote database (requires extra confirmation)
npm run db:restore ./backups/backup_file.sql.gz remote
```
### Direct Script Usage
You can also run the scripts directly:
```bash
# Backup operations
./scripts/backup-db.sh local
./scripts/backup-db.sh remote
./scripts/backup-db.sh sync
# Restore operations
./scripts/restore-db.sh <backup-file> [local|remote]
# List backups
./scripts/list-backups.sh [all|local|remote|recent]
```
## Backup Storage
All backups are stored in the `./backups/` directory with timestamps:
- Local backups: `local_YYYYMMDD_HHMMSS.sql.gz`
- Remote backups: `remote_YYYYMMDD_HHMMSS.sql.gz`
## Safety Features
1. **Automatic Backups**: The sync operation creates backups of both databases before syncing
2. **Confirmation Prompts**: Destructive operations require confirmation
3. **Extra Protection for Remote**: Restoring to remote requires typing "RESTORE REMOTE"
4. **Compressed Storage**: Backups are automatically compressed with gzip
5. **Timestamp Naming**: All backups include timestamps to prevent overwrites
## Common Use Cases
### Daily Local Development
```bash
# Start your day by syncing the remote database to local
npm run db:backup:sync
```
### Before Deploying Changes
```bash
# Backup remote database before deploying schema changes
npm run db:backup:remote
```
### Restore from Accident
```bash
# List recent backups
npm run db:backups
# Restore a specific backup
npm run db:restore ./backups/local_20240615_143022.sql.gz
```
## Environment Variables
You can set these in `.env.local` (git-ignored) for local overrides:
```bash
# Required for local operations
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
# Required for remote operations (one of these)
REMOTE_DATABASE_URL="postgresql://user:password@remote-host:5432/dbname"
DATABASE_URL_PRODUCTION="postgresql://user:password@remote-host:5432/dbname"
```
## Troubleshooting
### "pg_dump: command not found"
Install PostgreSQL client tools:
```bash
# macOS
brew install postgresql
# Ubuntu/Debian
sudo apt-get install postgresql-client
# Arch Linux
sudo pacman -S postgresql
```
### "FATAL: password authentication failed"
Check that your database URLs are correct and include the password.
### Backup seems stuck
Large databases may take time. The scripts show progress. For very large databases, consider using `pg_dump` directly with custom options.

View file

@ -0,0 +1,135 @@
#!/usr/bin/env tsx
import { PrismaClient } from '@prisma/client'
import { selectBestDominantColor, isGreyColor, analyzeColor } from '../src/lib/server/color-utils'
const prisma = new PrismaClient()
async function analyzeImage(filename: string) {
try {
// Find the image by filename
const media = await prisma.media.findFirst({
where: {
filename: {
contains: filename
}
},
select: {
id: true,
filename: true,
url: true,
dominantColor: true,
colors: true,
width: true,
height: true
}
})
if (!media) {
console.log(`Media not found with filename: ${filename}`)
return
}
console.log('\n=== Image Analysis ===')
console.log(`Filename: ${media.filename}`)
console.log(`URL: ${media.url}`)
console.log(`Current dominant color: ${media.dominantColor}`)
console.log(`Dimensions: ${media.width}x${media.height}`)
if (media.colors && Array.isArray(media.colors)) {
const colors = media.colors as Array<[string, number]>
console.log('\n=== Color Distribution ===')
console.log('Top 15 colors:')
colors.slice(0, 15).forEach(([hex, percentage], index) => {
const isGrey = isGreyColor(hex)
console.log(`${index + 1}. ${hex} - ${percentage.toFixed(2)}%${isGrey ? ' (grey)' : ''}`)
})
console.log('\n=== Color Analysis Strategies ===')
// Try different strategies
const strategies = {
'Default (min 2%, prefer vibrant & bright)': selectBestDominantColor(colors, {
minPercentage: 2,
preferVibrant: true,
excludeGreys: false,
preferBrighter: true
}),
'Exclude greys, prefer bright': selectBestDominantColor(colors, {
minPercentage: 1,
preferVibrant: true,
excludeGreys: true,
preferBrighter: true
}),
'Very low threshold (0.5%), bright': selectBestDominantColor(colors, {
minPercentage: 0.5,
preferVibrant: true,
excludeGreys: false,
preferBrighter: true
}),
'Allow dark colors': selectBestDominantColor(colors, {
minPercentage: 1,
preferVibrant: true,
excludeGreys: false,
preferBrighter: false
}),
'Focus on prominence (5%)': selectBestDominantColor(colors, {
minPercentage: 5,
preferVibrant: false,
excludeGreys: false,
preferBrighter: true
})
}
Object.entries(strategies).forEach(([strategy, color]) => {
const analysis = analyzeColor(color)
console.log(`${strategy}: ${color} | V:${analysis.vibrance.toFixed(2)} B:${analysis.brightness.toFixed(2)}${analysis.isGrey ? ' (grey)' : ''}${analysis.isDark ? ' (dark)' : ''}`)
})
// Show non-grey colors
console.log('\n=== Non-Grey Colors ===')
const nonGreyColors = colors.filter(([hex]) => !isGreyColor(hex))
console.log(`Found ${nonGreyColors.length} non-grey colors out of ${colors.length} total`)
if (nonGreyColors.length > 0) {
console.log('\nTop 10 non-grey colors:')
nonGreyColors.slice(0, 10).forEach(([hex, percentage], index) => {
const analysis = analyzeColor(hex)
console.log(`${index + 1}. ${hex} - ${percentage.toFixed(2)}% | B:${analysis.brightness.toFixed(2)}`)
})
// Look for more vibrant colors deeper in the list
console.log('\n=== All Colors with >0.5% ===')
const significantColors = colors.filter(([_, pct]) => pct > 0.5)
significantColors.forEach(([hex, percentage]) => {
const isGrey = isGreyColor(hex)
// Convert hex to RGB to analyze
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const saturation = max === 0 ? 0 : (max - min) / max * 100
console.log(`${hex} - ${percentage.toFixed(2)}% | Sat: ${saturation.toFixed(0)}%${isGrey ? ' (grey)' : ''}`)
})
}
} else {
console.log('\nNo color data available for this image')
}
} catch (error) {
console.error('Error:', error)
} finally {
await prisma.$disconnect()
}
}
// Get filename from command line argument
const filename = process.argv[2] || 'B0000295.jpg'
analyzeImage(filename)

256
scripts/backup-db.sh Executable file
View file

@ -0,0 +1,256 @@
#!/bin/bash
# Database Backup Script
# Usage: ./scripts/backup-db.sh [local|remote|sync]
# local - Backup local database
# remote - Backup remote database
# sync - Copy remote database to local
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Load environment variables
if [ -f ".env" ]; then
set -a
source .env
set +a
fi
if [ -f ".env.local" ]; then
set -a
source .env.local
set +a
fi
# Check if required environment variables are set
if [ -z "$DATABASE_URL" ]; then
echo -e "${RED}Error: DATABASE_URL is not set${NC}"
exit 1
fi
# Parse DATABASE_URL for local database
# Format: postgresql://user:password@host:port/database
LOCAL_DB_URL=$DATABASE_URL
LOCAL_DB_NAME=$(echo $LOCAL_DB_URL | sed -E 's/.*\/([^?]+).*/\1/')
LOCAL_DB_USER=$(echo $LOCAL_DB_URL | sed -E 's/postgresql:\/\/([^:]+):.*/\1/')
LOCAL_DB_HOST=$(echo $LOCAL_DB_URL | sed -E 's/.*@([^:]+):.*/\1/')
LOCAL_DB_PORT=$(echo $LOCAL_DB_URL | sed -E 's/.*:([0-9]+)\/.*/\1/')
# Remote database URL (can be set as REMOTE_DATABASE_URL or passed as env var)
REMOTE_DB_URL=${REMOTE_DATABASE_URL:-$DATABASE_URL_PRODUCTION}
if [ -z "$REMOTE_DB_URL" ] && [ "$1" != "local" ]; then
echo -e "${YELLOW}Warning: REMOTE_DATABASE_URL or DATABASE_URL_PRODUCTION not set${NC}"
echo "For remote operations, set one of these environment variables or pass it:"
echo "REMOTE_DATABASE_URL='postgresql://...' ./scripts/backup-db.sh remote"
fi
# Create backups directory if it doesn't exist
BACKUP_DIR="./backups"
mkdir -p $BACKUP_DIR
# Generate timestamp for backup files
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
# Function to parse database URL
parse_db_url() {
local url=$1
# Debug: Show input URL
>&2 echo "Debug - Input URL: $url"
# postgresql://user:password@host:port/database
# Remove the postgresql:// prefix
local stripped=$(echo $url | sed 's|postgresql://||')
>&2 echo "Debug - Stripped: $stripped"
# Extract user:password@host:port/database
local user_pass=$(echo $stripped | cut -d@ -f1)
local host_port_db=$(echo $stripped | cut -d@ -f2)
>&2 echo "Debug - User/Pass: $user_pass"
>&2 echo "Debug - Host/Port/DB: $host_port_db"
# Extract user and password
local db_user=$(echo $user_pass | cut -d: -f1)
local db_password=$(echo $user_pass | cut -d: -f2)
# Extract host, port, and database
local host_port=$(echo $host_port_db | cut -d/ -f1)
local db_name=$(echo $host_port_db | cut -d/ -f2 | cut -d? -f1)
# Extract host and port
local db_host=$(echo $host_port | cut -d: -f1)
local db_port=$(echo $host_port | cut -d: -f2)
>&2 echo "Debug - Final parsed: host=$db_host, port=$db_port, db=$db_name, user=$db_user"
echo "$db_host|$db_port|$db_name|$db_user|$db_password"
}
# Function to backup database
backup_database() {
local db_url=$1
local backup_name=$2
local description=$3
echo -e "${GREEN}Starting backup: $description${NC}"
# Parse database URL
local parsed_url=$(parse_db_url "$db_url")
IFS='|' read -r db_host db_port db_name db_user db_password <<< "$parsed_url"
# Create backup filename
local backup_file="${BACKUP_DIR}/${backup_name}_${TIMESTAMP}.sql"
# Set PGPASSWORD to avoid password prompt
export PGPASSWORD=$db_password
# Debug: Show parsed values
echo "Debug - Parsed values:"
echo " Host: '$db_host'"
echo " Port: '$db_port'"
echo " Database: '$db_name'"
echo " User: '$db_user'"
# Run pg_dump
echo "Backing up database: $db_name from $db_host:$db_port"
pg_dump -h "$db_host" -p "$db_port" -U "$db_user" -d "$db_name" -f "$backup_file" --verbose --no-owner --no-acl
# Compress the backup
echo "Compressing backup..."
gzip $backup_file
unset PGPASSWORD
echo -e "${GREEN}Backup completed: ${backup_file}.gz${NC}"
echo "Size: $(ls -lh ${backup_file}.gz | awk '{print $5}')"
}
# Function to restore database
restore_database() {
local backup_file=$1
local target_db_url=$2
local description=$3
echo -e "${GREEN}Starting restore: $description${NC}"
# Parse database URL
local parsed_url=$(parse_db_url "$target_db_url")
IFS='|' read -r db_host db_port db_name db_user db_password <<< "$parsed_url"
# Set PGPASSWORD to avoid password prompt
export PGPASSWORD=$db_password
# Drop and recreate database
echo -e "${YELLOW}Warning: This will drop and recreate the database: $db_name${NC}"
echo -n "Are you sure you want to continue? (y/N): "
read confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "Restore cancelled"
return
fi
# Drop existing connections
echo "Dropping existing connections..."
psql -h $db_host -p $db_port -U $db_user -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$db_name' AND pid <> pg_backend_pid();" 2>/dev/null || true
# Drop and recreate database
echo "Dropping database..."
psql -h $db_host -p $db_port -U $db_user -d postgres -c "DROP DATABASE IF EXISTS $db_name;"
echo "Creating database..."
psql -h $db_host -p $db_port -U $db_user -d postgres -c "CREATE DATABASE $db_name;"
# Decompress if needed
if [[ $backup_file == *.gz ]]; then
echo "Decompressing backup..."
gunzip -c $backup_file > ${backup_file%.gz}
backup_file=${backup_file%.gz}
temp_file=true
fi
# Restore database
echo "Restoring database..."
psql -h $db_host -p $db_port -U $db_user -d $db_name -f $backup_file
# Clean up temp file
if [ "$temp_file" = true ]; then
rm $backup_file
fi
unset PGPASSWORD
# Run Prisma migrations to ensure schema is up to date
echo "Running Prisma migrations..."
npm run db:deploy
echo -e "${GREEN}Restore completed${NC}"
}
# Function to sync remote to local
sync_remote_to_local() {
if [ -z "$REMOTE_DB_URL" ]; then
echo -e "${RED}Error: REMOTE_DATABASE_URL is not set${NC}"
exit 1
fi
echo -e "${GREEN}Syncing remote database to local${NC}"
# First, backup the local database
echo "Creating backup of local database first..."
backup_database "$LOCAL_DB_URL" "local_before_sync" "Local database (before sync)"
# Backup remote database
backup_database "$REMOTE_DB_URL" "remote_for_sync" "Remote database"
# Find the latest remote backup
latest_remote_backup=$(ls -t ${BACKUP_DIR}/remote_for_sync_*.sql.gz | head -1)
# Restore remote backup to local
restore_database "$latest_remote_backup" "$LOCAL_DB_URL" "Remote database to local"
}
# Main script logic
case "$1" in
"local")
backup_database "$LOCAL_DB_URL" "local" "Local database"
;;
"remote")
if [ -z "$REMOTE_DB_URL" ]; then
echo -e "${RED}Error: REMOTE_DATABASE_URL is not set${NC}"
exit 1
fi
backup_database "$REMOTE_DB_URL" "remote" "Remote database"
;;
"sync")
sync_remote_to_local
;;
*)
echo "Database Backup Utility"
echo ""
echo "Usage: $0 [local|remote|sync]"
echo ""
echo "Commands:"
echo " local - Backup local database"
echo " remote - Backup remote database"
echo " sync - Copy remote database to local (backs up both first)"
echo ""
echo "Environment variables:"
echo " DATABASE_URL - Local database connection URL (required)"
echo " REMOTE_DATABASE_URL - Remote database connection URL"
echo " DATABASE_URL_PRODUCTION - Alternative remote database URL"
echo ""
echo "Backups are stored in: ./backups/"
exit 1
;;
esac
# List recent backups
echo ""
echo "Recent backups:"
ls -lht $BACKUP_DIR/*.sql.gz 2>/dev/null | head -5 || echo "No backups found"

View file

@ -0,0 +1,63 @@
#!/usr/bin/env tsx
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
async function checkPhotoColors() {
try {
// Count total photography media
const totalPhotos = await prisma.media.count({
where: { isPhotography: true }
})
// Count photos with dominant color
const photosWithColor = await prisma.media.count({
where: {
isPhotography: true,
dominantColor: { not: null }
}
})
// Count photos without dominant color
const photosWithoutColor = await prisma.media.count({
where: {
isPhotography: true,
dominantColor: null
}
})
// Get some examples
const examples = await prisma.media.findMany({
where: {
isPhotography: true,
dominantColor: { not: null }
},
select: {
filename: true,
dominantColor: true,
thumbnailUrl: true
},
take: 5
})
console.log('=== Photography Color Analysis ===')
console.log(`Total photography items: ${totalPhotos}`)
console.log(`With dominant color: ${photosWithColor} (${((photosWithColor/totalPhotos)*100).toFixed(1)}%)`)
console.log(`Without dominant color: ${photosWithoutColor} (${((photosWithoutColor/totalPhotos)*100).toFixed(1)}%)`)
if (examples.length > 0) {
console.log('\n=== Examples with dominant colors ===')
examples.forEach(media => {
console.log(`${media.filename}: ${media.dominantColor}`)
})
}
} catch (error) {
console.error('Error:', error)
} finally {
await prisma.$disconnect()
}
}
checkPhotoColors()

View file

@ -0,0 +1,89 @@
import { prisma } from '../src/lib/server/database'
async function findImageColors() {
try {
console.log('Searching for image with filename: B0000295.jpg\n')
// Search in Photo table
console.log('Checking Photo table...')
const photo = await prisma.photo.findFirst({
where: {
filename: 'B0000295.jpg'
},
select: {
id: true,
filename: true,
dominantColor: true,
colors: true,
url: true,
thumbnailUrl: true,
width: true,
height: true,
aspectRatio: true
}
})
if (photo) {
console.log('Found in Photo table:')
console.log('ID:', photo.id)
console.log('Filename:', photo.filename)
console.log('URL:', photo.url)
console.log('Dominant Color:', photo.dominantColor || 'Not set')
console.log('Colors:', photo.colors ? JSON.stringify(photo.colors, null, 2) : 'Not set')
console.log('Dimensions:', photo.width ? `${photo.width}x${photo.height}` : 'Not set')
console.log('Aspect Ratio:', photo.aspectRatio || 'Not set')
} else {
console.log('Not found in Photo table.')
}
// Search in Media table
console.log('\nChecking Media table...')
const media = await prisma.media.findFirst({
where: {
filename: 'B0000295.jpg'
},
select: {
id: true,
filename: true,
originalName: true,
dominantColor: true,
colors: true,
url: true,
thumbnailUrl: true,
width: true,
height: true,
aspectRatio: true,
mimeType: true,
size: true
}
})
if (media) {
console.log('Found in Media table:')
console.log('ID:', media.id)
console.log('Filename:', media.filename)
console.log('Original Name:', media.originalName || 'Not set')
console.log('URL:', media.url)
console.log('Dominant Color:', media.dominantColor || 'Not set')
console.log('Colors:', media.colors ? JSON.stringify(media.colors, null, 2) : 'Not set')
console.log('Dimensions:', media.width ? `${media.width}x${media.height}` : 'Not set')
console.log('Aspect Ratio:', media.aspectRatio || 'Not set')
console.log('MIME Type:', media.mimeType)
console.log('Size:', media.size, 'bytes')
} else {
console.log('Not found in Media table.')
}
if (!photo && !media) {
console.log('\nImage B0000295.jpg not found in either Photo or Media tables.')
}
} catch (error) {
console.error('Error searching for image:', error)
} finally {
await prisma.$disconnect()
}
}
// Run the script
findImageColors()

139
scripts/list-backups.sh Executable file
View file

@ -0,0 +1,139 @@
#!/bin/bash
# List Database Backups Script
# Usage: ./scripts/list-backups.sh [all|local|remote|recent]
set -e
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
BACKUP_DIR="./backups"
# Check if backup directory exists
if [ ! -d "$BACKUP_DIR" ]; then
echo "No backups directory found. Run a backup first."
exit 1
fi
# Function to format file size
format_size() {
local size=$1
if [ $size -lt 1024 ]; then
echo "${size}B"
elif [ $size -lt 1048576 ]; then
echo "$((size/1024))KB"
elif [ $size -lt 1073741824 ]; then
echo "$((size/1048576))MB"
else
echo "$((size/1073741824))GB"
fi
}
# Function to list backups
list_backups() {
local pattern=$1
local title=$2
echo -e "${GREEN}${title}${NC}"
echo "----------------------------------------"
local count=0
while IFS= read -r file; do
if [ -f "$file" ]; then
local filename=$(basename "$file")
local size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null)
local formatted_size=$(format_size $size)
local modified=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S" "$file" 2>/dev/null || stat -c "%y" "$file" 2>/dev/null | cut -d' ' -f1-2)
# Extract type and timestamp from filename
local type=$(echo $filename | cut -d'_' -f1)
local timestamp=$(echo $filename | grep -oE '[0-9]{8}_[0-9]{6}')
# Format timestamp
if [ ! -z "$timestamp" ]; then
local date_part=$(echo $timestamp | cut -d'_' -f1)
local time_part=$(echo $timestamp | cut -d'_' -f2)
local formatted_date="${date_part:0:4}-${date_part:4:2}-${date_part:6:2}"
local formatted_time="${time_part:0:2}:${time_part:2:2}:${time_part:4:2}"
local display_time="$formatted_date $formatted_time"
else
local display_time=$modified
fi
# Color code by type
case $type in
"local")
echo -e "${BLUE}$filename${NC}"
;;
"remote")
echo -e "${YELLOW}$filename${NC}"
;;
*)
echo "$filename"
;;
esac
echo " Size: $formatted_size | Created: $display_time"
echo ""
count=$((count + 1))
fi
done < <(ls -t $BACKUP_DIR/$pattern 2>/dev/null)
if [ $count -eq 0 ]; then
echo "No backups found"
else
echo "Total: $count backup(s)"
fi
echo ""
}
# Calculate total backup size
calculate_total_size() {
local total=0
for file in $BACKUP_DIR/*.sql.gz; do
if [ -f "$file" ]; then
local size=$(stat -f%z "$file" 2>/dev/null || stat -c%s "$file" 2>/dev/null)
total=$((total + size))
fi
done
echo $(format_size $total)
}
# Main logic
case "${1:-all}" in
"all")
list_backups "*.sql.gz" "All Backups"
echo -e "${GREEN}Total backup size: $(calculate_total_size)${NC}"
;;
"local")
list_backups "local*.sql.gz" "Local Backups"
;;
"remote")
list_backups "remote*.sql.gz" "Remote Backups"
;;
"recent")
echo -e "${GREEN}Recent Backups (last 5)${NC}"
echo "----------------------------------------"
ls -lht $BACKUP_DIR/*.sql.gz 2>/dev/null | head -5 || echo "No backups found"
;;
*)
echo "Usage: $0 [all|local|remote|recent]"
echo ""
echo "Options:"
echo " all - List all backups (default)"
echo " local - List only local database backups"
echo " remote - List only remote database backups"
echo " recent - Show 5 most recent backups"
exit 1
;;
esac
# Show legend
echo ""
echo "Legend:"
echo -e " ${BLUE}Blue${NC} = Local database backup"
echo -e " ${YELLOW}Yellow${NC} = Remote database backup"

167
scripts/reanalyze-colors.ts Executable file
View file

@ -0,0 +1,167 @@
#!/usr/bin/env tsx
/**
* Script to reanalyze colors for specific images or all images
* Usage: tsx scripts/reanalyze-colors.ts [options]
*
* Options:
* --id <mediaId> Reanalyze specific media ID
* --grey-only Only reanalyze images with grey dominant colors
* --all Reanalyze all images with color data
* --dry-run Show what would be changed without updating
*/
import { PrismaClient } from '@prisma/client'
import { selectBestDominantColor, isGreyColor } from '../src/lib/server/color-utils'
const prisma = new PrismaClient()
interface Options {
id?: number
greyOnly: boolean
all: boolean
dryRun: boolean
}
function parseArgs(): Options {
const args = process.argv.slice(2)
const options: Options = {
greyOnly: false,
all: false,
dryRun: false
}
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--id':
options.id = parseInt(args[++i])
break
case '--grey-only':
options.greyOnly = true
break
case '--all':
options.all = true
break
case '--dry-run':
options.dryRun = true
break
}
}
return options
}
async function reanalyzeColors(options: Options) {
try {
// Build query
const where: any = {
colors: { not: null }
}
if (options.id) {
where.id = options.id
} else if (options.greyOnly) {
// We'll filter in code since Prisma doesn't support function calls in where
}
// Get media items
const mediaItems = await prisma.media.findMany({
where,
select: {
id: true,
filename: true,
dominantColor: true,
colors: true
}
})
console.log(`Found ${mediaItems.length} media items with color data`)
let updated = 0
let skipped = 0
for (const media of mediaItems) {
if (!media.colors || !Array.isArray(media.colors)) {
skipped++
continue
}
const currentColor = media.dominantColor
const colors = media.colors as Array<[string, number]>
// Skip if grey-only filter and current color isn't grey
if (options.greyOnly && currentColor && !isGreyColor(currentColor)) {
skipped++
continue
}
// Calculate new dominant color
const newColor = selectBestDominantColor(colors, {
minPercentage: 2,
preferVibrant: true,
excludeGreys: false
})
if (newColor !== currentColor) {
console.log(`\n${media.filename}:`)
console.log(` Current: ${currentColor || 'none'}`)
console.log(` New: ${newColor}`)
// Show color breakdown
const topColors = colors.slice(0, 5)
console.log(' Top colors:')
topColors.forEach(([hex, percentage]) => {
const isGrey = isGreyColor(hex)
console.log(` ${hex} - ${percentage.toFixed(1)}%${isGrey ? ' (grey)' : ''}`)
})
if (!options.dryRun) {
// Update media
await prisma.media.update({
where: { id: media.id },
data: { dominantColor: newColor }
})
// Update related photos
await prisma.photo.updateMany({
where: { mediaId: media.id },
data: { dominantColor: newColor }
})
updated++
}
} else {
skipped++
}
}
console.log(`\n✓ Complete!`)
console.log(` Updated: ${updated}`)
console.log(` Skipped: ${skipped}`)
if (options.dryRun) {
console.log(` (Dry run - no changes made)`)
}
} catch (error) {
console.error('Error:', error)
process.exit(1)
} finally {
await prisma.$disconnect()
}
}
// Run the script
const options = parseArgs()
if (!options.id && !options.all && !options.greyOnly) {
console.log('Usage: tsx scripts/reanalyze-colors.ts [options]')
console.log('')
console.log('Options:')
console.log(' --id <mediaId> Reanalyze specific media ID')
console.log(' --grey-only Only reanalyze images with grey dominant colors')
console.log(' --all Reanalyze all images with color data')
console.log(' --dry-run Show what would be changed without updating')
process.exit(1)
}
reanalyzeColors(options)

168
scripts/restore-db.sh Executable file
View file

@ -0,0 +1,168 @@
#!/bin/bash
# Database Restore Script
# Usage: ./scripts/restore-db.sh <backup-file> [local|remote]
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Load environment variables
if [ -f ".env" ]; then
set -a
source .env
set +a
fi
if [ -f ".env.local" ]; then
set -a
source .env.local
set +a
fi
# Check arguments
if [ $# -lt 1 ]; then
echo "Database Restore Utility"
echo ""
echo "Usage: $0 <backup-file> [local|remote]"
echo ""
echo "Arguments:"
echo " backup-file - Path to the backup file (.sql or .sql.gz)"
echo " target - Target database: 'local' (default) or 'remote'"
echo ""
echo "Example:"
echo " $0 ./backups/local_20240101_120000.sql.gz"
echo " $0 ./backups/remote_20240101_120000.sql.gz local"
echo ""
echo "Recent backups:"
ls -lht ./backups/*.sql.gz 2>/dev/null | head -10 || echo "No backups found"
exit 1
fi
BACKUP_FILE=$1
TARGET=${2:-local}
# Check if backup file exists
if [ ! -f "$BACKUP_FILE" ]; then
echo -e "${RED}Error: Backup file not found: $BACKUP_FILE${NC}"
exit 1
fi
# Function to parse database URL
parse_db_url() {
local url=$1
# postgresql://user:password@host:port/database
# Remove the postgresql:// prefix
local stripped=$(echo $url | sed 's|postgresql://||')
# Extract user:password@host:port/database
local user_pass=$(echo $stripped | cut -d@ -f1)
local host_port_db=$(echo $stripped | cut -d@ -f2)
# Extract user and password
local db_user=$(echo $user_pass | cut -d: -f1)
local db_password=$(echo $user_pass | cut -d: -f2)
# Extract host, port, and database
local host_port=$(echo $host_port_db | cut -d/ -f1)
local db_name=$(echo $host_port_db | cut -d/ -f2 | cut -d? -f1)
# Extract host and port
local db_host=$(echo $host_port | cut -d: -f1)
local db_port=$(echo $host_port | cut -d: -f2)
echo "$db_host|$db_port|$db_name|$db_user|$db_password"
}
# Determine target database URL
if [ "$TARGET" = "local" ]; then
TARGET_DB_URL=$DATABASE_URL
TARGET_DESC="local"
elif [ "$TARGET" = "remote" ]; then
TARGET_DB_URL=${REMOTE_DATABASE_URL:-$DATABASE_URL_PRODUCTION}
TARGET_DESC="remote"
if [ -z "$TARGET_DB_URL" ]; then
echo -e "${RED}Error: REMOTE_DATABASE_URL or DATABASE_URL_PRODUCTION not set${NC}"
exit 1
fi
else
echo -e "${RED}Error: Invalid target. Use 'local' or 'remote'${NC}"
exit 1
fi
# Parse database URL
parsed_url=$(parse_db_url "$TARGET_DB_URL")
IFS='|' read -r db_host db_port db_name db_user db_password <<< "$parsed_url"
echo -e "${GREEN}Restoring to $TARGET_DESC database${NC}"
echo "Database: $db_name"
echo "Host: $db_host:$db_port"
echo "Backup file: $BACKUP_FILE"
echo ""
# Confirmation with stronger warning for remote
if [ "$TARGET" = "remote" ]; then
echo -e "${RED}WARNING: You are about to restore to the REMOTE database!${NC}"
echo -e "${RED}This will DELETE ALL DATA in the remote database and replace it.${NC}"
echo -n "Type 'RESTORE REMOTE' to confirm: "
read confirm
if [ "$confirm" != "RESTORE REMOTE" ]; then
echo "Restore cancelled"
exit 1
fi
else
echo -e "${YELLOW}Warning: This will delete all data in the $TARGET_DESC database${NC}"
echo -n "Are you sure you want to continue? (y/N): "
read confirm
if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then
echo "Restore cancelled"
exit 1
fi
fi
# Set PGPASSWORD to avoid password prompt
export PGPASSWORD=$db_password
# Drop existing connections
echo "Dropping existing connections..."
psql -h $db_host -p $db_port -U $db_user -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$db_name' AND pid <> pg_backend_pid();" 2>/dev/null || true
# Drop and recreate database
echo "Dropping database..."
psql -h $db_host -p $db_port -U $db_user -d postgres -c "DROP DATABASE IF EXISTS $db_name;"
echo "Creating database..."
psql -h $db_host -p $db_port -U $db_user -d postgres -c "CREATE DATABASE $db_name;"
# Handle compressed files
if [[ $BACKUP_FILE == *.gz ]]; then
echo "Decompressing backup..."
TEMP_FILE=$(mktemp)
gunzip -c $BACKUP_FILE > $TEMP_FILE
RESTORE_FILE=$TEMP_FILE
else
RESTORE_FILE=$BACKUP_FILE
fi
# Restore database
echo "Restoring database..."
psql -h $db_host -p $db_port -U $db_user -d $db_name -f $RESTORE_FILE
# Clean up temp file if created
if [ ! -z "$TEMP_FILE" ]; then
rm $TEMP_FILE
fi
unset PGPASSWORD
# Run Prisma migrations if restoring to local
if [ "$TARGET" = "local" ]; then
echo "Running Prisma migrations..."
npm run db:deploy
fi
echo -e "${GREEN}✓ Database restored successfully${NC}"

View file

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<!-- Horizontal scroll view -->
<rect x="2" y="5" width="14" height="14" rx="3"/>
<rect x="18" y="5" width="4" height="14" rx="2"/>
</svg>

After

Width:  |  Height:  |  Size: 248 B

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<!-- Single column view icon - rounded square with text -->
<rect x="5" y="3" width="14" height="14" rx="3"/>
<rect x="5" y="19" width="14" height="4" rx="2"/>
</svg>

After

Width:  |  Height:  |  Size: 276 B

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="7" height="16" rx="3" fill="currentColor"/>
<rect x="13" y="4" width="7" height="16" rx="3" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 245 B

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<!-- Normal width -->
<rect x="8" y="4" width="8" height="16" rx="3"/>
</svg>

After

Width:  |  Height:  |  Size: 185 B

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<!-- Wide width -->
<rect x="4" y="4" width="16" height="16" rx="3"/>
</svg>

After

Width:  |  Height:  |  Size: 184 B

View file

@ -0,0 +1,76 @@
<script lang="ts">
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
import { isAlbum } from '$lib/types/photos'
const {
photoItems,
albumSlug
}: {
photoItems: PhotoItemType[]
albumSlug?: string
} = $props()
</script>
<div class="horizontal-scroll">
{#each photoItems as item}
{#if isAlbum(item)}
<a href="/photos/{item.slug}" class="photo-link">
<img src={item.coverPhoto.src} alt={item.title} />
<p class="caption">{item.title}</p>
</a>
{:else}
{@const mediaId = item.id.replace(/^(media|photo)-/, '')}
<a href="/photos/{albumSlug ? `${albumSlug}/${mediaId}` : `p/${mediaId}`}" class="photo-link">
<img src={item.src} alt={item.alt} />
{#if item.caption}
<p class="caption">{item.caption}</p>
{/if}
</a>
{/if}
{/each}
</div>
<style lang="scss">
.horizontal-scroll {
display: flex;
gap: $unit-3x;
overflow-x: auto;
overflow-y: hidden;
padding: 0 $unit-3x;
// Hide scrollbar
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
@include breakpoint('phone') {
gap: $unit-2x;
}
}
.photo-link {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: $unit;
text-decoration: none;
color: inherit;
img {
height: 60vh;
width: auto;
object-fit: contain;
border-radius: $corner-radius-md;
}
}
.caption {
margin: 0;
font-size: 0.875rem;
line-height: 1.4;
color: $grey-20;
padding: $unit 0;
}
</style>

View file

@ -37,6 +37,16 @@
const photo = $derived(isAlbum(item) ? item.coverPhoto : item)
const isAlbumItem = $derived(isAlbum(item))
const placeholderStyle = $derived(
photo.dominantColor
? `background: ${photo.dominantColor}`
: ''
)
const aspectRatioStyle = $derived(
photo.aspectRatio
? `aspect-ratio: ${photo.aspectRatio}`
: ''
)
</script>
<div class="photo-item" class:is-album={isAlbumItem}>
@ -46,7 +56,7 @@
<div class="album-stack">
<div class="stack-photo stack-back"></div>
<div class="stack-photo stack-middle"></div>
<div class="stack-photo stack-front">
<div class="stack-photo stack-front" style={aspectRatioStyle}>
<img
src={photo.src}
alt={photo.alt}
@ -55,9 +65,7 @@
onload={handleImageLoad}
class:loaded={imageLoaded}
/>
{#if !imageLoaded}
<div class="image-placeholder"></div>
{/if}
<div class="image-placeholder" style={placeholderStyle} class:loaded={imageLoaded}></div>
</div>
<div class="album-overlay">
<div class="album-info">
@ -68,7 +76,7 @@
</div>
{:else}
<!-- Single photo -->
<div class="single-photo">
<div class="single-photo" style={aspectRatioStyle}>
<img
src={photo.src}
alt={photo.alt}
@ -77,9 +85,7 @@
onload={handleImageLoad}
class:loaded={imageLoaded}
/>
{#if !imageLoaded}
<div class="image-placeholder"></div>
{/if}
<div class="image-placeholder" style={placeholderStyle} class:loaded={imageLoaded}></div>
</div>
{/if}
</button>
@ -129,6 +135,8 @@
border-radius: $corner-radius;
opacity: 0;
transition: opacity 0.4s ease;
position: relative;
z-index: 2;
&.loaded {
opacity: 1;
@ -177,6 +185,8 @@
border-radius: $corner-radius;
opacity: 0;
transition: opacity 0.4s ease;
position: relative;
z-index: 2;
&.loaded {
opacity: 1;
@ -232,34 +242,18 @@
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, #f0f0f0 0%, #e0e0e0 50%, #f0f0f0 100%);
background-size: 200% 200%;
animation: shimmer 1.5s ease-in-out infinite;
background: #f0f0f0; // Lighter default grey
border-radius: $corner-radius;
opacity: 1;
transition: opacity 0.4s ease;
z-index: 1;
overflow: hidden;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 40px;
height: 40px;
background: rgba(0, 0, 0, 0.1);
border-radius: 50%;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='1.5'%3E%3Crect x='3' y='3' width='18' height='18' rx='2' ry='2'/%3E%3Ccircle cx='8.5' cy='8.5' r='1.5'/%3E%3Cpolyline points='21,15 16,10 5,21'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: center;
background-size: 24px 24px;
&.loaded {
opacity: 0;
pointer-events: none;
}
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import PhotoItem from './PhotoItem.svelte'
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
import { isAlbum } from '$lib/types/photos'
const {
photoItems,
albumSlug
}: {
photoItems: PhotoItemType[]
albumSlug?: string
} = $props()
</script>
<div class="single-column-grid">
{#each photoItems as item}
<div class="photo-container">
<PhotoItem {item} {albumSlug} />
{#if !isAlbum(item) && item.caption}
<div class="photo-details">
<p class="photo-caption">{item.caption}</p>
</div>
{/if}
</div>
{/each}
</div>
<style lang="scss">
.single-column-grid {
display: flex;
flex-direction: column;
gap: $unit-4x;
width: 100%;
}
.photo-container {
width: 100%;
}
.photo-details {
padding: $unit-2x 0 0;
}
.photo-caption {
margin: 0;
font-size: 0.9rem;
line-height: 1.6;
color: $grey-20;
}
@include breakpoint('phone') {
.single-column-grid {
gap: $unit-3x;
}
.photo-details {
padding: $unit 0;
}
}
</style>

View file

@ -0,0 +1,51 @@
<script lang="ts">
import PhotoItem from '$components/PhotoItem.svelte'
import type { PhotoItem as PhotoItemType } from '$lib/types/photos'
const {
photoItems,
albumSlug
}: {
photoItems: PhotoItemType[]
albumSlug?: string
} = $props()
// Split items into two columns
const column1 = $derived(photoItems.filter((_, index) => index % 2 === 0))
const column2 = $derived(photoItems.filter((_, index) => index % 2 === 1))
</script>
<div class="two-column-grid">
<div class="column">
{#each column1 as item}
<PhotoItem {item} {albumSlug} />
{/each}
</div>
<div class="column">
{#each column2 as item}
<PhotoItem {item} {albumSlug} />
{/each}
</div>
</div>
<style lang="scss">
.two-column-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $unit-3x;
@include breakpoint('phone') {
gap: $unit-2x;
}
}
.column {
display: flex;
flex-direction: column;
gap: $unit-3x;
@include breakpoint('phone') {
gap: $unit-2x;
}
}
</style>

View file

@ -0,0 +1,143 @@
<script lang="ts">
import { getContext } from 'svelte'
import PhotosIcon from '$icons/photos.svg?component'
import ViewSingleIcon from '$icons/view-single.svg?component'
import ViewTwoColumnIcon from '$icons/view-two-column.svg?component'
import ViewHorizontalIcon from '$icons/view-horizontal.svg?component'
import WidthNormalIcon from '$icons/width-normal.svg?component'
import WidthWideIcon from '$icons/width-wide.svg?component'
export type ViewMode = 'masonry' | 'single' | 'two-column' | 'horizontal'
interface Props {
mode?: ViewMode
width?: 'normal' | 'wide'
onModeChange?: (mode: ViewMode) => void
onWidthChange?: (width: 'normal' | 'wide') => void
}
let {
mode = 'masonry',
width = 'normal',
onModeChange,
onWidthChange
}: Props = $props()
</script>
<div class="view-mode-selector">
<div class="mode-section">
<button
class="mode-button"
class:selected={mode === 'masonry'}
aria-label="Masonry view"
onclick={() => onModeChange?.('masonry')}
>
<PhotosIcon />
</button>
<button
class="mode-button"
class:selected={mode === 'single'}
aria-label="Single column view"
onclick={() => onModeChange?.('single')}
>
<ViewSingleIcon />
</button>
<button
class="mode-button"
class:selected={mode === 'two-column'}
aria-label="Two column view"
onclick={() => onModeChange?.('two-column')}
>
<ViewTwoColumnIcon />
</button>
<button
class="mode-button"
class:selected={mode === 'horizontal'}
aria-label="Horizontal scroll view"
onclick={() => onModeChange?.('horizontal')}
>
<ViewHorizontalIcon />
</button>
</div>
{#if mode !== 'horizontal'}
<div class="separator"></div>
<div class="width-section">
<button
class="mode-button"
class:selected={width === 'normal'}
aria-label="Normal width"
onclick={() => onWidthChange?.('normal')}
>
<WidthNormalIcon />
</button>
<button
class="mode-button"
class:selected={width === 'wide'}
aria-label="Wide width"
onclick={() => onWidthChange?.('wide')}
>
<WidthWideIcon />
</button>
</div>
{/if}
</div>
<style lang="scss">
.view-mode-selector {
width: 100%;
background: $grey-100;
border-radius: $corner-radius-lg;
box-sizing: border-box;
padding: $unit;
display: flex;
justify-content: space-between;
align-items: center;
gap: $unit-2x;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
@include breakpoint('phone') {
display: none;
}
}
.mode-section,
.width-section {
display: flex;
gap: $unit-half;
}
.separator {
flex: 1;
min-width: $unit-2x;
}
.mode-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
background: transparent;
border-radius: $corner-radius-sm;
cursor: pointer;
transition: all 0.2s ease;
color: $grey-60;
&:hover {
background: $grey-95;
}
&.selected {
color: $red-60;
background: $salmon-pink;
}
:global(svg) {
width: 20px;
height: 20px;
}
}
</style>

View file

@ -309,6 +309,21 @@
<span class="value">{media.width} × {media.height}px</span>
</div>
{/if}
{#if media.dominantColor}
<div class="info-item">
<span class="label">Dominant Color</span>
<span class="value color-value">
<span
class="color-swatch"
style="background-color: {media.dominantColor}"
title={media.dominantColor}
></span>
{media.dominantColor}
</span>
</div>
{:else}
<!-- Debug: dominantColor = {JSON.stringify(media.dominantColor)} -->
{/if}
<div class="info-item">
<span class="label">Uploaded</span>
<span class="value">{new Date(media.createdAt).toLocaleDateString()}</span>
@ -625,8 +640,23 @@
font-size: 0.875rem;
color: $grey-10;
font-weight: 500;
&.color-value {
display: flex;
align-items: center;
gap: $unit-2x;
}
}
}
.color-swatch {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
:global(.btn.btn-ghost.exif-toggle) {
margin-top: $unit-2x;

View file

@ -3,6 +3,7 @@ import type { UploadApiResponse, UploadApiErrorResponse } from 'cloudinary'
import { logger } from './logger'
import { uploadFileLocally } from './local-storage'
import { dev } from '$app/environment'
import { selectBestDominantColor } from './color-utils'
// Configure Cloudinary
cloudinary.config({
@ -67,6 +68,9 @@ export interface UploadResult {
height?: number
format?: string
size?: number
dominantColor?: string
colors?: any
aspectRatio?: number
error?: string
}
@ -92,6 +96,10 @@ export async function uploadFile(
}
}
const aspectRatio = localResult.width && localResult.height
? localResult.width / localResult.height
: undefined
return {
success: true,
publicId: `local/${localResult.filename}`,
@ -101,7 +109,8 @@ export async function uploadFile(
width: localResult.width,
height: localResult.height,
format: file.type.split('/')[1],
size: localResult.size
size: localResult.size,
aspectRatio
}
}
@ -122,7 +131,9 @@ export async function uploadFile(
...customOptions,
public_id: `${Date.now()}-${fileNameWithoutExt}`,
// For SVG files, explicitly set format to preserve extension
...(isSvg && { format: 'svg' })
...(isSvg && { format: 'svg' }),
// Request color analysis for images
colors: true
}
// Log upload attempt for debugging
@ -151,6 +162,20 @@ export async function uploadFile(
secure: true
})
// Extract dominant color using smart selection
let dominantColor: string | undefined
if (result.colors && Array.isArray(result.colors) && result.colors.length > 0) {
dominantColor = selectBestDominantColor(result.colors, {
minPercentage: 2,
preferVibrant: true,
excludeGreys: false,
preferBrighter: true
})
}
// Calculate aspect ratio
const aspectRatio = result.width && result.height ? result.width / result.height : undefined
logger.mediaUpload(file.name, file.size, file.type, true)
return {
@ -162,7 +187,10 @@ export async function uploadFile(
width: result.width,
height: result.height,
format: result.format,
size: result.bytes
size: result.bytes,
dominantColor,
colors: result.colors,
aspectRatio
}
} catch (error) {
logger.error('Cloudinary upload failed', error as Error)
@ -273,8 +301,14 @@ export function extractPublicId(url: string): string | null {
try {
// Cloudinary URLs typically follow this pattern:
// https://res.cloudinary.com/{cloud_name}/image/upload/{version}/{public_id}.{format}
const match = url.match(/\/v\d+\/(.+)\.[a-zA-Z]+$/)
return match ? match[1] : null
// First decode the URL to handle encoded characters
const decodedUrl = decodeURIComponent(url)
const match = decodedUrl.match(/\/v\d+\/(.+)\.[a-zA-Z]+$/)
if (match) {
// Re-encode the public ID for Cloudinary API
return match[1]
}
return null
} catch {
return null
}

View file

@ -0,0 +1,238 @@
/**
* Color utility functions for selecting better dominant colors
*/
interface ColorInfo {
hex: string
percentage: number
}
/**
* Calculate color vibrance/saturation
* Returns a value between 0 (grey) and 1 (fully saturated)
*/
function getColorVibrance(hex: string): number {
// Convert hex to RGB
const r = parseInt(hex.slice(1, 3), 16) / 255
const g = parseInt(hex.slice(3, 5), 16) / 255
const b = parseInt(hex.slice(5, 7), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
// Calculate saturation
const delta = max - min
const lightness = (max + min) / 2
if (delta === 0) return 0 // Grey
const saturation = lightness > 0.5
? delta / (2 - max - min)
: delta / (max + min)
return saturation
}
/**
* Calculate color brightness
* Returns a value between 0 (black) and 1 (white)
*/
function getColorBrightness(hex: string): number {
const r = parseInt(hex.slice(1, 3), 16) / 255
const g = parseInt(hex.slice(3, 5), 16) / 255
const b = parseInt(hex.slice(5, 7), 16) / 255
// Using perceived brightness formula
return (r * 0.299 + g * 0.587 + b * 0.114)
}
/**
* Score a color based on its visual interest
* Higher scores mean more visually interesting colors
*/
function scoreColor(color: ColorInfo, preferBrighter: boolean = false): number {
const vibrance = getColorVibrance(color.hex)
const brightness = getColorBrightness(color.hex)
// Apply brightness penalties with a smoother curve
let brightnessPenalty = 0
if (brightness < 0.15) {
// Heavy penalty for very dark colors (below 15%)
brightnessPenalty = (0.15 - brightness) * 6
} else if (brightness < 0.3 && preferBrighter) {
// Moderate penalty for dark colors (15-30%) when preferBrighter is true
brightnessPenalty = (0.3 - brightness) * 2
} else if (brightness > 0.85) {
// Penalty for very light colors
brightnessPenalty = (brightness - 0.85) * 2
}
// Ideal brightness range is 0.3-0.7 for most use cases
const idealBrightness = brightness >= 0.3 && brightness <= 0.7
// Weight factors
const vibranceWeight = 2.5 // Prefer colorful over grey
const percentageWeight = 0.4 // Slightly higher weight for prevalence
const brightnessWeight = 2.0 // Important to avoid too dark/light
// Calculate base score
let score = (
(vibrance * vibranceWeight) +
(color.percentage / 100 * percentageWeight) +
(Math.max(0, 1 - brightnessPenalty) * brightnessWeight)
)
// Apply bonuses for ideal colors
if (idealBrightness && vibrance > 0.5) {
// Bonus for colors in ideal brightness range with good vibrance
score *= 1.3
} else if (vibrance > 0.8 && brightness > 0.25 && brightness < 0.75) {
// Smaller bonus for very vibrant colors that aren't too dark/light
score *= 1.15
}
return score
}
/**
* Select the best dominant color from Cloudinary's color array
*
* @param colors - Array of [hex, percentage] tuples from Cloudinary
* @param options - Configuration options
* @returns The selected dominant color hex string
*/
export function selectBestDominantColor(
colors: Array<[string, number]>,
options: {
minPercentage?: number
preferVibrant?: boolean
excludeGreys?: boolean
preferBrighter?: boolean
} = {}
): string {
const {
minPercentage = 2, // Ignore colors below this percentage
preferVibrant = true,
excludeGreys = false,
preferBrighter = true // Avoid very dark colors
} = options
if (!colors || colors.length === 0) {
return '#888888' // Default grey
}
// Convert to our format and filter
let colorCandidates: ColorInfo[] = colors
.map(([hex, percentage]) => ({ hex, percentage }))
.filter(color => color.percentage >= minPercentage)
// Exclude greys if requested
if (excludeGreys) {
colorCandidates = colorCandidates.filter(color => {
const vibrance = getColorVibrance(color.hex)
return vibrance > 0.1 // Keep colors with at least 10% saturation
})
}
// If no candidates after filtering, use the original dominant color
if (colorCandidates.length === 0) {
return colors[0][0]
}
// Score and sort colors
const scoredColors = colorCandidates.map(color => ({
...color,
score: scoreColor(color, preferBrighter)
}))
scoredColors.sort((a, b) => b.score - a.score)
// If we're still getting a darker color than ideal, look for better alternatives
if (preferBrighter && scoredColors.length > 1) {
const bestColor = scoredColors[0]
const bestBrightness = getColorBrightness(bestColor.hex)
// If the best color is darker than ideal (< 45%), check alternatives
if (bestBrightness < 0.45) {
// Look through top candidates for significantly brighter alternatives
for (let i = 1; i < Math.min(5, scoredColors.length); i++) {
const candidate = scoredColors[i]
const candidateBrightness = getColorBrightness(candidate.hex)
const candidateVibrance = getColorVibrance(candidate.hex)
// Select a brighter alternative if:
// 1. It's at least 15% brighter than current best
// 2. It still has good vibrance (> 0.5)
// 3. Its score is at least 80% of the best score
if (candidateBrightness > bestBrightness + 0.15 &&
candidateVibrance > 0.5 &&
candidate.score >= bestColor.score * 0.8) {
return candidate.hex
}
}
// If still very dark and we can lower the threshold, try again
if (bestBrightness < 0.25 && minPercentage > 0.5) {
return selectBestDominantColor(colors, {
...options,
minPercentage: Math.max(0.5, minPercentage * 0.5)
})
}
}
}
// Return the best scoring color
return scoredColors[0].hex
}
/**
* Get a color palette excluding greys and very dark/light colors
*/
export function getVibrantPalette(
colors: Array<[string, number]>,
maxColors: number = 5
): string[] {
const vibrantColors = colors
.map(([hex, percentage]) => ({ hex, percentage }))
.filter(color => {
const vibrance = getColorVibrance(color.hex)
const brightness = getColorBrightness(color.hex)
return vibrance > 0.2 && brightness > 0.15 && brightness < 0.85
})
.slice(0, maxColors)
.map(color => color.hex)
return vibrantColors
}
/**
* Determine if a color is considered "grey" or neutral
*/
export function isGreyColor(hex: string): boolean {
const vibrance = getColorVibrance(hex)
return vibrance < 0.1
}
/**
* Debug function to analyze a color
*/
export function analyzeColor(hex: string): {
hex: string
vibrance: number
brightness: number
isGrey: boolean
isDark: boolean
isBright: boolean
} {
const vibrance = getColorVibrance(hex)
const brightness = getColorBrightness(hex)
return {
hex,
vibrance,
brightness,
isGrey: vibrance < 0.1,
isDark: brightness < 0.2,
isBright: brightness > 0.9
}
}

View file

@ -16,6 +16,9 @@ export interface Photo {
caption?: string
width: number
height: number
dominantColor?: string
colors?: any
aspectRatio?: number
exif?: ExifData
createdAt?: string
}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import AdminHeader from '$lib/components/admin/AdminHeader.svelte'
import AdminFilters from '$lib/components/admin/AdminFilters.svelte'
@ -195,7 +196,7 @@
}
function handleAuditStorage() {
window.location.href = '/admin/media/audit'
goto('/admin/media/audit')
}
$effect(() => {
@ -391,6 +392,9 @@
<DropdownItem onclick={handleAuditStorage}>
Audit Storage
</DropdownItem>
<DropdownItem onclick={() => goto('/admin/media/regenerate')}>
Regenerate Cloudinary
</DropdownItem>
</DropdownMenuContainer>
{/if}
</div>

View file

@ -51,7 +51,6 @@
.filter((f) => selectedFiles.has(f.publicId))
.reduce((sum, f) => sum + f.size, 0) || 0
$: console.log('Reactive state:', { hasSelection, selectedFilesSize: selectedFiles.size, deleting, showDeleteModal, showCleanupModal })
onMount(() => {
runAudit()
@ -97,18 +96,15 @@
}
function toggleFile(publicId: string) {
console.log('toggleFile called', publicId)
if (selectedFiles.has(publicId)) {
selectedFiles.delete(publicId)
} else {
selectedFiles.add(publicId)
}
selectedFiles = selectedFiles // Trigger reactivity
console.log('selectedFiles after toggle:', Array.from(selectedFiles))
}
async function deleteSelected(dryRun = true) {
console.log('deleteSelected called', { dryRun, hasSelection, deleting, selectedFiles: Array.from(selectedFiles) })
if (!hasSelection || deleting) return
if (!dryRun) {
@ -302,7 +298,6 @@
variant="danger"
buttonSize="small"
onclick={() => {
console.log('Delete Selected clicked', { hasSelection, deleting, selectedFiles: Array.from(selectedFiles) })
showDeleteModal = true
}}
disabled={!hasSelection || deleting}
@ -405,7 +400,6 @@
variant="secondary"
buttonSize="small"
onclick={() => {
console.log('Clean Up Broken References clicked', { cleaningUp, missingReferencesCount: auditData?.missingReferences.length })
showCleanupModal = true
}}
disabled={cleaningUp}
@ -444,11 +438,9 @@
</div>
<div class="modal-actions">
<Button variant="secondary" onclick={() => {
console.log('Cancel clicked')
showDeleteModal = false
}}>Cancel</Button>
<Button variant="danger" onclick={() => {
console.log('Delete Files clicked')
deleteSelected(false)
}} disabled={deleting}>
{deleting ? 'Deleting...' : 'Delete Files'}
@ -476,11 +468,9 @@
</div>
<div class="modal-actions">
<Button variant="secondary" onclick={() => {
console.log('Cancel cleanup clicked')
showCleanupModal = false
}}>Cancel</Button>
<Button variant="danger" onclick={() => {
console.log('Clean Up References clicked')
cleanupBrokenReferences()
}} disabled={cleaningUp}>
{cleaningUp ? 'Cleaning Up...' : 'Clean Up References'}

View file

@ -0,0 +1,605 @@
<script lang="ts">
import { onMount } from 'svelte'
import { goto } from '$app/navigation'
import AdminPage from '$lib/components/admin/AdminPage.svelte'
import Button from '$lib/components/admin/Button.svelte'
import Modal from '$lib/components/admin/Modal.svelte'
import { Play, Palette, Image, Sparkles } from 'lucide-svelte'
import ChevronLeft from '$icons/chevron-left.svg'
let extractingColors = $state(false)
let regeneratingThumbnails = $state(false)
let reanalyzingColors = $state(false)
let colorExtractionResults: {
processed: number
succeeded: number
failed: number
errors: string[]
photosUpdated: number
} | null = $state(null)
let thumbnailResults: {
processed: number
succeeded: number
failed: number
errors: string[]
} | null = $state(null)
let reanalysisResults: {
processed: number
updated: number
skipped: number
errors: string[]
} | null = $state(null)
let showResultsModal = $state(false)
let error: string | null = $state(null)
let mediaStats = $state<{
totalMedia: number
missingColors: number
missingAspectRatio: number
outdatedThumbnails: number
greyDominantColors: number
} | null>(null)
onMount(() => {
// Check authentication
const auth = localStorage.getItem('admin_auth')
if (!auth) {
goto('/admin/login')
} else {
fetchMediaStats()
}
})
async function fetchMediaStats() {
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) return
const response = await fetch('/api/admin/media-stats', {
headers: {
Authorization: `Basic ${auth}`
}
})
if (!response.ok) {
throw new Error('Failed to fetch media stats')
}
mediaStats = await response.json()
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to fetch media stats'
}
}
async function extractColors() {
extractingColors = true
error = null
colorExtractionResults = null
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
error = 'Not authenticated'
return
}
const response = await fetch('/api/admin/cloudinary-extract-colors', {
method: 'POST',
headers: {
Authorization: `Basic ${auth}`
}
})
if (!response.ok) {
throw new Error('Failed to extract colors')
}
colorExtractionResults = await response.json()
showResultsModal = true
// Refresh stats
await fetchMediaStats()
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred'
} finally {
extractingColors = false
}
}
async function regenerateThumbnails() {
regeneratingThumbnails = true
error = null
thumbnailResults = null
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
error = 'Not authenticated'
return
}
const response = await fetch('/api/admin/regenerate-thumbnails', {
method: 'POST',
headers: {
Authorization: `Basic ${auth}`
}
})
if (!response.ok) {
throw new Error('Failed to regenerate thumbnails')
}
thumbnailResults = await response.json()
showResultsModal = true
// Refresh stats
await fetchMediaStats()
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred'
} finally {
regeneratingThumbnails = false
}
}
async function reanalyzeColors() {
reanalyzingColors = true
error = null
reanalysisResults = null
try {
const auth = localStorage.getItem('admin_auth')
if (!auth) {
error = 'Not authenticated'
return
}
const response = await fetch('/api/admin/reanalyze-colors', {
method: 'POST',
headers: {
Authorization: `Basic ${auth}`
}
})
if (!response.ok) {
throw new Error('Failed to reanalyze colors')
}
reanalysisResults = await response.json()
showResultsModal = true
// Refresh stats
await fetchMediaStats()
} catch (err) {
error = err instanceof Error ? err.message : 'An error occurred'
} finally {
reanalyzingColors = false
}
}
</script>
<svelte:head>
<title>Regenerate Cloudinary - Admin @jedmund</title>
</svelte:head>
<AdminPage>
<header slot="header">
<div class="header-left">
<button class="btn-icon" onclick={() => goto('/admin/media')}>
<ChevronLeft />
</button>
<h1>Regenerate Cloudinary Data</h1>
</div>
</header>
{#if error}
<div class="error-message">
<p>{error}</p>
</div>
{/if}
{#if mediaStats}
<div class="stats-grid">
<div class="stat-card">
<h3>Total Media</h3>
<p class="value">{mediaStats.totalMedia.toLocaleString()}</p>
</div>
<div class="stat-card">
<h3>Missing Colors</h3>
<p class="value">{mediaStats.missingColors.toLocaleString()}</p>
</div>
<div class="stat-card">
<h3>Missing Aspect Ratio</h3>
<p class="value">{mediaStats.missingAspectRatio.toLocaleString()}</p>
</div>
<div class="stat-card">
<h3>Outdated Thumbnails</h3>
<p class="value">{mediaStats.outdatedThumbnails.toLocaleString()}</p>
</div>
<div class="stat-card">
<h3>Grey Dominant Colors</h3>
<p class="value">{mediaStats.greyDominantColors.toLocaleString()}</p>
</div>
</div>
{/if}
<div class="regenerate-section">
<div class="action-card">
<div class="action-header">
<Palette size={24} />
<h2>Extract Dominant Colors</h2>
</div>
<p>Analyze images to extract dominant colors for better loading states. This process uses Cloudinary's color analysis API.</p>
<div class="action-details">
<ul>
<li>Extracts the primary color from each image</li>
<li>Stores full color palette data</li>
<li>Calculates and saves aspect ratios</li>
<li>Updates both Media and Photo records</li>
</ul>
</div>
<Button
variant="primary"
onclick={extractColors}
disabled={extractingColors || regeneratingThumbnails || reanalyzingColors}
icon={Play}
iconPosition="left"
>
{extractingColors ? 'Extracting Colors...' : 'Extract Colors'}
</Button>
</div>
<div class="action-card">
<div class="action-header">
<Image size={24} />
<h2>Regenerate Thumbnails</h2>
</div>
<p>Update thumbnails to maintain aspect ratio with 800px on the long edge instead of fixed 800x600 dimensions.</p>
<div class="action-details">
<ul>
<li>Preserves original aspect ratios</li>
<li>Sets longest edge to 800px</li>
<li>Updates thumbnail URLs in database</li>
<li>Processes only images with outdated thumbnails</li>
</ul>
</div>
<Button
variant="primary"
onclick={regenerateThumbnails}
disabled={extractingColors || regeneratingThumbnails || reanalyzingColors}
icon={Play}
iconPosition="left"
>
{regeneratingThumbnails ? 'Regenerating Thumbnails...' : 'Regenerate Thumbnails'}
</Button>
</div>
<div class="action-card">
<div class="action-header">
<Sparkles size={24} />
<h2>Smart Color Reanalysis</h2>
</div>
<p>Use advanced color detection to pick vibrant subject colors instead of background greys.</p>
<div class="action-details">
<ul>
<li>Analyzes existing color data intelligently</li>
<li>Prefers vibrant colors from subjects</li>
<li>Avoids grey backgrounds automatically</li>
<li>Updates both Media and Photo records</li>
</ul>
</div>
<Button
variant="primary"
onclick={reanalyzeColors}
disabled={extractingColors || regeneratingThumbnails || reanalyzingColors}
icon={Play}
iconPosition="left"
>
{reanalyzingColors ? 'Reanalyzing Colors...' : 'Reanalyze Colors'}
</Button>
</div>
</div>
</AdminPage>
<!-- Results Modal -->
<Modal bind:isOpen={showResultsModal}>
<div class="modal-content">
<div class="modal-header">
<h2>
{colorExtractionResults ? 'Color Extraction Results' : thumbnailResults ? 'Thumbnail Regeneration Results' : 'Color Reanalysis Results'}
</h2>
</div>
{#if colorExtractionResults}
<div class="results">
<p><strong>Processed:</strong> {colorExtractionResults.processed} media items</p>
<p><strong>Succeeded:</strong> {colorExtractionResults.succeeded}</p>
<p><strong>Failed:</strong> {colorExtractionResults.failed}</p>
<p><strong>Photos Updated:</strong> {colorExtractionResults.photosUpdated}</p>
{#if colorExtractionResults.errors.length > 0}
<div class="errors-section">
<h3>Errors:</h3>
<ul>
{#each colorExtractionResults.errors.slice(0, 10) as error}
<li>{error}</li>
{/each}
{#if colorExtractionResults.errors.length > 10}
<li>... and {colorExtractionResults.errors.length - 10} more errors</li>
{/if}
</ul>
</div>
{/if}
</div>
{/if}
{#if thumbnailResults}
<div class="results">
<p><strong>Processed:</strong> {thumbnailResults.processed} media items</p>
<p><strong>Succeeded:</strong> {thumbnailResults.succeeded}</p>
<p><strong>Failed:</strong> {thumbnailResults.failed}</p>
{#if thumbnailResults.errors.length > 0}
<div class="errors-section">
<h3>Errors:</h3>
<ul>
{#each thumbnailResults.errors.slice(0, 10) as error}
<li>{error}</li>
{/each}
{#if thumbnailResults.errors.length > 10}
<li>... and {thumbnailResults.errors.length - 10} more errors</li>
{/if}
</ul>
</div>
{/if}
</div>
{/if}
{#if reanalysisResults}
<div class="results">
<p><strong>Processed:</strong> {reanalysisResults.processed} media items</p>
<p><strong>Updated:</strong> {reanalysisResults.updated} (colors improved)</p>
<p><strong>Skipped:</strong> {reanalysisResults.skipped} (already optimal)</p>
{#if reanalysisResults.errors.length > 0}
<div class="errors-section">
<h3>Errors:</h3>
<ul>
{#each reanalysisResults.errors.slice(0, 10) as error}
<li>{error}</li>
{/each}
{#if reanalysisResults.errors.length > 10}
<li>... and {reanalysisResults.errors.length - 10} more errors</li>
{/if}
</ul>
</div>
{/if}
</div>
{/if}
<div class="modal-actions">
<Button variant="primary" onclick={() => {
showResultsModal = false
colorExtractionResults = null
thumbnailResults = null
reanalysisResults = null
}}>Close</Button>
</div>
</div>
</Modal>
<style lang="scss">
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: $grey-10;
}
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: transparent;
border: 1px solid $grey-85;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
color: $grey-30;
&:hover {
background: $grey-95;
border-color: $grey-70;
color: $grey-10;
}
:global(svg) {
width: 20px;
height: 20px;
}
}
.error-message {
background: rgba($red-60, 0.1);
border: 1px solid rgba($red-60, 0.2);
border-radius: 8px;
padding: 1rem;
margin-bottom: 2rem;
p {
margin: 0;
color: $red-60;
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: $grey-97;
border: 1px solid $grey-90;
border-radius: 8px;
padding: 1.5rem;
h3 {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: $grey-30;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.value {
margin: 0;
font-size: 2rem;
font-weight: 600;
color: $grey-10;
}
}
.regenerate-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.action-card {
background: $grey-100;
border: 1px solid $grey-90;
border-radius: 12px;
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
.action-header {
display: flex;
align-items: center;
gap: 1rem;
:global(svg) {
color: $primary-color;
}
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: $grey-10;
}
}
p {
margin: 0;
color: $grey-30;
line-height: 1.6;
}
.action-details {
background: $grey-97;
border-radius: 8px;
padding: 1rem;
ul {
margin: 0;
padding-left: 1.5rem;
list-style-type: disc;
li {
margin: 0.25rem 0;
font-size: 0.875rem;
color: $grey-30;
}
}
}
}
.modal-content {
display: flex;
flex-direction: column;
padding: 1.5rem;
min-width: 500px;
max-width: 600px;
}
.modal-header {
margin-bottom: 1.5rem;
h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: $grey-10;
}
}
.results {
display: flex;
flex-direction: column;
gap: 0.75rem;
p {
margin: 0;
font-size: 0.875rem;
color: $grey-30;
strong {
color: $grey-10;
}
}
}
.errors-section {
margin-top: 1rem;
padding: 1rem;
background: rgba($red-60, 0.1);
border-radius: 8px;
border: 1px solid rgba($red-60, 0.2);
h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
color: $red-60;
}
ul {
margin: 0;
padding-left: 1.5rem;
list-style-type: disc;
li {
font-size: 0.75rem;
color: $grey-30;
margin: 0.25rem 0;
}
}
}
.modal-actions {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid $grey-90;
}
</style>

View file

@ -0,0 +1,179 @@
import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger'
import { v2 as cloudinary } from 'cloudinary'
import { extractPublicId } from '$lib/server/cloudinary'
import { selectBestDominantColor } from '$lib/server/color-utils'
export const POST: RequestHandler = async (event) => {
// Check authentication
if (!checkAdminAuth(event)) {
return errorResponse('Unauthorized', 401)
}
try {
// Get media items without dominant color
const mediaWithoutColor = await prisma.media.findMany({
where: {
dominantColor: null,
mimeType: {
startsWith: 'image/'
}
},
select: {
id: true,
url: true,
width: true,
height: true
}
})
logger.info(`Found ${mediaWithoutColor.length} media items without color data`)
const results = {
processed: 0,
succeeded: 0,
failed: 0,
errors: [] as string[]
}
// Process each media item
for (const media of mediaWithoutColor) {
try {
// Extract public ID from URL
const publicId = extractPublicId(media.url)
if (!publicId) {
results.failed++
results.errors.push(`Could not extract public ID from: ${media.url}`)
continue
}
// Skip local files
if (publicId.startsWith('local/')) {
// For local files, just calculate aspect ratio
if (media.width && media.height) {
await prisma.media.update({
where: { id: media.id },
data: {
aspectRatio: media.width / media.height
}
})
results.succeeded++
} else {
results.failed++
}
results.processed++
continue
}
// Fetch resource details from Cloudinary with colors
const resource = await cloudinary.api.resource(publicId, {
colors: true,
resource_type: 'image'
})
// Extract dominant color using smart selection
let dominantColor: string | undefined
if (resource.colors && Array.isArray(resource.colors) && resource.colors.length > 0) {
dominantColor = selectBestDominantColor(resource.colors, {
minPercentage: 2,
preferVibrant: true,
excludeGreys: false, // Keep greys as fallback
preferBrighter: true
})
}
// Calculate aspect ratio
const aspectRatio = resource.width && resource.height
? resource.width / resource.height
: media.width && media.height
? media.width / media.height
: undefined
// Update database
await prisma.media.update({
where: { id: media.id },
data: {
dominantColor,
colors: resource.colors,
aspectRatio,
// Update dimensions if they were missing
width: resource.width || media.width,
height: resource.height || media.height
}
})
results.succeeded++
results.processed++
// Log progress every 10 items
if (results.processed % 10 === 0) {
logger.info(`Color extraction progress: ${results.processed}/${mediaWithoutColor.length}`)
}
// Add a small delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 100))
} catch (error) {
results.failed++
results.processed++
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
results.errors.push(`Media ID ${media.id}: ${errorMsg}`)
logger.error(`Failed to extract colors for media ${media.id}:`, {
error: error as Error,
url: media.url,
publicId: extractPublicId(media.url)
})
}
}
// Also update photos table if needed
const photosWithoutColor = await prisma.photo.findMany({
where: {
dominantColor: null,
mediaId: {
not: null
}
},
include: {
media: {
select: {
dominantColor: true,
colors: true,
aspectRatio: true
}
}
}
})
// Update photos with their media's color data
for (const photo of photosWithoutColor) {
if (photo.media && photo.media.dominantColor) {
await prisma.photo.update({
where: { id: photo.id },
data: {
dominantColor: photo.media.dominantColor,
colors: photo.media.colors,
aspectRatio: photo.media.aspectRatio
}
})
}
}
logger.info('Color extraction completed', results)
return jsonResponse({
message: 'Color extraction completed',
...results,
photosUpdated: photosWithoutColor.length
})
} catch (error) {
logger.error('Color extraction error', error as Error)
return errorResponse(
`Color extraction failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
500
)
}
}

View file

@ -0,0 +1,80 @@
import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger'
import { isGreyColor } from '$lib/server/color-utils'
export const GET: RequestHandler = async (event) => {
// Check authentication
if (!checkAdminAuth(event)) {
return errorResponse('Unauthorized', 401)
}
try {
// Get total media count
const totalMedia = await prisma.media.count()
// Count media missing dominant color
const missingColors = await prisma.media.count({
where: {
dominantColor: null,
mimeType: {
startsWith: 'image/'
}
}
})
// Count media missing aspect ratio
const missingAspectRatio = await prisma.media.count({
where: {
aspectRatio: null,
mimeType: {
startsWith: 'image/'
}
}
})
// Count media with outdated thumbnails (800x600 fixed size)
const outdatedThumbnails = await prisma.media.count({
where: {
thumbnailUrl: {
contains: 'w_800,h_600,c_fill'
},
mimeType: {
startsWith: 'image/'
}
}
})
// Count media with grey dominant colors
const mediaWithColors = await prisma.media.findMany({
where: {
dominantColor: { not: null },
mimeType: { startsWith: 'image/' }
},
select: { dominantColor: true }
})
const greyDominantColors = mediaWithColors.filter(
media => media.dominantColor && isGreyColor(media.dominantColor)
).length
const stats = {
totalMedia,
missingColors,
missingAspectRatio,
outdatedThumbnails,
greyDominantColors
}
logger.info('Media stats fetched', stats)
return jsonResponse(stats)
} catch (error) {
logger.error('Failed to fetch media stats', error as Error)
return errorResponse(
`Failed to fetch media stats: ${error instanceof Error ? error.message : 'Unknown error'}`,
500
)
}
}

View file

@ -0,0 +1,139 @@
import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth, parseRequestBody } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger'
import { selectBestDominantColor, getVibrantPalette } from '$lib/server/color-utils'
export const POST: RequestHandler = async (event) => {
// Check authentication
if (!checkAdminAuth(event)) {
return errorResponse('Unauthorized', 401)
}
try {
const body = await parseRequestBody<{ mediaId: number }>(event.request)
if (!body?.mediaId) {
return errorResponse('Media ID is required', 400)
}
// Get media with existing color data
const media = await prisma.media.findUnique({
where: { id: body.mediaId },
select: {
id: true,
filename: true,
colors: true,
dominantColor: true
}
})
if (!media) {
return errorResponse('Media not found', 404)
}
if (!media.colors || !Array.isArray(media.colors)) {
return errorResponse('No color data available for this media', 400)
}
// Reanalyze colors with different strategies
const strategies = {
// Default: balanced approach with brightness preference
default: selectBestDominantColor(media.colors as Array<[string, number]>, {
minPercentage: 2,
preferVibrant: true,
excludeGreys: false,
preferBrighter: true
}),
// Vibrant: exclude greys completely, prefer bright
vibrant: selectBestDominantColor(media.colors as Array<[string, number]>, {
minPercentage: 1,
preferVibrant: true,
excludeGreys: true,
preferBrighter: true
}),
// Prominent: focus on larger color areas
prominent: selectBestDominantColor(media.colors as Array<[string, number]>, {
minPercentage: 5,
preferVibrant: false,
excludeGreys: false,
preferBrighter: true
})
}
// Get vibrant palette
const vibrantPalette = getVibrantPalette(media.colors as Array<[string, number]>)
// Return analysis results
return jsonResponse({
media: {
id: media.id,
filename: media.filename,
currentDominantColor: media.dominantColor,
colors: media.colors
},
analysis: {
strategies,
vibrantPalette,
recommendation: strategies.default
}
})
} catch (error) {
logger.error('Color reanalysis error', error as Error)
return errorResponse(
`Color reanalysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
500
)
}
}
// PUT endpoint to update with new color
export const PUT: RequestHandler = async (event) => {
// Check authentication
if (!checkAdminAuth(event)) {
return errorResponse('Unauthorized', 401)
}
try {
const body = await parseRequestBody<{
mediaId: number
dominantColor: string
}>(event.request)
if (!body?.mediaId || !body?.dominantColor) {
return errorResponse('Media ID and dominant color are required', 400)
}
// Update media
const updated = await prisma.media.update({
where: { id: body.mediaId },
data: { dominantColor: body.dominantColor }
})
// Also update any photos using this media
await prisma.photo.updateMany({
where: { mediaId: body.mediaId },
data: { dominantColor: body.dominantColor }
})
logger.info('Dominant color updated', {
mediaId: body.mediaId,
color: body.dominantColor
})
return jsonResponse({
success: true,
media: updated
})
} catch (error) {
logger.error('Color update error', error as Error)
return errorResponse(
`Color update failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
500
)
}
}

View file

@ -0,0 +1,109 @@
import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger'
import { selectBestDominantColor, isGreyColor } from '$lib/server/color-utils'
export const POST: RequestHandler = async (event) => {
// Check authentication
if (!checkAdminAuth(event)) {
return errorResponse('Unauthorized', 401)
}
try {
const results = {
processed: 0,
updated: 0,
skipped: 0,
errors: [] as string[]
}
// Get all media with color data (prioritize those with grey dominant colors)
const mediaWithColors = await prisma.media.findMany({
where: {
colors: { not: null },
mimeType: { startsWith: 'image/' }
},
select: {
id: true,
filename: true,
dominantColor: true,
colors: true
}
})
logger.info(`Found ${mediaWithColors.length} media items with color data`)
// Process each media item
for (const media of mediaWithColors) {
try {
results.processed++
if (!media.colors || !Array.isArray(media.colors)) {
results.skipped++
continue
}
const currentColor = media.dominantColor
const colors = media.colors as Array<[string, number]>
// Calculate new dominant color with smart selection
const newColor = selectBestDominantColor(colors, {
minPercentage: 2,
preferVibrant: true,
excludeGreys: false,
preferBrighter: true
})
// Only update if the color changed significantly
// (either was grey and now isn't, or is a different color)
const wasGrey = currentColor ? isGreyColor(currentColor) : false
const isNewGrey = isGreyColor(newColor)
const changed = currentColor !== newColor && (wasGrey || !isNewGrey)
if (changed) {
// Update media
await prisma.media.update({
where: { id: media.id },
data: { dominantColor: newColor }
})
// Update related photos
await prisma.photo.updateMany({
where: { mediaId: media.id },
data: { dominantColor: newColor }
})
results.updated++
logger.info(`Updated dominant color for ${media.filename}`, {
from: currentColor,
to: newColor,
wasGrey,
isNewGrey
})
} else {
results.skipped++
}
} catch (error) {
const errorMessage = `Media ID ${media.id}: ${error instanceof Error ? error.message : 'Unknown error'}`
results.errors.push(errorMessage)
logger.error('Failed to reanalyze colors for media', {
mediaId: media.id,
error: error as Error
})
}
}
logger.info('Color reanalysis completed', results)
return jsonResponse(results)
} catch (error) {
logger.error('Color reanalysis error', error as Error)
return errorResponse(
`Color reanalysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
500
)
}
}

View file

@ -0,0 +1,172 @@
import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger'
import { v2 as cloudinary } from 'cloudinary'
import { extractPublicId } from '$lib/server/cloudinary'
export const POST: RequestHandler = async (event) => {
// Check authentication
if (!checkAdminAuth(event)) {
return errorResponse('Unauthorized', 401)
}
try {
// Get media items with outdated thumbnails
const mediaWithOldThumbnails = await prisma.media.findMany({
where: {
thumbnailUrl: {
contains: 'w_800,h_600,c_fill'
},
mimeType: {
startsWith: 'image/'
}
},
select: {
id: true,
url: true,
thumbnailUrl: true,
width: true,
height: true
}
})
logger.info(`Found ${mediaWithOldThumbnails.length} media items with outdated thumbnails`)
const results = {
processed: 0,
succeeded: 0,
failed: 0,
errors: [] as string[]
}
// Process each media item
for (const media of mediaWithOldThumbnails) {
try {
// Extract public ID from URL
const publicId = extractPublicId(media.url)
if (!publicId) {
results.failed++
results.errors.push(`Could not extract public ID from: ${media.url}`)
continue
}
// Skip local files
if (publicId.startsWith('local/')) {
results.processed++
results.succeeded++
continue
}
// Generate new thumbnail URL with aspect ratio preservation
// 800px on the longest edge
let thumbnailUrl: string
if (media.width && media.height) {
// Use actual dimensions if available
if (media.width > media.height) {
// Landscape: limit width
thumbnailUrl = cloudinary.url(publicId, {
secure: true,
width: 800,
crop: 'limit',
quality: 'auto:good',
fetch_format: 'auto'
})
} else {
// Portrait or square: limit height
thumbnailUrl = cloudinary.url(publicId, {
secure: true,
height: 800,
crop: 'limit',
quality: 'auto:good',
fetch_format: 'auto'
})
}
} else {
// Fallback: use longest edge limiting
thumbnailUrl = cloudinary.url(publicId, {
secure: true,
width: 800,
height: 800,
crop: 'limit',
quality: 'auto:good',
fetch_format: 'auto'
})
}
// Update database
await prisma.media.update({
where: { id: media.id },
data: {
thumbnailUrl
}
})
results.succeeded++
results.processed++
// Log progress every 10 items
if (results.processed % 10 === 0) {
logger.info(`Thumbnail regeneration progress: ${results.processed}/${mediaWithOldThumbnails.length}`)
}
// Add a small delay to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 50))
} catch (error) {
results.failed++
results.processed++
const errorMsg = error instanceof Error ? error.message : 'Unknown error'
results.errors.push(`Media ID ${media.id}: ${errorMsg}`)
logger.error(`Failed to regenerate thumbnail for media ${media.id}:`, error as Error)
}
}
// Also update photos table thumbnails if they have the old format
const photosWithOldThumbnails = await prisma.photo.findMany({
where: {
thumbnailUrl: {
contains: 'w_800,h_600,c_fill'
},
mediaId: {
not: null
}
},
include: {
media: {
select: {
thumbnailUrl: true
}
}
}
})
// Update photos with their media's new thumbnail URL
for (const photo of photosWithOldThumbnails) {
if (photo.media && photo.media.thumbnailUrl) {
await prisma.photo.update({
where: { id: photo.id },
data: {
thumbnailUrl: photo.media.thumbnailUrl
}
})
}
}
logger.info('Thumbnail regeneration completed', results)
return jsonResponse({
message: 'Thumbnail regeneration completed',
...results,
photosUpdated: photosWithOldThumbnails.length
})
} catch (error) {
logger.error('Thumbnail regeneration error', error as Error)
return errorResponse(
`Thumbnail regeneration failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
500
)
}
}

View file

@ -179,6 +179,9 @@ export const GET: RequestHandler = async (event) => {
thumbnailUrl: true,
width: true,
height: true,
dominantColor: true,
colors: true,
aspectRatio: true,
usedIn: true,
isPhotography: true,
createdAt: true,

View file

@ -23,8 +23,8 @@ export const GET: RequestHandler = async (event) => {
id: true,
filename: true,
url: true,
altText: true,
description: true,
dominantColor: true,
isPhotography: true
}
})
@ -41,8 +41,8 @@ export const GET: RequestHandler = async (event) => {
id: media.id,
filename: media.filename,
url: media.url,
altText: media.altText,
description: media.description,
dominantColor: media.dominantColor,
isPhotography: media.isPhotography
},
usage: usage,

View file

@ -161,6 +161,9 @@ export const POST: RequestHandler = async (event) => {
thumbnailUrl: uploadResult.thumbnailUrl,
width: uploadResult.width,
height: uploadResult.height,
dominantColor: uploadResult.dominantColor,
colors: uploadResult.colors,
aspectRatio: uploadResult.aspectRatio,
exifData: exifData,
description: description?.trim() || null,
isPhotography: isPhotography

View file

@ -29,6 +29,9 @@ export const GET: RequestHandler = async (event) => {
thumbnailUrl: true,
width: true,
height: true,
dominantColor: true,
colors: true,
aspectRatio: true,
photoCaption: true,
exifData: true
}
@ -55,6 +58,9 @@ export const GET: RequestHandler = async (event) => {
thumbnailUrl: true,
width: true,
height: true,
dominantColor: true,
colors: true,
aspectRatio: true,
photoCaption: true,
photoTitle: true,
photoDescription: true,
@ -116,7 +122,10 @@ export const GET: RequestHandler = async (event) => {
alt: firstMedia.photoCaption || album.title,
caption: firstMedia.photoCaption || undefined,
width: firstMedia.width || 400,
height: firstMedia.height || 400
height: firstMedia.height || 400,
dominantColor: firstMedia.dominantColor || undefined,
colors: firstMedia.colors || undefined,
aspectRatio: firstMedia.aspectRatio || undefined
},
photos: album.media.map((albumMedia) => ({
id: `media-${albumMedia.media.id}`,
@ -124,7 +133,10 @@ export const GET: RequestHandler = async (event) => {
alt: albumMedia.media.photoCaption || albumMedia.media.filename,
caption: albumMedia.media.photoCaption || undefined,
width: albumMedia.media.width || 400,
height: albumMedia.media.height || 400
height: albumMedia.media.height || 400,
dominantColor: albumMedia.media.dominantColor || undefined,
colors: albumMedia.media.colors || undefined,
aspectRatio: albumMedia.media.aspectRatio || undefined
})),
createdAt: albumDate.toISOString()
}
@ -142,6 +154,9 @@ export const GET: RequestHandler = async (event) => {
caption: media.photoCaption || undefined,
width: media.width || 400,
height: media.height || 400,
dominantColor: media.dominantColor || undefined,
colors: media.colors || undefined,
aspectRatio: media.aspectRatio || undefined,
createdAt: photoDate.toISOString()
}
})

View file

@ -1,11 +1,19 @@
<script lang="ts">
import MasonryPhotoGrid from '$components/MasonryPhotoGrid.svelte'
import SingleColumnPhotoGrid from '$components/SingleColumnPhotoGrid.svelte'
import TwoColumnPhotoGrid from '$components/TwoColumnPhotoGrid.svelte'
import HorizontalScrollPhotoGrid from '$components/HorizontalScrollPhotoGrid.svelte'
import LoadingSpinner from '$components/admin/LoadingSpinner.svelte'
import ViewModeSelector from '$components/ViewModeSelector.svelte'
import type { ViewMode } from '$components/ViewModeSelector.svelte'
import { InfiniteLoader, LoaderState } from 'svelte-infinite'
import { generateMetaTags } from '$lib/utils/metadata'
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { browser } from '$app/environment'
import type { PageData } from './$types'
import type { PhotoItem } from '$lib/types/photos'
import type { Snapshot } from './$types'
const { data }: { data: PageData } = $props()
@ -15,6 +23,13 @@
// Initialize state with server-side data
let allPhotoItems = $state<PhotoItem[]>(data.photoItems || [])
let currentOffset = $state(data.pagination?.limit || 20)
let containerWidth = $state<'normal' | 'wide'>('normal')
// Initialize view mode from URL or default
const urlMode = $page.url.searchParams.get('view') as ViewMode
let viewMode = $state<ViewMode>(
urlMode && ['masonry', 'single', 'two-column', 'horizontal'].includes(urlMode) ? urlMode : 'masonry'
)
// Track loaded photo IDs to prevent duplicates
let loadedPhotoIds = $state(new Set(data.photoItems?.map((item) => item.id) || []))
@ -24,6 +39,53 @@
// Error message for retry display
let lastError = $state<string>('')
let isLoadingAll = $state(false)
// Update URL when view mode changes
async function handleViewModeChange(mode: ViewMode) {
viewMode = mode
if (browser) {
const url = new URL($page.url)
if (mode === 'masonry') {
url.searchParams.delete('view')
} else {
url.searchParams.set('view', mode)
}
goto(url.toString(), { replaceState: true, keepFocus: true })
// Load all remaining photos for horizontal mode
if (mode === 'horizontal' && data.pagination?.hasMore && !isLoadingAll) {
loadAllPhotos()
}
}
}
// Load all photos for horizontal mode
async function loadAllPhotos() {
if (isLoadingAll) return
isLoadingAll = true
try {
while (currentOffset < (data.pagination?.total || Infinity)) {
const response = await fetch(`/api/photos?limit=50&offset=${currentOffset}`)
if (!response.ok) break
const result = await response.json()
const newItems = (result.photoItems || []).filter(
(item: PhotoItem) => !loadedPhotoIds.has(item.id)
)
newItems.forEach((item: PhotoItem) => loadedPhotoIds.add(item.id))
allPhotoItems = [...allPhotoItems, ...newItems]
currentOffset += result.pagination?.limit || 50
if (!result.pagination?.hasMore) break
}
} finally {
isLoadingAll = false
loaderState.complete()
}
}
// Load more photos
async function loadMore() {
@ -66,9 +128,16 @@
}
// Initialize loader state based on initial data
let hasInitialized = false
$effect(() => {
if (!data.pagination?.hasMore) {
loaderState.complete()
if (!hasInitialized) {
hasInitialized = true
if (!data.pagination?.hasMore) {
loaderState.complete()
} else if (viewMode === 'horizontal') {
// Load all photos for horizontal mode on initial load
loadAllPhotos()
}
}
})
@ -80,6 +149,37 @@
url: pageUrl
})
)
// Snapshot to preserve scroll position
export const snapshot: Snapshot<{
scrollY: number
horizontalScroll: number | undefined
}> = {
capture: () => {
if (!browser) return { scrollY: 0, horizontalScroll: undefined }
return {
scrollY: window.scrollY,
horizontalScroll: document.querySelector('.horizontal-scroll')?.scrollLeft
}
},
restore: (data) => {
if (!browser) return
// Small delay to ensure content is rendered
setTimeout(() => {
if (data.scrollY) {
window.scrollTo(0, data.scrollY)
}
if (data.horizontalScroll !== undefined) {
const element = document.querySelector('.horizontal-scroll')
if (element) {
element.scrollLeft = data.horizontalScroll
}
}
}, 10)
}
}
</script>
<svelte:head>
@ -100,7 +200,11 @@
<link rel="canonical" href={metaTags.other.canonical} />
</svelte:head>
<div class="photos-container">
<div
class="photos-container"
class:wide={containerWidth === 'wide'}
class:horizontal-mode={viewMode === 'horizontal'}
>
{#if error}
<div class="error-container">
<div class="error-message">
@ -116,44 +220,68 @@
</div>
</div>
{:else}
<MasonryPhotoGrid photoItems={allPhotoItems} />
<ViewModeSelector
mode={viewMode}
width={containerWidth}
onModeChange={handleViewModeChange}
onWidthChange={(width) => (containerWidth = width)}
/>
<InfiniteLoader
{loaderState}
triggerLoad={loadMore}
intersectionOptions={{ rootMargin: '0px 0px 200px 0px' }}
>
<!-- Empty content since we're rendering the grid above -->
<div style="height: 1px;"></div>
<div class="grid-container" class:full-width={viewMode === 'horizontal'}>
{#if viewMode === 'masonry'}
<MasonryPhotoGrid photoItems={allPhotoItems} />
{:else if viewMode === 'single'}
<SingleColumnPhotoGrid photoItems={allPhotoItems} />
{:else if viewMode === 'two-column'}
<TwoColumnPhotoGrid photoItems={allPhotoItems} />
{:else if viewMode === 'horizontal'}
<HorizontalScrollPhotoGrid photoItems={allPhotoItems} />
{#if isLoadingAll}
<div class="loading-more-indicator">
<LoadingSpinner size="small" text="Loading all photos..." />
</div>
{/if}
{/if}
</div>
{#snippet loading()}
<div class="loading-container">
<LoadingSpinner size="medium" text="Loading more photos..." />
</div>
{/snippet}
{#if viewMode !== 'horizontal'}
<InfiniteLoader
{loaderState}
triggerLoad={loadMore}
intersectionOptions={{ rootMargin: '0px 0px 200px 0px' }}
>
<!-- Empty content since we're rendering the grid above -->
<div style="height: 1px;"></div>
{#snippet error()}
<div class="error-retry">
<p class="error-text">{lastError || 'Failed to load photos'}</p>
<button
class="retry-button"
onclick={() => {
lastError = ''
loaderState.reset()
loadMore()
}}
>
Try again
</button>
</div>
{/snippet}
{#snippet loading()}
<div class="loading-container">
<LoadingSpinner size="medium" text="Loading more photos..." />
</div>
{/snippet}
{#snippet noData()}
<div class="end-message">
<p>You've reached the end</p>
</div>
{/snippet}
</InfiniteLoader>
{#snippet error()}
<div class="error-retry">
<p class="error-text">{lastError || 'Failed to load photos'}</p>
<button
class="retry-button"
onclick={() => {
lastError = ''
loaderState.reset()
loadMore()
}}
>
Try again
</button>
</div>
{/snippet}
{#snippet noData()}
<div class="end-message">
<p>You've reached the end</p>
</div>
{/snippet}
</InfiniteLoader>
{/if}
{/if}
</div>
@ -163,6 +291,34 @@
max-width: 700px;
margin: 0 auto;
padding: 0 $unit-3x;
transition: max-width 0.3s ease;
&.wide {
max-width: 1100px;
}
&.horizontal-mode {
max-width: none;
padding-left: 0;
padding-right: 0;
:global(.view-mode-selector) {
max-width: 700px;
margin-left: auto;
margin-right: auto;
}
&.wide :global(.view-mode-selector) {
max-width: 1100px;
}
}
:global(.view-mode-selector) {
margin-bottom: $unit-3x;
position: sticky;
top: $unit-2x;
z-index: 10;
}
@include breakpoint('phone') {
padding: 0 $unit-2x;
@ -211,6 +367,17 @@
margin-top: $unit-4x;
}
.loading-more-indicator {
position: fixed;
bottom: $unit-3x;
right: $unit-3x;
background: $grey-100;
padding: $unit-2x $unit-3x;
border-radius: $corner-radius-lg;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 20;
}
.end-message {
text-align: center;
padding: $unit-6x 0;