feat(loader): SearchLoader mit wackelnder Pfanne und rotierenden Sprüchen
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 54s

Neue Komponente src/lib/components/SearchLoader.svelte ersetzt die
stumpfen "Suche läuft …"-Zeilen an allen vier Stellen:
- Startseite (scope=local und scope=web)
- Header-Dropdown (size=sm, beide Scopes)

Was passiert:
- Ein Pfannen-Emoji (🍳🥘🍲🍜🥣) wechselt alle 900 ms
- Wobble-Animation kippt es im 1.4-s-Takt hin und her
- Drei Dampf-Punkte steigen zeitversetzt auf und fadeen
- Caption unten rotiert alle 1.8 s durch vier passende Sprüche
  (lokal: "Stöbere im Rezeptbuch …", web: "Schnuppere in fremden
  Küchen …" etc.)

Zwei Size-Varianten: md (Homepage) und sm (Header-Dropdown).
This commit is contained in:
hsiegeln
2026-04-17 18:40:38 +02:00
parent 347b1de555
commit cf31e79fb0
3 changed files with 186 additions and 4 deletions

View File

@@ -0,0 +1,180 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
type Scope = 'local' | 'web';
type Size = 'sm' | 'md';
let { scope = 'local', size = 'md' }: { scope?: Scope; size?: Size } = $props();
const LOCAL_MESSAGES = [
'Stöbere im Rezeptbuch …',
'Schaue unter den Topfdeckeln …',
'Krame in den Gewürzregalen …',
'Durchsuche Omas Geheimrezepte …'
];
const WEB_MESSAGES = [
'Schnuppere in fremden Küchen …',
'Befrage Chefkoch, Emmi und Co. …',
'Durchforste die Kochblog-Gassen …',
'Klopfe an Internet-Kochtöpfe …'
];
const EMOJIS = ['🍳', '🥘', '🍲', '🍜', '🥣'];
const messages = $derived(scope === 'web' ? WEB_MESSAGES : LOCAL_MESSAGES);
let msgIdx = $state(0);
let emojiIdx = $state(0);
let msgTimer: ReturnType<typeof setInterval> | null = null;
let emojiTimer: ReturnType<typeof setInterval> | null = null;
onMount(() => {
msgTimer = setInterval(() => {
msgIdx = (msgIdx + 1) % messages.length;
}, 1800);
emojiTimer = setInterval(() => {
emojiIdx = (emojiIdx + 1) % EMOJIS.length;
}, 900);
});
onDestroy(() => {
if (msgTimer) clearInterval(msgTimer);
if (emojiTimer) clearInterval(emojiTimer);
});
</script>
<div class="loader" class:sm={size === 'sm'}>
<div class="pot-wrap" aria-hidden="true">
<span class="steam s1">·</span>
<span class="steam s2">·</span>
<span class="steam s3">·</span>
<span class="pot">{EMOJIS[emojiIdx]}</span>
</div>
<p class="caption" aria-live="polite">{messages[msgIdx]}</p>
</div>
<style>
.loader {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.75rem 0;
}
.loader.sm {
padding: 0.85rem 0;
gap: 0.35rem;
}
.pot-wrap {
position: relative;
width: 80px;
height: 80px;
}
.loader.sm .pot-wrap {
width: 50px;
height: 50px;
}
.pot {
font-size: 2.8rem;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
transform-origin: 50% 85%;
animation: wobble 1.4s ease-in-out infinite;
display: inline-block;
}
.loader.sm .pot {
font-size: 1.8rem;
}
.steam {
font-size: 1.7rem;
font-weight: 900;
color: #8fb097;
position: absolute;
bottom: 55%;
opacity: 0;
line-height: 1;
}
.s1 {
left: 22%;
animation: rise 2.4s ease-out infinite;
}
.s2 {
left: 50%;
transform: translateX(-50%);
animation: rise 2.4s ease-out infinite 0.6s;
}
.s3 {
left: 72%;
animation: rise 2.4s ease-out infinite 1.2s;
}
@keyframes wobble {
0%,
100% {
transform: translateX(-50%) rotate(-7deg);
}
50% {
transform: translateX(-50%) rotate(7deg);
}
}
@keyframes rise {
0% {
opacity: 0;
transform: translate(-50%, 0) scale(0.6);
}
25% {
opacity: 0.9;
}
100% {
opacity: 0;
transform: translate(-50%, -34px) scale(1.6);
}
}
.s1,
.s3 {
transform: none;
}
.s1 {
animation-name: rise-left;
}
.s3 {
animation-name: rise-right;
}
@keyframes rise-left {
0% {
opacity: 0;
transform: translate(0, 0) scale(0.6);
}
25% {
opacity: 0.9;
}
100% {
opacity: 0;
transform: translate(-8px, -34px) scale(1.5);
}
}
@keyframes rise-right {
0% {
opacity: 0;
transform: translate(0, 0) scale(0.6);
}
25% {
opacity: 0.9;
}
100% {
opacity: 0;
transform: translate(8px, -34px) scale(1.5);
}
}
.caption {
color: #6a7670;
font-style: italic;
font-size: 0.95rem;
margin: 0;
min-height: 1.3em;
text-align: center;
}
.loader.sm .caption {
font-size: 0.85rem;
}
</style>

View File

@@ -5,6 +5,7 @@
import { profileStore } from '$lib/client/profile.svelte';
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import SearchLoader from '$lib/components/SearchLoader.svelte';
import type { SearchHit } from '$lib/server/recipes/search-local';
import type { WebHit } from '$lib/server/search/searxng';
@@ -135,7 +136,7 @@
{#if navOpen}
<div class="dropdown" role="listbox">
{#if navSearching && navHits.length === 0}
<p class="dd-status">Suche läuft …</p>
<SearchLoader scope="local" size="sm" />
{:else if navHits.length > 0}
<ul class="dd-list">
{#each navHits as r (r.id)}
@@ -172,7 +173,7 @@
{:else}
<p class="dd-section">Keine lokalen Rezepte aus dem Internet:</p>
{#if navWebSearching}
<p class="dd-status">Suche im Internet läuft …</p>
<SearchLoader scope="web" size="sm" />
{:else if navWebError}
<p class="dd-status dd-error">Internet-Suche zurzeit nicht möglich.</p>
{:else if navWebHits.length > 0}

View File

@@ -3,6 +3,7 @@
import type { SearchHit } from '$lib/server/recipes/search-local';
import type { WebHit } from '$lib/server/search/searxng';
import { randomQuote } from '$lib/quotes';
import SearchLoader from '$lib/components/SearchLoader.svelte';
let query = $state('');
let quote = $state('');
@@ -102,7 +103,7 @@
{#if activeSearch}
<section class="results">
{#if searching && hits.length === 0}
<p class="muted">Suche läuft …</p>
<SearchLoader scope="local" />
{:else if hits.length > 0}
<ul class="cards">
{#each hits as r (r.id)}
@@ -129,7 +130,7 @@
{:else if searchedFor === query.trim()}
<p class="muted no-local-msg">Keine lokalen Rezepte für „{searchedFor}" — Ergebnisse aus dem Internet:</p>
{#if webSearching}
<p class="muted">Suche im Internet läuft …</p>
<SearchLoader scope="web" />
{:else if webError}
<p class="error">Internet-Suche zurzeit nicht möglich: {webError}</p>
{:else if webHits.length > 0}