recipes: replace placeholder images with OKLAB dominant color backgrounds

Instead of generating/serving 20px placeholder images with blur CSS, extract
a perceptually accurate dominant color (Gaussian-weighted OKLAB average) and
use it as a solid background-color while the full image loads. Removes
placeholder image generation, blur CSS/JS, and placeholder directory references
across upload flows, API routes, service worker, and all card/hero components.
Adds admin bulk tool to backfill colors for existing recipes.
This commit is contained in:
2026-02-17 18:12:36 +01:00
parent d8f8aec282
commit b8469d4ae2
21 changed files with 592 additions and 108 deletions
+8 -11
View File
@@ -36,6 +36,8 @@ const img_name = $derived(
const img_alt = $derived(
recipe.images?.[0]?.alt || recipe.name
);
const img_color = $derived(recipe.images?.[0]?.color || '');
</script>
<style>
.card-main-link {
@@ -93,21 +95,16 @@ const img_alt = $derived(
transition: var(--transition-normal);
border-top-left-radius: inherit;
border-top-right-radius: inherit;
opacity: 0;
}
.blur{
filter: blur(10px);
}
.backdrop_blur{
backdrop-filter: blur(10px);
.image.loaded{
opacity: 1;
}
.card-image{
width: 300px;
height: 255px;
position: absolute;
top: 0;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
overflow: hidden;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
@@ -232,11 +229,11 @@ const img_alt = $derived(
<a href="{routePrefix}/{recipe.short_name}" class="card-main-link" aria-label="View recipe: {recipe.name}">
<span class="visually-hidden">View recipe: {recipe.name}</span>
</a>
<div class="card-image" style="background-image:url(https://bocken.org/static/rezepte/placeholder/{img_name})">
<div class="card-image" style:background-color={img_color}>
<noscript>
<img class="image backdrop_blur" src="https://bocken.org/static/rezepte/thumb/{img_name}" loading={loading_strat} alt="{img_alt}"/>
<img class="image loaded" src="https://bocken.org/static/rezepte/thumb/{img_name}" loading={loading_strat} alt="{img_alt}"/>
</noscript>
<img class="image backdrop_blur" class:blur={!isloaded} src={'https://bocken.org/static/rezepte/thumb/' + img_name} loading={loading_strat} alt="{img_alt}" onload={() => isloaded=true}/>
<img class="image" class:loaded={isloaded} src={'https://bocken.org/static/rezepte/thumb/' + img_name} loading={loading_strat} alt="{img_alt}" onload={() => isloaded=true}/>
</div>
{#if showFavoriteIndicator && isFavorite}
<div class="favorite-indicator">❤️</div>
@@ -20,6 +20,8 @@
recipe.images?.[0]?.alt || recipe.name
);
const img_color = $derived(recipe.images?.[0]?.color || '');
const isInSeason = $derived(icon_override || recipe.season?.includes(current_month));
</script>
<style>
@@ -160,7 +162,7 @@
{#if showFavoriteIndicator && isFavorite}
<span class="favorite">❤️</span>
{/if}
<div class="img-wrap">
<div class="img-wrap" style:background-color={img_color}>
<img
src="https://bocken.org/static/rezepte/thumb/{img_name}"
alt={img_alt}
@@ -1,7 +1,7 @@
<script>
import { onMount } from "svelte";
let { src, placeholder_src, alt = "", children } = $props();
let { src, color = '', alt = "", children } = $props();
let isloaded = $state(false);
let isredirected = $state(false);
@@ -79,21 +79,27 @@
margin: 0;
}
.image-wrap {
position: absolute;
top: 0;
left: 0;
right: 0;
margin-inline: auto;
width: min(1000px, 100dvw);
height: max(60dvh,600px);
overflow: hidden;
}
.image{
display: block;
position: absolute;
top: 0;
width: min(1000px, 100dvw);
z-index: -1;
opacity: 0;
transition: var(--transition-normal);
height: max(60dvh,600px);
object-fit: cover;
object-position: 50% 20%;
backdrop-filter: blur(20px);
filter: blur(20px);
z-index: -10;
}
.image-container::after {
@@ -106,32 +112,7 @@
:global(h1){
width: 100%;
}
.placeholder{
background-repeat: no-repeat;
background-size: cover;
background-position: 50% 20%;
position: absolute;
width: min(1000px, 100dvw);
height: max(60dvh,600px);
z-index: -2;
}
.placeholder_blur{
width: inherit;
height: inherit;
backdrop-filter: blur(20px);
}
div:has(.placeholder){
position: absolute;
top: 0;
left: 0;
right: 0;
margin-inline: auto;
width: min(1000px, 100dvw);
height: max(60dvh,600px);
overflow: hidden;
}
.unblur.image{
filter: blur(0px) !important;
opacity: 1;
}
@@ -175,13 +156,11 @@ dialog button{
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class:zoom-in={isloaded && !isredirected} onclick={show_dialog_img}>
<div class=placeholder style="background-image:url({placeholder_src})" >
<div class=placeholder_blur>
<div class="image-wrap" style:background-color={color}>
<img class="image" class:unblur={isloaded} {src} onload={() => {isloaded=true}} {alt}/>
</div>
</div>
<noscript>
<div class=placeholder style="background-image:url({placeholder_src})" >
<div class="image-wrap" style:background-color={color}>
<img class="image unblur" {src} onload={() => {isloaded=true}} {alt}/>
</div>
</noscript>
+2 -2
View File
@@ -37,7 +37,7 @@ export function toBrief(recipe: any, recipeLang: string): BriefRecipeType {
name: recipe.translations.en.name,
short_name: recipe.translations.en.short_name,
images: recipe.images?.[0]
? [{ alt: recipe.images[0].alt, mediapath: recipe.images[0].mediapath }]
? [{ alt: recipe.images[0].alt, mediapath: recipe.images[0].mediapath, color: recipe.images[0].color }]
: [],
tags: recipe.translations.en.tags || [],
category: recipe.translations.en.category,
@@ -51,7 +51,7 @@ export function toBrief(recipe: any, recipeLang: string): BriefRecipeType {
return {
...recipe,
images: recipe.images?.[0]
? [{ alt: recipe.images[0].alt, mediapath: recipe.images[0].mediapath }]
? [{ alt: recipe.images[0].alt, mediapath: recipe.images[0].mediapath, color: recipe.images[0].color }]
: [],
} as BriefRecipeType;
}