From 13df86e9fcac4e5cc2f00171da11285e7fbd7253 Mon Sep 17 00:00:00 2001 From: team3 Date: Tue, 8 Sep 2026 17:57:51 +0200 Subject: [PATCH] update --- backend/app/db.py | 13 +- backend/app/main.py | 69 +++++ backend/app/search.py | 295 +++++++++++++++++++++- backend/app/sync.py | 2 +- backend/app/tmdb.py | 32 +++ frontend/src/App.vue | 119 ++++++++- frontend/src/components/AehnlichTab.vue | 288 +++++++++++++++++++++ frontend/src/components/FilterSidebar.vue | 12 +- frontend/src/components/MerklisteTab.vue | 2 +- frontend/src/components/MovieDetail.vue | 174 +++++++++---- frontend/src/components/SearchTab.vue | 148 +++++++++-- frontend/src/filter.js | 24 ++ 12 files changed, 1088 insertions(+), 90 deletions(-) create mode 100644 frontend/src/components/AehnlichTab.vue create mode 100644 frontend/src/filter.js diff --git a/backend/app/db.py b/backend/app/db.py index e4e776f..9cf535e 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -125,12 +125,23 @@ def drop_missing(conn, keep_ids): return len(gone) +# Ein Film bringt im Schnitt vierzehn Bildpfade mit, einer sogar 179. +# Die Karte zeigt vier. Der Rest kommt beim Öffnen über /api/movies/{id}. +KATALOG_BILDER = 6 + + def all_movies(): with connect() as conn: rows = conn.execute( "SELECT * FROM movies ORDER BY vote_count DESC, title ASC" ).fetchall() - return [row_to_dict(r) for r in rows] + filme = [] + for row in rows: + film = row_to_dict(row) + if film.get("images"): + film["images"] = film["images"][:KATALOG_BILDER] + filme.append(film) + return filme def get_movie(movie_id): diff --git a/backend/app/main.py b/backend/app/main.py index 6ce4c3b..c56699a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -145,6 +145,75 @@ async def search_movies(q: str): return await search.search(q.strip()) +@app.get("/api/discover") +async def stoebern( + sort: str = "rating", + include: str = "", + exclude: str = "", + stimmen_min: int | None = None, + stimmen_max: int | None = None, + dauer_min: int | None = None, + dauer_max: int | None = None, + jahr_min: int | None = None, + jahr_max: int | None = None, + note_min: float | None = None, + note_max: float | None = None, +): + """Suche ohne Suchbegriff: TMDB stöbert nach den Filtern der Leiste.""" + return await search.discover( + { + "sort": sort, + "include": [g for g in include.split(",") if g], + "exclude": [g for g in exclude.split(",") if g], + "stimmen_min": stimmen_min, + "stimmen_max": stimmen_max, + "dauer_min": dauer_min, + "dauer_max": dauer_max, + "jahr_min": jahr_min, + "jahr_max": jahr_max, + "note_min": note_min, + "note_max": note_max, + } + ) + + +@app.get("/api/movies/{movie_id}/aehnliche") +async def aehnliche_filme( + movie_id: int, + limit: int = 12, + voll: bool = False, + sort: str = "", + include: str = "", + exclude: str = "", + stimmen_min: int | None = None, + stimmen_max: int | None = None, + jahr_min: int | None = None, + jahr_max: int | None = None, + note_min: float | None = None, + note_max: float | None = None, +): + """Was Leute noch geschaut haben, die diesen Film mochten.""" + try: + return await search.aehnliche( + movie_id, + limit=limit, + voll=voll, + filter_={ + "sort": sort, + "include": [g for g in include.split(",") if g], + "exclude": [g for g in exclude.split(",") if g], + "stimmen_min": stimmen_min, + "stimmen_max": stimmen_max, + "jahr_min": jahr_min, + "jahr_max": jahr_max, + "note_min": note_min, + "note_max": note_max, + }, + ) + except Exception: + return [] + + @app.get("/api/search/{movie_id}") async def search_detail(movie_id: int): """Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB.""" diff --git a/backend/app/search.py b/backend/app/search.py index 3f6655e..d3b0a4e 100644 --- a/backend/app/search.py +++ b/backend/app/search.py @@ -5,10 +5,19 @@ es auch Titel, die nirgends davon stehen — die kommen live von TMDB und landen nicht in der Datenbank. """ +import asyncio +import random + import httpx from .config import LANGUAGE, TMDB_BASE, TMDB_TOKEN -from .tmdb import DETAIL_PARAMS, pick_backdrops, pick_overview, pick_trailer +from .tmdb import ( + DETAIL_PARAMS, + pick_backdrops, + pick_overview, + pick_title, + pick_trailer, +) HEADERS = {"Authorization": f"Bearer {TMDB_TOKEN}", "accept": "application/json"} @@ -17,6 +26,19 @@ def client(): return httpx.AsyncClient(base_url=TMDB_BASE, headers=HEADERS, timeout=30.0) +# Die Namen kommen von TMDB selbst und ändern sich praktisch nie. Sie hier +# festzuhalten spart bei jeder Suche einen Abruf. +GENRES = { + 28: "Action", 12: "Abenteuer", 16: "Animation", 35: "Komödie", + 80: "Krimi", 99: "Dokumentarfilm", 18: "Drama", 10751: "Familie", + 14: "Fantasy", 36: "Historie", 27: "Horror", 10402: "Musik", + 9648: "Mystery", 10749: "Liebesfilm", 878: "Science Fiction", + 10770: "TV-Film", 53: "Thriller", 10752: "Kriegsfilm", 37: "Western", +} + +NACH_NAME = {name: kennung for kennung, name in GENRES.items()} + + def as_result(item): release = item.get("release_date") or "" return { @@ -29,9 +51,127 @@ def as_result(item): "poster_path": item.get("poster_path"), "vote_average": item.get("vote_average"), "vote_count": item.get("vote_count"), + "genres": [GENRES[g] for g in item.get("genre_ids", []) if g in GENRES], + # Trefferlisten sind knapp — details_nachladen() füllt nach + "images": [], + "runtime": None, } +async def details_nachladen(http, treffer, je_film=4): + """Bilder, Laufzeit und lesbarer Titel — alle Abrufe gleichzeitig. + + Trefferlisten von TMDB sind knapp: keine Bilder, keine Laufzeit, und + bei indischen oder japanischen Filmen steht der Titel in Originalschrift. + Das alles steckt in der Detailansicht, ein Abruf je Film reicht. + """ + + async def hole(film): + try: + resp = await http.get( + f"/movie/{film['id']}", + params={ + "language": LANGUAGE, + "append_to_response": "images,translations", + "include_image_language": "de,en,null", + }, + ) + resp.raise_for_status() + daten = resp.json() + except httpx.HTTPError: + return + + film["title"] = pick_title(daten) + film["runtime"] = daten.get("runtime") + backdrops = daten.get("images", {}).get("backdrops", []) + # Ohne Sprachkennung steht kein Text im Bild — das sind die Szenen + ohne_text = [b for b in backdrops if not b.get("iso_639_1")] + film["images"] = [ + b["file_path"] for b in (ohne_text or backdrops)[:je_film] + ] + + await asyncio.gather(*(hole(f) for f in treffer)) + + +SORTIERUNG = { + "rating": "vote_average.desc", + "newest": "primary_release_date.desc", + "oldest": "primary_release_date.asc", + "title": "title.asc", + # TMDB kennt keine Zufallsordnung. Statt dessen holt discover() eine + # zufällige Seite aus den Beliebtesten. + "random": "popularity.desc", +} + +# Weiter hinten liegen nur noch Filme, die kaum jemand kennt +MAX_SEITE = 20 + + +async def discover(filter_, limit=40): + """Stöbern ohne Suchbegriff: TMDB sucht nach den Filtern aus der Leiste. + + Anders als /search/movie versteht /discover/movie Genres, Jahre und + Bewertungen — damit lässt sich die Leiste direkt weiterreichen. + """ + params = { + "language": LANGUAGE, + "include_adult": "false", + "sort_by": SORTIERUNG.get(filter_.get("sort"), "popularity.desc"), + # Ohne Untergrenze stehen Filme mit einer einzigen Stimme ganz oben + "vote_count.gte": filter_.get("stimmen_min") or 50, + } + einbeziehen = [ + NACH_NAME[g] for g in (filter_.get("include") or []) if g in NACH_NAME + ] + ausschliessen = [ + NACH_NAME[g] for g in (filter_.get("exclude") or []) if g in NACH_NAME + ] + if einbeziehen: + params["with_genres"] = ",".join(str(g) for g in einbeziehen) + if ausschliessen: + params["without_genres"] = ",".join(str(g) for g in ausschliessen) + + for schluessel, ziel in ( + ("stimmen_max", "vote_count.lte"), + ("dauer_min", "with_runtime.gte"), + ("dauer_max", "with_runtime.lte"), + ("note_min", "vote_average.gte"), + ("note_max", "vote_average.lte"), + ): + wert = filter_.get(schluessel) + if wert is not None: + params[ziel] = wert + + if filter_.get("jahr_min"): + params["primary_release_date.gte"] = f"{filter_['jahr_min']}-01-01" + if filter_.get("jahr_max"): + params["primary_release_date.lte"] = f"{filter_['jahr_max']}-12-31" + + async with client() as http: + resp = await http.get("/discover/movie", params=params) + resp.raise_for_status() + daten = resp.json() + + # Bei "Zufällig" nochmal von einer beliebigen Seite holen — sonst + # stehen bei jedem Aufruf dieselben zwanzig Filme oben + if filter_.get("sort") == "random": + seiten = min(daten.get("total_pages") or 1, MAX_SEITE) + if seiten > 1: + params["page"] = random.randint(1, seiten) + resp = await http.get("/discover/movie", params=params) + resp.raise_for_status() + daten = resp.json() + + results = daten.get("results", []) + if filter_.get("sort") == "random": + random.shuffle(results) + + usable = [r for r in results if r.get("poster_path")] + treffer = [as_result(r) for r in usable[:limit]] + await details_nachladen(http, treffer) + return treffer + + async def search(query, limit=24): async with client() as http: resp = await http.get( @@ -41,11 +181,154 @@ async def search(query, limit=24): resp.raise_for_status() results = resp.json().get("results", []) - # Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer - # Dubletten oder Making-ofs - usable = [r for r in results if r.get("poster_path")] - usable.sort(key=lambda r: -(r.get("popularity") or 0)) - return [as_result(r) for r in usable[:limit]] + # Ohne Poster taugt eine Kachel nichts, und Namenlose sind fast immer + # Dubletten oder Making-ofs + usable = [r for r in results if r.get("poster_path")] + usable.sort(key=lambda r: -(r.get("popularity") or 0)) + treffer = [as_result(r) for r in usable[:limit]] + await details_nachladen(http, treffer) + return treffer + + +# TMDB hat je Film gut dreißig Seiten Empfehlungen. Weiter hinten wird die +# Verwandtschaft dünn, und jede Seite kostet einen Abruf. +MAX_AEHNLICH_SEITEN = 8 + + +def passt_zur_leiste(item, filter_): + """Die Filter der Leiste auf einen knappen Treffer anwenden. + + Die Laufzeit fehlt hier noch — die steht erst in der Detailantwort. + Nach ihr filtert weiter die Oberfläche. + """ + genres = {GENRES[g] for g in item.get("genre_ids", []) if g in GENRES} + einbeziehen = set(filter_.get("include") or []) + ausschliessen = set(filter_.get("exclude") or []) + if einbeziehen and not (genres & einbeziehen): + return False + if genres & ausschliessen: + return False + + release = item.get("release_date") or "" + jahr = int(release[:4]) if release[:4].isdigit() else None + for wert, unten, oben in ( + (item.get("vote_count"), filter_.get("stimmen_min"), filter_.get("stimmen_max")), + (item.get("vote_average"), filter_.get("note_min"), filter_.get("note_max")), + (jahr, filter_.get("jahr_min"), filter_.get("jahr_max")), + ): + if wert is None: + continue + if unten is not None and wert < unten: + return False + if oben is not None and wert > oben: + return False + return True + + +def brauchbare(roh, filter_): + """Dubletten und Posterlose raus, dann die Filter drüber.""" + raus, gesehen = [], set() + for item in roh: + if not item.get("poster_path") or item["id"] in gesehen: + continue + gesehen.add(item["id"]) + if passt_zur_leiste(item, filter_): + raus.append(item) + return raus + + +# "relevanz" gehört nicht dazu: dort bleibt TMDBs eigene Ordnung stehen. +# Die geht nach Verwandtschaft — dieselbe, die IMDb unter "More like this" +# zeigt — und ist für Ähnliche das sinnvollste Erste. +UMSORTIERBAR = ("rating", "newest", "oldest", "title", "random") + + +def sortiert(kandidaten, sort): + """Die Reihenfolge aus der Leiste auf die Kandidaten anwenden.""" + if sort == "rating": + return sorted(kandidaten, key=lambda r: -(r.get("vote_average") or 0)) + if sort == "newest": + return sorted( + kandidaten, key=lambda r: r.get("release_date") or "", reverse=True + ) + if sort == "oldest": + return sorted(kandidaten, key=lambda r: r.get("release_date") or "9999") + if sort == "title": + return sorted(kandidaten, key=lambda r: (r.get("title") or "").lower()) + if sort == "random": + gemischt = list(kandidaten) + random.shuffle(gemischt) + return gemischt + return kandidaten + + +async def aehnliche(movie_id, limit=12, voll=False, filter_=None): + """Was Leute noch geschaut haben, die diesen Film mochten. + + TMDB bietet zwei Wege an. /similar geht nur nach Genre und Schlagwörtern + und liefert für einen Horrorfilm hunderttausend Treffer — unbrauchbar. + /recommendations beruht auf dem Verhalten der Nutzer und trifft. + + Filtern muss diese Funktion selbst: /recommendations kennt anders als + /discover keine Genres oder Jahre. Reicht die erste Seite nicht, holt + sie weitere nach — sonst bleiben bei enger Leiste drei Karten übrig. + """ + filter_ = filter_ or {} + sort = filter_.get("sort") or "" + + async with client() as http: + + async def seite(nr): + try: + resp = await http.get( + f"/movie/{movie_id}/recommendations", + params={"language": LANGUAGE, "page": nr}, + ) + resp.raise_for_status() + return resp.json() + except httpx.HTTPError: + return {} + + erste = await seite(1) + roh = list(erste.get("results", [])) + seiten = min(erste.get("total_pages") or 1, MAX_AEHNLICH_SEITEN) + + # Alle übrigen Seiten gleichzeitig — nacheinander wären das bei + # enger Leiste sieben Wartezeiten hintereinander. + # Auch beim Sortieren: die besten zwanzig von zwanzig wären keine. + zu_wenig = len(brauchbare(roh, filter_)) < limit + if (zu_wenig or sort in UMSORTIERBAR) and seiten > 1: + for daten in await asyncio.gather( + *(seite(nr) for nr in range(2, seiten + 1)) + ): + roh.extend(daten.get("results", [])) + + kandidaten = sortiert(brauchbare(roh, filter_), sort) + treffer = [as_result(r) for r in kandidaten[:limit]] + # Für die Kachelreihe reicht der Titel, für Karten braucht es mehr + if voll: + await details_nachladen(http, treffer) + else: + await asyncio.gather(*(titel_richten(http, f) for f in treffer)) + return treffer + + +async def titel_richten(http, film): + """Originalschrift durch den englischen Titel ersetzen.""" + if not any( + z.isalpha() and not ("a" <= z.lower() <= "z" or z in "äöüßàâçéèêëîïôùûÿñæœ") + for z in film["title"] + ): + return + try: + resp = await http.get( + f"/movie/{film['id']}", + params={"language": LANGUAGE, "append_to_response": "translations"}, + ) + resp.raise_for_status() + film["title"] = pick_title(resp.json()) + except httpx.HTTPError: + pass async def detail(movie_id): diff --git a/backend/app/sync.py b/backend/app/sync.py index aea8a39..74f43f0 100644 --- a/backend/app/sync.py +++ b/backend/app/sync.py @@ -22,7 +22,7 @@ def build_movie(data): "collection_id": collection.get("id"), "collection_name": collection.get("name"), "id": data["id"], - "title": data.get("title") or data.get("original_title") or "Ohne Titel", + "title": tmdb.pick_title(data), "original_title": data.get("original_title"), "release_date": release or None, "year": int(release[:4]) if release[:4].isdigit() else None, diff --git a/backend/app/tmdb.py b/backend/app/tmdb.py index 2aec33c..c03a8ab 100644 --- a/backend/app/tmdb.py +++ b/backend/app/tmdb.py @@ -1,3 +1,5 @@ +import unicodedata + import httpx from .config import ( @@ -63,6 +65,36 @@ async def detail(http, movie_id): return resp.json() +def _fremde_schrift(text): + """Steht der Titel in einer nichtlateinischen Schrift?""" + for zeichen in text or "": + if zeichen.isalpha() and "LATIN" not in unicodedata.name(zeichen, ""): + return True + return False + + +def pick_title(data): + """Deutscher Titel, sonst der englische aus den Translations. + + Für indische, chinesische und koreanische Filme hat TMDB oft keine + deutsche Fassung und gibt den Originaltitel zurück — in Originalschrift. + Damit kann hier niemand etwas anfangen; IMDb zeigt an der Stelle den + umschriebenen Titel, und den liefert TMDB in den Translations mit. + """ + titel = data.get("title") or data.get("original_title") or "" + if titel and not _fremde_schrift(titel): + return titel + + for entry in data.get("translations", {}).get("translations", []): + if entry.get("iso_639_1") != "en": + continue + englisch = entry.get("data", {}).get("title") + if englisch and not _fremde_schrift(englisch): + return englisch + + return titel or "Ohne Titel" + + def pick_overview(data): """Deutsche Synopsis, sonst englische aus den Translations.""" if data.get("overview"): diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 60774fa..39534b4 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { abonnieren } from './live' import MovieCard from './components/MovieCard.vue' import FilterSidebar from './components/FilterSidebar.vue' +import AehnlichTab from './components/AehnlichTab.vue' import MerklisteTab from './components/MerklisteTab.vue' import MovieDetail from './components/MovieDetail.vue' import NamensWahl from './components/NamensWahl.vue' @@ -20,6 +21,10 @@ const loading = ref(true) const error = ref('') const search = ref('') const sort = ref('random') +// Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert +// dort eine Reihenfolge nach Verwandtschaft — dieselbe, die IMDb unter +// "More like this" zeigt. Im Katalog gibt es die nicht. +const suchSort = ref('relevanz') // Kompakt zeigt viele Karten nebeneinander, Details dafür mehr je Film const ANSICHT_SCHLUESSEL = 'filmabend_ansicht' @@ -27,11 +32,29 @@ const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt') watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert)) const selected = ref(null) + +// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir +// den vollen Datensatz nach — die Ansicht steht sofort, die restlichen +// Bilder kommen gleich danach. +// +// Über /api/search, nicht über /api/movies: Empfehlungen führen auch zu +// Filmen außerhalb des Katalogs. Dort greift der Rückgriff auf TMDB, +// sonst fehlten Trailer, Bilder, Laufzeit und der IMDb-Knopf. +async function filmOeffnen(movie) { + selected.value = movie + if (!movie?.id) return + try { + const resp = await fetch(`/api/search/${movie.id}`) + if (resp.ok && selected.value?.id === movie.id) selected.value = await resp.json() + } catch { + /* dann eben mit den Listendaten */ + } +} // Nach dem Neuladen soll derselbe Reiter offen sein const TAB_SCHLUESSEL = 'filmabend_tab' const TAB_IDS = [ - 'netflix', 'canon', 'search', 'wheel', 'merkliste', 'watched', 'wuensche', - 'verwaltung', 'stream', + 'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched', + 'wuensche', 'verwaltung', 'stream', ] const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL) const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix') @@ -54,7 +77,25 @@ const REGLER = [ { key: 'vote_average', label: 'Bewertung', feld: 'vote_average', step: 0.1, nachkomma: 1 }, ] +// Bei der Suche steht kein Katalog dahinter, aus dem sich Grenzen ableiten +// ließen — TMDB reicht von den Anfängen bis heute +const SUCH_GRENZEN = { + vote_count: { min: 50, max: 50000 }, + runtime: { min: 0, max: 240 }, + year: { min: 1900, max: new Date().getFullYear() + 1 }, + vote_average: { min: 0, max: 10 }, +} + +// Die 19 Genres, die TMDB kennt +const SUCH_GENRES = [ + 'Abenteuer', 'Action', 'Animation', 'Dokumentarfilm', 'Drama', 'Familie', + 'Fantasy', 'Historie', 'Horror', 'Komödie', 'Krimi', 'Kriegsfilm', + 'Liebesfilm', 'Musik', 'Mystery', 'Science Fiction', 'TV-Film', 'Thriller', + 'Western', +].map((genre) => ({ genre, count: null })) + const grenzen = computed(() => { + if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GRENZEN const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value const out = {} for (const regler of REGLER) { @@ -135,6 +176,7 @@ function resetGenres() { // Nur Genres anbieten, die im offenen Reiter auch Treffer haben const genreOptions = computed(() => { + if (activeTab.value === 'search' || activeTab.value === 'aehnlich') return SUCH_GENRES const pool = activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value const counts = new Map() for (const movie of pool) { @@ -213,6 +255,7 @@ const tabs = computed(() => [ { id: 'netflix', label: 'Netflix', count: netflixMovies.value.length }, { id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length }, { id: 'search', label: 'Suche', count: null }, + { id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null }, ...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]), @@ -223,6 +266,30 @@ const tabs = computed(() => [ ...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []), ]) +// Was die Leiste eingestellt hat — der Suche-Reiter schickt es an TMDB +const suchFilter = computed(() => { + const spanne = (key) => ranges.value[key] || [] + return { + sort: suchSort.value, + include: include.value, + exclude: exclude.value, + stimmen: spanne('vote_count'), + dauer: spanne('runtime'), + jahr: spanne('year'), + note: spanne('vote_average'), + grenzen: grenzen.value, + } +}) + +// Aus der Filmansicht heraus zur Ähnlichkeitsuche springen +const aehnlichStart = ref(null) + +function zurAehnlichkeitsuche(movie) { + aehnlichStart.value = movie + selected.value = null + activeTab.value = 'aehnlich' +} + const rad = ref(null) // Bei 2500 Karten kommt man sonst nicht mehr nach oben @@ -515,7 +582,7 @@ onUnmounted(() => { @@ -631,7 +730,7 @@ onUnmounted(() => { :movie="selected" :series="series" @close="selected = null" - @open="selected = $event" + @open="filmOeffnen" :mein-vorschlag="meinVorschlag" :host="host" :gesehen="gesehen.some((e) => e.movie_id === selected.id)" @@ -639,6 +738,7 @@ onUnmounted(() => { @gemerkt="ladeMerkliste" @suggested="nachVorschlag" @watched="ladeGesehen" + @aehnlich="zurAehnlichkeitsuche" /> @@ -662,10 +762,11 @@ onUnmounted(() => { z-index: 10; display: flex; gap: 0.35rem; + align-items: stretch; /* Der negative Rand deckt das Polster der Seite ab, sonst scrollen Karten oberhalb der Leiste durchs Bild */ margin: -1.5rem -1.25rem 1.25rem; - padding: 0.7rem 1.25rem 0; + padding: 0 1.25rem; background: var(--bg); border-bottom: 1px solid var(--line); } @@ -694,7 +795,7 @@ onUnmounted(() => { display: flex; align-items: center; gap: 0.45rem; - padding: 0.6rem 1rem; + padding: 0.8rem 1rem 0.6rem; color: var(--muted); font-size: 0.95rem; border-bottom: 2px solid transparent; diff --git a/frontend/src/components/AehnlichTab.vue b/frontend/src/components/AehnlichTab.vue new file mode 100644 index 0000000..23d2d56 --- /dev/null +++ b/frontend/src/components/AehnlichTab.vue @@ -0,0 +1,288 @@ + + + + + diff --git a/frontend/src/components/FilterSidebar.vue b/frontend/src/components/FilterSidebar.vue index 179e513..9a3efc5 100644 --- a/frontend/src/components/FilterSidebar.vue +++ b/frontend/src/components/FilterSidebar.vue @@ -8,6 +8,10 @@ const props = defineProps({ exclude: { type: Array, required: true }, regler: { type: Array, default: () => [] }, grenzen: { type: Object, default: () => ({}) }, + // Der Suche-Reiter hat ein eigenes, großes Suchfeld über der Liste + ohneSuche: Boolean, + // Nur wo TMDB die Reihenfolge liefert: Suche und Ähnlichkeitsuche + mitRelevanz: Boolean, }) const emit = defineEmits(['toggle', 'all', 'reset']) @@ -46,8 +50,14 @@ function beschriften(regler) {