diff --git a/src/routes/admin/media/regenerate/+page.svelte b/src/routes/admin/media/regenerate/+page.svelte new file mode 100644 index 0000000..523bbe7 --- /dev/null +++ b/src/routes/admin/media/regenerate/+page.svelte @@ -0,0 +1,605 @@ + + + + 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