feat(search): Enter bleibt auf Seite + robustere Thumbnail-Erkennung
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 55s

Startseite:
- Enter/Return löst die Suche jetzt sofort aus (cancelt den Debounce),
  navigiert aber NICHT mehr auf /search. Der Anwender bleibt auf der
  gleichen Seite mit Inline-Ergebnissen.

Thumbnail-Enrichment (searxng.ts):
- Regex-basierte og:image-Extraktion durch linkedom-parseHTML ersetzt.
- Neue Fallback-Kette (in dieser Reihenfolge):
    1. <meta property/name = og:image | og:image:url | og:image:secure_url
                           | twitter:image | twitter:image:src>
    2. <link rel="image_src" href="...">
    3. JSON-LD image (auch tief in @graph; "image" als String, Array,
       Objekt-mit-url)
    4. Erstes <img> in article/main/.entry-content/.post-content/figure
- Relative URLs werden gegen die Seiten-URL zu absoluten aufgelöst
  (z.B. /uploads/foo.jpg → http://host/uploads/foo.jpg).
- maxBytes von 256 KB auf 512 KB angehoben, damit JSON-LD-lastige
  Recipe-Seiten nicht mitten im Script abgeschnitten werden.

Tests (97/97):
- Neu: JSON-LD-Image-Fallback-Test.
- Neu: Content-<img>-Fallback-Test mit relativer URL, die zur
  absoluten aufgelöst wird.
This commit is contained in:
hsiegeln
2026-04-17 18:04:59 +02:00
parent 9bc4465061
commit 211d58ebec
3 changed files with 178 additions and 42 deletions

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { SearchHit } from '$lib/server/recipes/search-local';
import type { WebHit } from '$lib/server/search/searxng';
import { randomQuote } from '$lib/quotes';
@@ -24,6 +23,34 @@
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
async function runSearch(q: string) {
try {
const res = await fetch(`/api/recipes/search?q=${encodeURIComponent(q)}`);
const body = await res.json();
if (query.trim() !== q) return;
hits = body.hits;
searchedFor = q;
if (body.hits.length === 0) {
webSearching = true;
try {
const wres = await fetch(`/api/recipes/search/web?q=${encodeURIComponent(q)}`);
if (query.trim() !== q) return;
if (!wres.ok) {
const err = await wres.json().catch(() => ({}));
webError = err.message ?? `HTTP ${wres.status}`;
} else {
const wbody = await wres.json();
webHits = wbody.hits;
}
} finally {
if (query.trim() === q) webSearching = false;
}
}
} finally {
if (query.trim() === q) searching = false;
}
}
$effect(() => {
const q = query.trim();
if (debounceTimer) clearTimeout(debounceTimer);
@@ -40,40 +67,18 @@
webHits = [];
webSearching = false;
webError = null;
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/recipes/search?q=${encodeURIComponent(q)}`);
const body = await res.json();
if (query.trim() !== q) return;
hits = body.hits;
searchedFor = q;
if (body.hits.length === 0) {
webSearching = true;
try {
const wres = await fetch(`/api/recipes/search/web?q=${encodeURIComponent(q)}`);
if (query.trim() !== q) return;
if (!wres.ok) {
const err = await wres.json().catch(() => ({}));
webError = err.message ?? `HTTP ${wres.status}`;
} else {
const wbody = await wres.json();
webHits = wbody.hits;
}
} finally {
if (query.trim() === q) webSearching = false;
}
}
} finally {
if (query.trim() === q) searching = false;
}
debounceTimer = setTimeout(() => {
void runSearch(q);
}, 300);
});
function submit(e: SubmitEvent) {
e.preventDefault();
const q = query.trim();
if (!q) return;
void goto(`/search?q=${encodeURIComponent(q)}`);
if (q.length <= 3) return;
if (debounceTimer) clearTimeout(debounceTimer);
searching = true;
void runSearch(q);
}
const activeSearch = $derived(query.trim().length > 3);