Compare commits
3 Commits
f92ce677f6
...
a10ebefb75
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a10ebefb75 | ||
|
|
e56c1543d8 | ||
|
|
8c93099d91 |
@@ -175,16 +175,13 @@
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
/* Nie mehr als 30% der Bildschirmhöhe — auf schmalen Screens würde das
|
||||
Bild sonst alles Wichtige wegdrücken, auf breiten Desktops wäre es
|
||||
unverhältnismäßig groß. */
|
||||
max-height: 30vh;
|
||||
object-fit: cover;
|
||||
background: #eef3ef;
|
||||
}
|
||||
/* Mobile: verhindere, dass das Header-Bild einen unverhältnismäßig
|
||||
großen Teil des Viewports füllt und alles Wichtige wegdrückt. */
|
||||
@media (max-width: 820px) {
|
||||
.cover {
|
||||
max-height: 30vh;
|
||||
}
|
||||
}
|
||||
.hdr-body {
|
||||
padding: 1rem 1rem 0.25rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Der Favicon-Fetcher versucht ab jetzt zuerst die <link rel="icon">-Tags
|
||||
-- aus der Homepage, weil WordPress-Seiten (z.B. Emmi kocht einfach) unter
|
||||
-- /favicon.ico ein generisches Zahnrad-Default des Hosters ausliefern und
|
||||
-- das eigentliche Site-Icon erst im <head> auftaucht. Einmalig alle
|
||||
-- gespeicherten Favicon-Pfade zurücksetzen, damit sie mit der neuen
|
||||
-- Heuristik neu geladen werden. Alte Dateien bleiben als Orphans im
|
||||
-- IMAGE_DIR, sind aber harmlos.
|
||||
UPDATE allowed_domain SET favicon_path = NULL;
|
||||
@@ -3,7 +3,7 @@ import { createHash } from 'node:crypto';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { fetchBuffer } from '../http';
|
||||
import { fetchBuffer, fetchText } from '../http';
|
||||
import { listDomains, setDomainFavicon } from './repository';
|
||||
|
||||
const EXT_BY_CONTENT_TYPE: Record<string, string> = {
|
||||
@@ -33,14 +33,83 @@ async function tryFetch(url: string): Promise<{ data: Uint8Array; contentType: s
|
||||
}
|
||||
}
|
||||
|
||||
// Parst <link rel="…icon">-Tags aus dem <head>. WordPress-Seiten liefern
|
||||
// oft ein generisches /favicon.ico (Zahnrad-Default vom Hoster oder Plugin),
|
||||
// während das eigentliche Site-Icon per <link rel="icon"> eingebunden ist.
|
||||
// Darum zuerst den Head durchsehen, nicht blind /favicon.ico nehmen.
|
||||
type IconLink = { href: string; size: number; isApple: boolean };
|
||||
|
||||
function extractIconLinks(html: string, baseUrl: string): IconLink[] {
|
||||
const head = html.slice(0, 300_000);
|
||||
const icons: IconLink[] = [];
|
||||
const linkRe = /<link\b[^>]*>/gi;
|
||||
for (const m of head.matchAll(linkRe)) {
|
||||
const tag = m[0];
|
||||
const relMatch = tag.match(/\brel\s*=\s*["']([^"']+)["']/i);
|
||||
if (!relMatch) continue;
|
||||
const rel = relMatch[1].toLowerCase();
|
||||
const isApple = rel.includes('apple-touch-icon');
|
||||
if (!isApple && !/\b(shortcut\s+icon|icon)\b/.test(rel)) continue;
|
||||
const hrefMatch = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i);
|
||||
if (!hrefMatch) continue;
|
||||
const raw = hrefMatch[1].trim();
|
||||
if (!raw || raw.startsWith('data:')) continue;
|
||||
let href: string;
|
||||
try {
|
||||
href = new URL(raw, baseUrl).toString();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
let size = 0;
|
||||
const sizesMatch = tag.match(/\bsizes\s*=\s*["']([^"']+)["']/i);
|
||||
if (sizesMatch) {
|
||||
const sm = sizesMatch[1].match(/(\d+)\s*x\s*\d+/i);
|
||||
if (sm) size = Number(sm[1]);
|
||||
}
|
||||
if (!size && isApple) size = 180;
|
||||
icons.push({ href, size, isApple });
|
||||
}
|
||||
return icons;
|
||||
}
|
||||
|
||||
// Holt Icon-Kandidaten per HTML-Parse. 32–192 px bevorzugt (für 24×24-Darstellung
|
||||
// ist das sharp genug, ohne SVG-Wahnsinn); alles außerhalb landet am Ende.
|
||||
async function resolveIconsFromHtml(domain: string): Promise<string[]> {
|
||||
try {
|
||||
const baseUrl = `https://${domain}/`;
|
||||
const html = await fetchText(baseUrl, {
|
||||
timeoutMs: 3_500,
|
||||
maxBytes: 256 * 1024,
|
||||
allowTruncate: true
|
||||
});
|
||||
const icons = extractIconLinks(html, baseUrl);
|
||||
if (icons.length === 0) return [];
|
||||
const sweet = (s: number) => s >= 32 && s <= 192;
|
||||
icons.sort((a, b) => {
|
||||
if (sweet(a.size) && !sweet(b.size)) return -1;
|
||||
if (!sweet(a.size) && sweet(b.size)) return 1;
|
||||
return b.size - a.size;
|
||||
});
|
||||
return icons.map((i) => i.href);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFaviconBytes(
|
||||
domain: string
|
||||
): Promise<{ data: Uint8Array; contentType: string | null } | null> {
|
||||
// 1. Versuche /favicon.ico direkt (klassisch, funktioniert auf fast allen Seiten).
|
||||
// 1. Aus der Homepage die <link rel="icon">-Kandidaten ziehen — das
|
||||
// ist normalerweise das "echte" Site-Icon, nicht der Hoster-Default.
|
||||
const htmlIcons = await resolveIconsFromHtml(domain);
|
||||
for (const url of htmlIcons) {
|
||||
const got = await tryFetch(url);
|
||||
if (got) return got;
|
||||
}
|
||||
// 2. Klassiker: /favicon.ico. Viele ältere Seiten haben nur den.
|
||||
const direct = await tryFetch(`https://${domain}/favicon.ico`);
|
||||
if (direct) return direct;
|
||||
// 2. Fallback: Google-Favicon-Service. Liefert praktisch immer etwas und
|
||||
// geben SVG/PNG in der gewünschten Größe.
|
||||
// 3. Fallback: Google-Favicon-Service. Liefert praktisch immer etwas.
|
||||
return tryFetch(`https://www.google.com/s2/favicons?sz=64&domain=${encodeURIComponent(domain)}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { CookingPot, X } from 'lucide-svelte';
|
||||
import type { Snapshot } from './$types';
|
||||
@@ -43,6 +43,7 @@
|
||||
let allExhausted = $state(false);
|
||||
let allLoading = $state(false);
|
||||
let allSentinel: HTMLElement | undefined = $state();
|
||||
let allChips: HTMLElement | undefined = $state();
|
||||
let allObserver: IntersectionObserver | null = null;
|
||||
|
||||
type SearchSnapshot = {
|
||||
@@ -105,10 +106,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
function resetAllRecipes() {
|
||||
allRecipes = [];
|
||||
allExhausted = false;
|
||||
allLoading = false;
|
||||
async function setAllSort(next: AllSort) {
|
||||
if (next === allSort) return;
|
||||
allSort = next;
|
||||
if (typeof window !== 'undefined') localStorage.setItem('kochwas.allSort', next);
|
||||
if (allLoading) return;
|
||||
// Position der Sort-Chips vor dem Swap merken — wenn der Rezept-Block
|
||||
// beim Tausch kürzer wird, hält der Browser sonst nicht Schritt und
|
||||
// snapt nach oben. Wir korrigieren nach dem Render per scrollBy.
|
||||
const chipsBefore = allChips?.getBoundingClientRect().top ?? 0;
|
||||
allLoading = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/recipes/all?sort=${next}&limit=${ALL_PAGE}&offset=0`
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const body = await res.json();
|
||||
const hits = body.hits as SearchHit[];
|
||||
allRecipes = hits;
|
||||
allExhausted = hits.length < ALL_PAGE;
|
||||
await tick();
|
||||
const chipsAfter = allChips?.getBoundingClientRect().top ?? 0;
|
||||
const delta = chipsAfter - chipsBefore;
|
||||
if (typeof window !== 'undefined' && Math.abs(delta) > 1) {
|
||||
window.scrollBy({ top: delta, left: 0, behavior: 'instant' });
|
||||
}
|
||||
} finally {
|
||||
allLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFavorites(profileId: number) {
|
||||
@@ -136,14 +161,6 @@
|
||||
void loadAllMore();
|
||||
});
|
||||
|
||||
function setAllSort(next: AllSort) {
|
||||
if (next === allSort) return;
|
||||
allSort = next;
|
||||
if (typeof window !== 'undefined') localStorage.setItem('kochwas.allSort', next);
|
||||
resetAllRecipes();
|
||||
void loadAllMore();
|
||||
}
|
||||
|
||||
// IntersectionObserver an den Sentinel hängen — wenn sichtbar, nachladen.
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -500,7 +517,12 @@
|
||||
<div class="listing-head">
|
||||
<h2>Alle Rezepte</h2>
|
||||
</div>
|
||||
<div class="sort-chips" role="tablist" aria-label="Sortierung">
|
||||
<div
|
||||
class="sort-chips"
|
||||
role="tablist"
|
||||
aria-label="Sortierung"
|
||||
bind:this={allChips}
|
||||
>
|
||||
{#each ALL_SORTS as s (s.value)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user