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
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:
180
src/lib/components/SearchLoader.svelte
Normal file
180
src/lib/components/SearchLoader.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user