feat: add admin tools for color management

- Add regenerate page for batch thumbnail and color processing
- Add API endpoint to extract colors from Cloudinary for existing media
- Add endpoints to reanalyze colors for individual or all media
- Add media statistics endpoint
- Add thumbnail regeneration endpoint for batch processing

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-19 01:59:08 +01:00
parent dab7fdf3ac
commit 90b450324b
6 changed files with 1284 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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