From b2488bd3016bf5e75bc5ca45160a866e78f451b3 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Wed, 18 Jun 2025 10:26:14 +0100 Subject: [PATCH 01/11] Add ViewModeSelector component with width controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create ViewModeSelector component with masonry view mode button - Add width toggle controls (normal 700px / wide 900px) - Create width-normal and width-wide SVG icons - Integrate component into photos route with smooth transitions - Use SCSS variables throughout for consistent styling 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/assets/icons/width-normal.svg | 8 ++ src/assets/icons/width-wide.svg | 8 ++ src/lib/components/ViewModeSelector.svelte | 101 +++++++++++++++++++++ src/routes/photos/+page.svelte | 20 +++- 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/assets/icons/width-normal.svg create mode 100644 src/assets/icons/width-wide.svg create mode 100644 src/lib/components/ViewModeSelector.svelte diff --git a/src/assets/icons/width-normal.svg b/src/assets/icons/width-normal.svg new file mode 100644 index 0000000..8810d6b --- /dev/null +++ b/src/assets/icons/width-normal.svg @@ -0,0 +1,8 @@ + + + + + + + + \ 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..72d850c --- /dev/null +++ b/src/assets/icons/width-wide.svg @@ -0,0 +1,8 @@ + + + + + + + + \ 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..7bc5cf3 --- /dev/null +++ b/src/lib/components/ViewModeSelector.svelte @@ -0,0 +1,101 @@ + + +
+
+ +
+ +
+ +
+ + +
+
+ + diff --git a/src/routes/photos/+page.svelte b/src/routes/photos/+page.svelte index d3dac2a..c9acee5 100644 --- a/src/routes/photos/+page.svelte +++ b/src/routes/photos/+page.svelte @@ -1,6 +1,7 @@ + +
+ {#each photoItems as item} +
+ + {#if !isAlbum(item) && item.caption} +
+

{item.caption}

+
+ {/if} +
+ {/each} +
+ + \ No newline at end of file From 049b6be57f4ca6566fdf81b5c1890066836ca7a2 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 19 Jun 2025 01:55:04 +0100 Subject: [PATCH 03/11] Add new photo grid view components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SingleColumnPhotoGrid: Displays photos in a single centered column - TwoColumnPhotoGrid: Splits photos evenly between two columns - HorizontalScrollPhotoGrid: Shows photos in a horizontal scrolling view 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../HorizontalScrollPhotoGrid.svelte | 76 +++++++++++++++++++ src/lib/components/TwoColumnPhotoGrid.svelte | 51 +++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/lib/components/HorizontalScrollPhotoGrid.svelte create mode 100644 src/lib/components/TwoColumnPhotoGrid.svelte 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/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 From 26ef48fa9542cab4764b0cbb4832b30c344dd598 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 19 Jun 2025 01:55:18 +0100 Subject: [PATCH 04/11] Update ViewModeSelector with new view modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add two-column view mode option - Import new view mode icons - Hide selector on mobile devices - Update ViewMode type to include all options 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/components/ViewModeSelector.svelte | 54 +++++++++++++++++++--- 1 file changed, 48 insertions(+), 6 deletions(-) diff --git a/src/lib/components/ViewModeSelector.svelte b/src/lib/components/ViewModeSelector.svelte index 7bc5cf3..cf2a21f 100644 --- a/src/lib/components/ViewModeSelector.svelte +++ b/src/lib/components/ViewModeSelector.svelte @@ -1,32 +1,69 @@
- + + +
-
- -
+ {#if mode !== 'horizontal'} +
+ +
-
+
+ {/if}
\ 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 From aa0677090b785e106392def5bc6d6bf8f8580ac0 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 19 Jun 2025 01:59:23 +0100 Subject: [PATCH 09/11] feat: enhance photo loading with color placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use dominant color as placeholder background while images load - Add aspect ratio support for proper image dimensions - Improve loading state with smoother transitions - Remove shimmer animation in favor of solid color placeholders 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/components/PhotoItem.svelte | 60 +++++++++++++---------------- 1 file changed, 27 insertions(+), 33 deletions(-) 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; - } - } From a8978373e07c32b65543684347603e39a3ca8b40 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 19 Jun 2025 01:59:36 +0100 Subject: [PATCH 10/11] chore: add color analysis scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - analyze-image-colors.ts: Test color extraction on local images - check-photo-colors.ts: Verify color data in database - find-image-colors.ts: Extract colors from Cloudinary URLs - reanalyze-colors.ts: Bulk reprocess colors for all media 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- scripts/analyze-image-colors.ts | 135 ++++++++++++++++++++++++++ scripts/check-photo-colors.ts | 63 ++++++++++++ scripts/find-image-colors.ts | 89 +++++++++++++++++ scripts/reanalyze-colors.ts | 167 ++++++++++++++++++++++++++++++++ 4 files changed, 454 insertions(+) create mode 100644 scripts/analyze-image-colors.ts create mode 100644 scripts/check-photo-colors.ts create mode 100644 scripts/find-image-colors.ts create mode 100755 scripts/reanalyze-colors.ts 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/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/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 From 27dbdd43c0e39ac223ccfeb22defb834594e80e5 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Thu, 19 Jun 2025 02:00:00 +0100 Subject: [PATCH 11/11] fix: improve Cloudinary URL handling and admin navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix extractPublicId to handle encoded URLs correctly - Update admin media page to use goto for client-side navigation - Add color display to media details modal - Include color data in media API responses - Clean up unused imports in audit page 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../components/admin/MediaDetailsModal.svelte | 30 +++++++++++++++++++ src/routes/admin/media/+page.svelte | 6 +++- src/routes/admin/media/audit/+page.svelte | 10 ------- src/routes/api/media/+server.ts | 3 ++ src/routes/api/media/[id]/usage/+server.ts | 4 +-- 5 files changed, 40 insertions(+), 13 deletions(-) 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/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 @@