diff --git a/.gitignore b/.gitignore index f926895..99d9898 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ vite.config.ts.timestamp-* *storybook.log storybook-static +backups/ diff --git a/package.json b/package.json index c912e7c..56ce453 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/prisma/migrations/20240618_add_color_fields/migration.sql b/prisma/migrations/20240618_add_color_fields/migration.sql new file mode 100644 index 0000000..45d2003 --- /dev/null +++ b/prisma/migrations/20240618_add_color_fields/migration.sql @@ -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; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 42ef646..3c98db4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..730ea96 --- /dev/null +++ b/scripts/README.md @@ -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 [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. \ No newline at end of file diff --git a/scripts/analyze-image-colors.ts b/scripts/analyze-image-colors.ts new file mode 100644 index 0000000..3d47187 --- /dev/null +++ b/scripts/analyze-image-colors.ts @@ -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) \ No newline at end of file diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100755 index 0000000..08b58c7 --- /dev/null +++ b/scripts/backup-db.sh @@ -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" \ No newline at end of file diff --git a/scripts/check-photo-colors.ts b/scripts/check-photo-colors.ts new file mode 100644 index 0000000..646a33d --- /dev/null +++ b/scripts/check-photo-colors.ts @@ -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() \ No newline at end of file diff --git a/scripts/find-image-colors.ts b/scripts/find-image-colors.ts new file mode 100644 index 0000000..7135414 --- /dev/null +++ b/scripts/find-image-colors.ts @@ -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() \ No newline at end of file diff --git a/scripts/list-backups.sh b/scripts/list-backups.sh new file mode 100755 index 0000000..03a6cdc --- /dev/null +++ b/scripts/list-backups.sh @@ -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" \ No newline at end of file diff --git a/scripts/reanalyze-colors.ts b/scripts/reanalyze-colors.ts new file mode 100755 index 0000000..dc4ce05 --- /dev/null +++ b/scripts/reanalyze-colors.ts @@ -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 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 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) \ No newline at end of file diff --git a/scripts/restore-db.sh b/scripts/restore-db.sh new file mode 100755 index 0000000..df79ec9 --- /dev/null +++ b/scripts/restore-db.sh @@ -0,0 +1,168 @@ +#!/bin/bash + +# Database Restore Script +# Usage: ./scripts/restore-db.sh [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 [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}" \ No newline at end of file diff --git a/src/assets/icons/view-horizontal.svg b/src/assets/icons/view-horizontal.svg new file mode 100644 index 0000000..f9a0647 --- /dev/null +++ b/src/assets/icons/view-horizontal.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/assets/icons/view-single.svg b/src/assets/icons/view-single.svg new file mode 100644 index 0000000..b3b07c4 --- /dev/null +++ b/src/assets/icons/view-single.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/assets/icons/view-two-column.svg b/src/assets/icons/view-two-column.svg new file mode 100644 index 0000000..e51fda8 --- /dev/null +++ b/src/assets/icons/view-two-column.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/icons/width-normal.svg b/src/assets/icons/width-normal.svg new file mode 100644 index 0000000..5962567 --- /dev/null +++ b/src/assets/icons/width-normal.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/icons/width-wide.svg b/src/assets/icons/width-wide.svg new file mode 100644 index 0000000..a325d99 --- /dev/null +++ b/src/assets/icons/width-wide.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/lib/components/HorizontalScrollPhotoGrid.svelte b/src/lib/components/HorizontalScrollPhotoGrid.svelte new file mode 100644 index 0000000..a13d7fe --- /dev/null +++ b/src/lib/components/HorizontalScrollPhotoGrid.svelte @@ -0,0 +1,76 @@ + + +
+ {#each photoItems as item} + {#if isAlbum(item)} + + {item.title} +

{item.title}

+
+ {:else} + {@const mediaId = item.id.replace(/^(media|photo)-/, '')} + + {item.alt} + {#if item.caption} +

{item.caption}

+ {/if} +
+ {/if} + {/each} +
+ + diff --git a/src/lib/components/PhotoItem.svelte b/src/lib/components/PhotoItem.svelte index 87b9624..811b3ec 100644 --- a/src/lib/components/PhotoItem.svelte +++ b/src/lib/components/PhotoItem.svelte @@ -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}` + : '' + )
@@ -46,7 +56,7 @@
-
+
{photo.alt} - {#if !imageLoaded} -
- {/if} +
@@ -68,7 +76,7 @@
{:else} -
+
{photo.alt} - {#if !imageLoaded} -
- {/if} +
{/if} @@ -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; - } - } diff --git a/src/lib/components/SingleColumnPhotoGrid.svelte b/src/lib/components/SingleColumnPhotoGrid.svelte new file mode 100644 index 0000000..c21a21a --- /dev/null +++ b/src/lib/components/SingleColumnPhotoGrid.svelte @@ -0,0 +1,60 @@ + + +
+ {#each photoItems as item} +
+ + {#if !isAlbum(item) && item.caption} +
+

{item.caption}

+
+ {/if} +
+ {/each} +
+ + \ No newline at end of file diff --git a/src/lib/components/TwoColumnPhotoGrid.svelte b/src/lib/components/TwoColumnPhotoGrid.svelte new file mode 100644 index 0000000..866807c --- /dev/null +++ b/src/lib/components/TwoColumnPhotoGrid.svelte @@ -0,0 +1,51 @@ + + +
+
+ {#each column1 as item} + + {/each} +
+
+ {#each column2 as item} + + {/each} +
+
+ + \ No newline at end of file diff --git a/src/lib/components/ViewModeSelector.svelte b/src/lib/components/ViewModeSelector.svelte new file mode 100644 index 0000000..cf2a21f --- /dev/null +++ b/src/lib/components/ViewModeSelector.svelte @@ -0,0 +1,143 @@ + + +
+
+ + + + +
+ + {#if mode !== 'horizontal'} +
+ +
+ + +
+ {/if} +
+ + diff --git a/src/lib/components/admin/MediaDetailsModal.svelte b/src/lib/components/admin/MediaDetailsModal.svelte index 8790657..ccd0a07 100644 --- a/src/lib/components/admin/MediaDetailsModal.svelte +++ b/src/lib/components/admin/MediaDetailsModal.svelte @@ -309,6 +309,21 @@ {media.width} Ɨ {media.height}px
{/if} + {#if media.dominantColor} +
+ Dominant Color + + + {media.dominantColor} + +
+ {:else} + + {/if}
Uploaded {new Date(media.createdAt).toLocaleDateString()} @@ -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; diff --git a/src/lib/server/cloudinary.ts b/src/lib/server/cloudinary.ts index f0ec7b7..384767d 100644 --- a/src/lib/server/cloudinary.ts +++ b/src/lib/server/cloudinary.ts @@ -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 } diff --git a/src/lib/server/color-utils.ts b/src/lib/server/color-utils.ts new file mode 100644 index 0000000..e081459 --- /dev/null +++ b/src/lib/server/color-utils.ts @@ -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 + } +} \ No newline at end of file diff --git a/src/lib/types/photos.ts b/src/lib/types/photos.ts index 3acbbc0..4a6da12 100644 --- a/src/lib/types/photos.ts +++ b/src/lib/types/photos.ts @@ -16,6 +16,9 @@ export interface Photo { caption?: string width: number height: number + dominantColor?: string + colors?: any + aspectRatio?: number exif?: ExifData createdAt?: string } diff --git a/src/routes/admin/media/+page.svelte b/src/routes/admin/media/+page.svelte index 5dc3acd..a9fea07 100644 --- a/src/routes/admin/media/+page.svelte +++ b/src/routes/admin/media/+page.svelte @@ -1,5 +1,6 @@ + + + Regenerate Cloudinary - Admin @jedmund + + + +
+
+ +

Regenerate Cloudinary Data

+
+
+ + {#if error} +
+

{error}

+
+ {/if} + + {#if mediaStats} +
+
+

Total Media

+

{mediaStats.totalMedia.toLocaleString()}

+
+
+

Missing Colors

+

{mediaStats.missingColors.toLocaleString()}

+
+
+

Missing Aspect Ratio

+

{mediaStats.missingAspectRatio.toLocaleString()}

+
+
+

Outdated Thumbnails

+

{mediaStats.outdatedThumbnails.toLocaleString()}

+
+
+

Grey Dominant Colors

+

{mediaStats.greyDominantColors.toLocaleString()}

+
+
+ {/if} + +
+
+
+ +

Extract Dominant Colors

+
+

Analyze images to extract dominant colors for better loading states. This process uses Cloudinary's color analysis API.

+
+
    +
  • Extracts the primary color from each image
  • +
  • Stores full color palette data
  • +
  • Calculates and saves aspect ratios
  • +
  • Updates both Media and Photo records
  • +
+
+ +
+ +
+
+ +

Regenerate Thumbnails

+
+

Update thumbnails to maintain aspect ratio with 800px on the long edge instead of fixed 800x600 dimensions.

+
+
    +
  • Preserves original aspect ratios
  • +
  • Sets longest edge to 800px
  • +
  • Updates thumbnail URLs in database
  • +
  • Processes only images with outdated thumbnails
  • +
+
+ +
+ +
+
+ +

Smart Color Reanalysis

+
+

Use advanced color detection to pick vibrant subject colors instead of background greys.

+
+
    +
  • Analyzes existing color data intelligently
  • +
  • Prefers vibrant colors from subjects
  • +
  • Avoids grey backgrounds automatically
  • +
  • Updates both Media and Photo records
  • +
+
+ +
+
+
+ + + + + + + \ No newline at end of file diff --git a/src/routes/api/admin/cloudinary-extract-colors/+server.ts b/src/routes/api/admin/cloudinary-extract-colors/+server.ts new file mode 100644 index 0000000..736cc44 --- /dev/null +++ b/src/routes/api/admin/cloudinary-extract-colors/+server.ts @@ -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 + ) + } +} \ No newline at end of file diff --git a/src/routes/api/admin/media-stats/+server.ts b/src/routes/api/admin/media-stats/+server.ts new file mode 100644 index 0000000..0d8f8ea --- /dev/null +++ b/src/routes/api/admin/media-stats/+server.ts @@ -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 + ) + } +} \ No newline at end of file diff --git a/src/routes/api/admin/reanalyze-color/+server.ts b/src/routes/api/admin/reanalyze-color/+server.ts new file mode 100644 index 0000000..a04e824 --- /dev/null +++ b/src/routes/api/admin/reanalyze-color/+server.ts @@ -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 + ) + } +} \ No newline at end of file diff --git a/src/routes/api/admin/reanalyze-colors/+server.ts b/src/routes/api/admin/reanalyze-colors/+server.ts new file mode 100644 index 0000000..ef2a043 --- /dev/null +++ b/src/routes/api/admin/reanalyze-colors/+server.ts @@ -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 + ) + } +} \ No newline at end of file diff --git a/src/routes/api/admin/regenerate-thumbnails/+server.ts b/src/routes/api/admin/regenerate-thumbnails/+server.ts new file mode 100644 index 0000000..c6b0026 --- /dev/null +++ b/src/routes/api/admin/regenerate-thumbnails/+server.ts @@ -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 + ) + } +} \ No newline at end of file diff --git a/src/routes/api/media/+server.ts b/src/routes/api/media/+server.ts index bb54acc..68f48a8 100644 --- a/src/routes/api/media/+server.ts +++ b/src/routes/api/media/+server.ts @@ -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, diff --git a/src/routes/api/media/[id]/usage/+server.ts b/src/routes/api/media/[id]/usage/+server.ts index e715977..093d9d1 100644 --- a/src/routes/api/media/[id]/usage/+server.ts +++ b/src/routes/api/media/[id]/usage/+server.ts @@ -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, diff --git a/src/routes/api/media/upload/+server.ts b/src/routes/api/media/upload/+server.ts index 2106f0c..412ac11 100644 --- a/src/routes/api/media/upload/+server.ts +++ b/src/routes/api/media/upload/+server.ts @@ -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 diff --git a/src/routes/api/photos/+server.ts b/src/routes/api/photos/+server.ts index 9327a5f..428fa6d 100644 --- a/src/routes/api/photos/+server.ts +++ b/src/routes/api/photos/+server.ts @@ -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() } }) diff --git a/src/routes/photos/+page.svelte b/src/routes/photos/+page.svelte index d3dac2a..0461b68 100644 --- a/src/routes/photos/+page.svelte +++ b/src/routes/photos/+page.svelte @@ -1,11 +1,19 @@ @@ -100,7 +200,11 @@ -
+
{#if error}
@@ -116,44 +220,68 @@
{:else} - + (containerWidth = width)} + /> - - -
+
+ {#if viewMode === 'masonry'} + + {:else if viewMode === 'single'} + + {:else if viewMode === 'two-column'} + + {:else if viewMode === 'horizontal'} + + {#if isLoadingAll} +
+ +
+ {/if} + {/if} +
- {#snippet loading()} -
- -
- {/snippet} + {#if viewMode !== 'horizontal'} + + +
- {#snippet error()} -
-

{lastError || 'Failed to load photos'}

- -
- {/snippet} + {#snippet loading()} +
+ +
+ {/snippet} - {#snippet noData()} -
-

You've reached the end

-
- {/snippet} -
+ {#snippet error()} +
+

{lastError || 'Failed to load photos'}

+ +
+ {/snippet} + + {#snippet noData()} +
+

You've reached the end

+
+ {/snippet} +
+ {/if} {/if}
@@ -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;