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:
parent
dab7fdf3ac
commit
90b450324b
6 changed files with 1284 additions and 0 deletions
605
src/routes/admin/media/regenerate/+page.svelte
Normal file
605
src/routes/admin/media/regenerate/+page.svelte
Normal 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>
|
||||
179
src/routes/api/admin/cloudinary-extract-colors/+server.ts
Normal file
179
src/routes/api/admin/cloudinary-extract-colors/+server.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
80
src/routes/api/admin/media-stats/+server.ts
Normal file
80
src/routes/api/admin/media-stats/+server.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
139
src/routes/api/admin/reanalyze-color/+server.ts
Normal file
139
src/routes/api/admin/reanalyze-color/+server.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
109
src/routes/api/admin/reanalyze-colors/+server.ts
Normal file
109
src/routes/api/admin/reanalyze-colors/+server.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
172
src/routes/api/admin/regenerate-thumbnails/+server.ts
Normal file
172
src/routes/api/admin/regenerate-thumbnails/+server.ts
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue