This commit is contained in:
team3
2026-09-18 22:12:01 +02:00
parent 33b3975b61
commit db77c1f985
10 changed files with 488 additions and 298 deletions

View File

@@ -9,6 +9,7 @@ erfundene oder verwechselte Filme draußen.
import asyncio import asyncio
import json import json
from difflib import SequenceMatcher
import httpx import httpx
@@ -24,12 +25,45 @@ SYSTEM = (
"Du schlägst Filme vor, die zu dem passen, was gesucht wird. " "Du schlägst Filme vor, die zu dem passen, was gesucht wird. "
f"Nenne genau {ANZAHL} Filme, keine Serien. Nur Filme, die es wirklich gibt. " f"Nenne genau {ANZAHL} Filme, keine Serien. Nur Filme, die es wirklich gibt. "
"Nichts vorschlagen, was in der Sammlung steht oder schon gesehen wurde. " "Nichts vorschlagen, was in der Sammlung steht oder schon gesehen wurde. "
"Was in der Frage genannt ist, kennt die Person schon — auch nicht vorschlagen. "
"Mische Bekanntes mit Geheimtipps. " "Mische Bekanntes mit Geheimtipps. "
"Antworte nur mit JSON in dieser Form, ohne Text davor oder danach: " "Antworte nur mit JSON in dieser Form, ohne Text davor oder danach: "
'{"filme": [{"titel": "Originaltitel", "jahr": 1999, ' '{"merkmale": ["drei Stichpunkte: was macht das Gesuchte aus"], '
'"grund": "ein Satz auf Deutsch, warum der Film passt"}]}' '"kandidaten": ["20 Originaltitel mit Jahr, die dazu passen könnten"], '
'"filme": [{"titel": "Originaltitel", "jahr": 1999, "grund": "..."}]}\n'
# Der Umweg über Merkmale und Kandidaten ersetzt das Nachdenken des
# Modells: es sortiert erst, dann wählt es — und trifft deutlich besser.
"Erst merkmale, dann kandidaten, dann die zwölf besten davon als filme.\n"
# Die Begründung lesen Leute, die einfach einen Film für den Abend
# suchen — nicht Kritiker. Sie muss auf vier kurze Zeilen passen.
"Für grund gilt: Höchstens 10 Wörter und 90 Zeichen, Deutsch in lateinischer "
"Schrift, für Laien. "
"Sag klar, was der Film mit dem Gesuchten gemeinsam hat. "
"Keine Fachwörter wie Whodunit, Neo-Noir, Ensemble, Slasher, Body-Horror, "
"Meta, Hommage, Genre-Namen auf Englisch. Keine Inhaltsangabe, kein Titel, "
"kein Land. Ein Regisseur nur, wenn er der Grund ist. "
"Jede Begründung nennt einen anderen Punkt, keine zwei gleich. "
'Beispiel — gesucht "Ich mochte Knives Out": '
'"Wieder rätselt eine zerstrittene Familie um einen Mord, mit Witz."'
) )
# Auf der Karte hat die Begründung vier Zeilen à gut zwanzig Zeichen
MAX_GRUND = 95
def kuerzen(grund):
"""Das Modell zählt ungenau. Was nicht auf die Karte passt, endet am
letzten Komma oder Wort davor — ein halber Satz ist besser als ein
abgeschnittener."""
grund = grund.strip()
if len(grund) <= MAX_GRUND:
return grund
kopf = grund[:MAX_GRUND]
schnitt = max(kopf.rfind(","), kopf.rfind(";"), kopf.rfind(" –"), kopf.rfind("."))
if schnitt < 40:
schnitt = kopf.rfind(" ")
return kopf[:schnitt].rstrip(",; –.") + " …"
def etikett(film): def etikett(film):
"""Titel (Jahr) — so kommt ein Film in den Prompt.""" """Titel (Jahr) — so kommt ein Film in den Prompt."""
@@ -71,6 +105,11 @@ def prompt(beschreibung, sammlung, gesehen, filter_=None):
teile = [] teile = []
if beschreibung: if beschreibung:
teile.append(f"Gesucht: {beschreibung}") teile.append(f"Gesucht: {beschreibung}")
# In der Nutzernachricht hält sich das Modell eher daran als im
# Systemprompt — sonst steht die Fortsetzung des Gesuchten vorn
teile.append(
"Nicht vorschlagen: Filme, die oben genannt sind, und ihre Fortsetzungen."
)
regeln = vorgaben(filter_) regeln = vorgaben(filter_)
if regeln: if regeln:
teile.append("Vorgaben, unbedingt einhalten:\n" + "\n".join(f"- {z}" for z in regeln)) teile.append("Vorgaben, unbedingt einhalten:\n" + "\n".join(f"- {z}" for z in regeln))
@@ -110,7 +149,7 @@ def parsen(text):
{ {
"titel": titel, "titel": titel,
"jahr": int(jahr) if jahr.isdigit() else None, "jahr": int(jahr) if jahr.isdigit() else None,
"grund": str(f.get("grund") or "").strip(), "grund": kuerzen(str(f.get("grund") or "")),
} }
) )
return aus[:ANZAHL] return aus[:ANZAHL]
@@ -123,17 +162,37 @@ async def fragen(beschreibung, sammlung, gesehen, filter_=None):
{"role": "system", "content": SYSTEM}, {"role": "system", "content": SYSTEM},
{"role": "user", "content": prompt(beschreibung, sammlung, gesehen, filter_)}, {"role": "user", "content": prompt(beschreibung, sammlung, gesehen, filter_)},
], ],
# Wie beim Teaser: sonst denkt M3 das Token-Limit voll # Ohne Denkblock: M3 grübelt sonst minutenlang über zwölf Filme
"reasoning_effort": "low", # und liefert am Token-Limit gar nichts. Die Merkmale- und
# Kandidatenfelder im Prompt leisten das Sortieren stattdessen,
# in gut zehn Sekunden. Niedrige Temperatur: weniger Ausreißer.
"thinking": {"type": "disabled"},
"max_tokens": 4000, "max_tokens": 4000,
"temperature": 0.7, "temperature": 0.3,
} }
async with httpx.AsyncClient( async with httpx.AsyncClient(
headers={"Authorization": f"Bearer {MINIMAX_KEY}"}, timeout=120.0 headers={"Authorization": f"Bearer {MINIMAX_KEY}"}, timeout=120.0
) as http: ) as http:
resp = await http.post(MINIMAX_URL, json=payload) # Hin und wieder kommt kein brauchbares JSON zurück. Ein zweiter
resp.raise_for_status() # Anlauf kostet wenige Sekunden und sitzt fast immer.
return parsen(resp.json()["choices"][0]["message"]["content"]) for _ in range(2):
resp = await http.post(MINIMAX_URL, json=payload)
resp.raise_for_status()
filme = parsen(resp.json()["choices"][0]["message"]["content"])
if filme:
return filme
return []
def _aehnlich(gesucht, treffer):
"""Passt der TMDB-Treffer zum genannten Titel? Die Suche liefert
auch Streifschüsse — "The Afterparty" findet ein Winx-Special. Der
deutsche oder der Originaltitel muss dem Genannten nahekommen."""
soll = search.ohne_zeichen(gesucht).lower()
return any(
SequenceMatcher(None, soll, search.ohne_zeichen(t or "").lower()).ratio() >= 0.6
for t in (treffer.get("title"), treffer.get("original_title"))
)
def _dauer_passt(karte, filter_): def _dauer_passt(karte, filter_):
@@ -162,7 +221,9 @@ async def aufloesen(vorschlaege, filter_=None, ausser=()):
resp.raise_for_status() resp.raise_for_status()
treffer = [ treffer = [
t for t in resp.json().get("results", []) t for t in resp.json().get("results", [])
if t.get("poster_path") and search.passt_zur_leiste(t, filter_) if t.get("poster_path")
and _aehnlich(v["titel"], t)
and search.passt_zur_leiste(t, filter_)
] ]
except httpx.HTTPError: except httpx.HTTPError:
return None return None

View File

@@ -17,6 +17,21 @@ def test_parsen_zieht_json_aus_geschwaetz():
] ]
def test_lange_begruendung_endet_am_komma():
kurz = "Wieder rätselt eine Familie um einen Mord, mit Witz."
assert ki.kuerzen(kurz) == kurz
lang = (
"Zwei chaotische Ermittler stolpern durch einen Fall voller Wendungen, "
"schräger Figuren und schlagfertiger Sprüche im Los Angeles der Siebziger."
)
gekuerzt = ki.kuerzen(lang)
assert gekuerzt == "Zwei chaotische Ermittler stolpern durch einen Fall voller Wendungen …"
assert len(gekuerzt) <= ki.MAX_GRUND + 2
# Ohne Komma bricht es am letzten Wort
ohne = "Wort " * 30
assert ki.kuerzen(ohne).endswith("Wort …") and len(ki.kuerzen(ohne)) <= ki.MAX_GRUND + 2
def test_parsen_bei_muell_leer(): def test_parsen_bei_muell_leer():
assert ki.parsen("Keine Ahnung.") == [] assert ki.parsen("Keine Ahnung.") == []
assert ki.parsen('{"filme": "nix"}') == [] assert ki.parsen('{"filme": "nix"}') == []
@@ -49,6 +64,16 @@ def test_vorgaben_aus_der_leiste():
assert "unbedingt einhalten" in ki.prompt("x", [], [], {"include": ["Horror"]}) assert "unbedingt einhalten" in ki.prompt("x", [], [], {"include": ["Horror"]})
def test_titel_muss_zum_treffer_passen():
assert ki._aehnlich("Clue", {"title": "Alle Mörder sind schon da", "original_title": "Clue"})
assert ki._aehnlich("The Hateful 8", {"title": "The Hateful 8", "original_title": "The Hateful Eight"})
assert ki._aehnlich("Murder by Death", {"title": "Eine Leiche zum Dessert", "original_title": "Murder by Death"})
assert not ki._aehnlich(
"The Afterparty",
{"title": "Fate: The Winx Saga - Die Afterparty", "original_title": "Fate: The Winx Saga - The Afterparty"},
)
def test_route_fragt_und_loest_auf(client, film, monkeypatch): def test_route_fragt_und_loest_auf(client, film, monkeypatch):
aufrufe = {} aufrufe = {}

View File

@@ -21,7 +21,7 @@ import { matches, useKatalog } from './composables/useKatalog'
import { REGLER, useFilter } from './composables/useFilter' import { REGLER, useFilter } from './composables/useFilter'
import { useLangeListe } from './composables/useLangeListe' import { useLangeListe } from './composables/useLangeListe'
const { ansicht, ohneGesehene, activeTab, host } = useEinstellungen() const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host } = useEinstellungen()
const { const {
runde, ich, michSetzen, runde, ich, michSetzen,
@@ -44,7 +44,7 @@ const {
const { const {
search, sort, suchSort, include, exclude, grenzen, ranges, genreOptions, search, sort, suchSort, include, exclude, grenzen, ranges, genreOptions,
toggleGenre, toggleAll, resetGenres, nichtGesehen, passt, suchFilter, toggleGenre, toggleAll, resetGenres, nichtGesehen, passt, suchFilter,
} = useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, ohneGesehene }) } = useFilter({ activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene })
// Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über // Eine Reihe erscheint, sobald ein Teil passt — sortiert wird sie über
// ihren stärksten Teil // ihren stärksten Teil
@@ -69,24 +69,31 @@ const visibleCanon = computed(() =>
) )
const aktuelleListe = computed(() => const aktuelleListe = computed(() =>
activeTab.value === 'netflix' ? visible.value : visibleCanon.value quelle.value === 'netflix' ? visible.value : visibleCanon.value
) )
// Die zwei Listen des Katalogs, umschaltbar in der Filterleiste
const quellen = computed(() => [
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
{
id: 'canon',
label: 'Horror-Kanon',
count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length,
},
])
const { gezeigt, sichtbar, nachschub, obenKnopf, nachOben } = useLangeListe({ const { gezeigt, sichtbar, nachschub, obenKnopf, nachOben } = useLangeListe({
liste: aktuelleListe, liste: aktuelleListe,
ansicht, ansicht,
}) })
const tabs = computed(() => [ const tabs = computed(() => [
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
{ {
id: 'canon', id: 'katalog',
label: 'Horror-Kanon', label: 'Katalog',
count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length, count: quellen.value.find((q) => q.id === quelle.value)?.count ?? null,
}, },
{ id: 'search', label: 'Suche', count: null }, { id: 'suche', label: 'Suche', count: null },
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
{ id: 'ki', label: 'KI-Suche', count: null },
...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]), ...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
{ id: 'wheel', label: 'Rad', count: null }, { id: 'wheel', label: 'Rad', count: null },
{ id: 'stream', label: 'Stream', count: null }, { id: 'stream', label: 'Stream', count: null },
@@ -101,7 +108,7 @@ const tabs = computed(() => [
// legt sie sich über einen Knopf über die Seite // legt sie sich über einen Knopf über die Seite
const filterOffen = ref(false) const filterOffen = ref(false)
const mitFilter = computed(() => const mitFilter = computed(() =>
['netflix', 'canon', 'search', 'aehnlich', 'ki', 'merkliste'].includes(activeTab.value) ['katalog', 'suche', 'merkliste'].includes(activeTab.value)
) )
// Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler — // Der Sammlungen-Reiter filtert nach Person und Genre, nicht nach Regler —
@@ -125,7 +132,7 @@ const leuteMitSammlung = computed(() => {
// Fällt der offene Reiter weg — Hostmodus aus, Name abgewählt —, wäre // Fällt der offene Reiter weg — Hostmodus aus, Name abgewählt —, wäre
// sonst keiner markiert und die Seite bliebe leer // sonst keiner markiert und die Seite bliebe leer
watch(tabs, (liste) => { watch(tabs, (liste) => {
if (!liste.some((t) => t.id === activeTab.value)) activeTab.value = 'netflix' if (!liste.some((t) => t.id === activeTab.value)) activeTab.value = 'katalog'
}) })
// Passen die Reiter nicht in eine Zeile, soll der offene sichtbar sein // Passen die Reiter nicht in eine Zeile, soll der offene sichtbar sein
@@ -184,9 +191,17 @@ const aehnlichStart = ref(null)
function zurAehnlichkeitsuche(movie) { function zurAehnlichkeitsuche(movie) {
aehnlichStart.value = movie aehnlichStart.value = movie
selected.value = null selected.value = null
activeTab.value = 'aehnlich' activeTab.value = 'suche'
suchArt.value = 'aehnlich'
} }
// Die drei Suchen unter einem Reiter, umschaltbar über der Liste
const SUCHARTEN = [
{ id: 'search', label: 'Titel' },
{ id: 'aehnlich', label: 'Ähnliche Filme' },
{ id: 'ki', label: 'KI' },
]
const rad = ref(null) const rad = ref(null)
function nachVorschlag() { function nachVorschlag() {
@@ -232,26 +247,51 @@ onMounted(load)
</div> </div>
</header> </header>
<div v-if="activeTab === 'search' || activeTab === 'aehnlich'" class="layout"> <div v-if="activeTab === 'suche'" class="layout">
<SearchTab <div class="suche">
v-if="activeTab === 'search'" <div class="sucharten">
:gesehen-ids="gesehenIds" <button
:ohne-gesehene="ohneGesehene" v-for="art in SUCHARTEN"
:filter="suchFilter" :key="art.id"
:ansicht="ansicht" type="button"
@open="selected = $event" :class="{ active: suchArt === art.id }"
/> @click="suchArt = art.id"
>
{{ art.label }}
</button>
</div>
<AehnlichTab <SearchTab
v-else v-if="suchArt === 'search'"
:gesehen-ids="gesehenIds" :gesehen-ids="gesehenIds"
:filter="suchFilter" :ohne-gesehene="ohneGesehene"
:ansicht="ansicht" :filter="suchFilter"
:start="aehnlichStart" :ansicht="ansicht"
@open="filmOeffnen" @open="selected = $event"
@gestartet="aehnlichStart = null" />
/>
<AehnlichTab
v-else-if="suchArt === 'aehnlich'"
:gesehen-ids="gesehenIds"
:filter="suchFilter"
:ansicht="ansicht"
:start="aehnlichStart"
@open="filmOeffnen"
@gestartet="aehnlichStart = null"
/>
<KiTab
v-else
:angemeldet="angemeldet"
:gesehen-ids="gesehenIds"
:ohne-gesehene="ohneGesehene"
:filter="suchFilter"
:ansicht="ansicht"
@open="selected = $event"
/>
</div>
<!-- Die KI ordnet selbst nach Passung, da gibt es nichts zu sortieren -->
<FilterSidebar <FilterSidebar
v-model:sort="suchSort" v-model:sort="suchSort"
v-model:ansicht="ansicht" v-model:ansicht="ansicht"
@@ -264,35 +304,8 @@ onMounted(load)
:include="include" :include="include"
:exclude="exclude" :exclude="exclude"
:ohne-suche="true" :ohne-suche="true"
:mit-relevanz="true" :mit-relevanz="suchArt !== 'ki'"
@toggle="toggleGenre" :ohne-sort="suchArt === 'ki'"
@all="toggleAll"
@reset="resetGenres"
/>
</div>
<div v-else-if="activeTab === 'ki'" class="layout">
<KiTab
:angemeldet="angemeldet"
:gesehen-ids="gesehenIds"
:ohne-gesehene="ohneGesehene"
:filter="suchFilter"
:ansicht="ansicht"
@open="selected = $event"
/>
<FilterSidebar
v-model:ansicht="ansicht"
v-model:ohne-gesehene="ohneGesehene"
v-model:ranges="ranges"
:class="{ offen: filterOffen }"
:regler="REGLER"
:grenzen="grenzen"
:options="genreOptions"
:include="include"
:exclude="exclude"
:ohne-suche="true"
:ohne-sort="true"
@toggle="toggleGenre" @toggle="toggleGenre"
@all="toggleAll" @all="toggleAll"
@reset="resetGenres" @reset="resetGenres"
@@ -399,6 +412,7 @@ onMounted(load)
<FilterSidebar <FilterSidebar
v-if="genreOptions.length" v-if="genreOptions.length"
v-model:quelle="quelle"
v-model:search="search" v-model:search="search"
v-model:sort="sort" v-model:sort="sort"
v-model:ansicht="ansicht" v-model:ansicht="ansicht"
@@ -410,6 +424,7 @@ onMounted(load)
:options="genreOptions" :options="genreOptions"
:include="include" :include="include"
:exclude="exclude" :exclude="exclude"
:quellen="quellen"
@toggle="toggleGenre" @toggle="toggleGenre"
@all="toggleAll" @all="toggleAll"
@reset="resetGenres" @reset="resetGenres"
@@ -570,6 +585,39 @@ onMounted(load)
min-width: 0; min-width: 0;
} }
.suche {
min-width: 0;
}
/* Umschalter zwischen den drei Suchen: drei gleich breite Knöpfe über
die volle Breite, im Stil von Kompakt/Details in der Leiste */
.sucharten {
display: flex;
gap: 0.4rem;
margin-bottom: 1.2rem;
}
.sucharten button {
flex: 1;
padding: 0.6rem 0.9rem;
color: var(--muted);
font-size: 0.9rem;
white-space: nowrap;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 8px;
}
.sucharten button:hover {
color: var(--text);
}
.sucharten button.active {
color: var(--text);
font-weight: 600;
border-color: var(--accent);
}
.results { .results {
min-width: 0; min-width: 0;
} }

View File

@@ -197,11 +197,6 @@ async function entfernen() {
<!-- ── Rechte Seite: das Gespräch ─────────────────────────────── --> <!-- ── Rechte Seite: das Gespräch ─────────────────────────────── -->
<section class="rechts"> <section class="rechts">
<h3>
Diskussion
<span v-if="anzahl" class="anzahl">{{ anzahl }}</span>
</h3>
<p v-if="!anzahl" class="leer">Noch nichts gesagt.</p> <p v-if="!anzahl" class="leer">Noch nichts gesagt.</p>
<ul v-else class="liste"> <ul v-else class="liste">
@@ -515,30 +510,9 @@ h2:hover {
border-color: var(--accent); border-color: var(--accent);
} }
h3 {
display: flex;
gap: 0.45rem;
align-items: center;
margin-bottom: 0.4rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.anzahl {
padding: 0.1rem 0.45rem;
letter-spacing: 0;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 20px;
}
/* Zeilen mit Trennlinie, wie bei den Wünschen */ /* Zeilen mit Trennlinie, wie bei den Wünschen */
.liste { .liste {
list-style: none; list-style: none;
border-top: 1px solid var(--line);
} }
.zeile { .zeile {

View File

@@ -212,10 +212,6 @@ async function oeffnen(movie) {
/> />
</div> </div>
</template> </template>
<p v-else class="note">
Such einen Film. Danach zeigt dir dieser Reiter, was dazu passt.
</p>
</div> </div>
</template> </template>

View File

@@ -14,6 +14,8 @@ const props = defineProps({
mitRelevanz: Boolean, mitRelevanz: Boolean,
// Die KI-Suche hat keine Sortierung — ihre Reihenfolge ist die Relevanz // Die KI-Suche hat keine Sortierung — ihre Reihenfolge ist die Relevanz
ohneSort: Boolean, ohneSort: Boolean,
// Der Katalog hat zwei Listen zur Wahl: [{ id, label, count }]
quellen: { type: Array, default: () => [] },
}) })
const emit = defineEmits(['toggle', 'all', 'reset']) const emit = defineEmits(['toggle', 'all', 'reset'])
@@ -23,6 +25,7 @@ const sort = defineModel('sort', { type: String, default: 'random' })
const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' }) const ansicht = defineModel('ansicht', { type: String, default: 'kompakt' })
const ohneGesehene = defineModel('ohneGesehene', { type: Boolean, default: true }) const ohneGesehene = defineModel('ohneGesehene', { type: Boolean, default: true })
const ranges = defineModel('ranges', { type: Object, default: () => ({}) }) const ranges = defineModel('ranges', { type: Object, default: () => ({}) })
const quelle = defineModel('quelle', { type: String, default: '' })
// Auch ein verschobener Regler zählt als aktiver Filter // Auch ein verschobener Regler zählt als aktiver Filter
const etwasAktiv = computed( const etwasAktiv = computed(
@@ -53,6 +56,19 @@ function beschriften(regler) {
<template> <template>
<aside class="sidebar"> <aside class="sidebar">
<div class="controls"> <div class="controls">
<div v-if="quellen.length" class="ansicht">
<button
v-for="q in quellen"
:key="q.id"
type="button"
:class="{ an: quelle === q.id }"
@click="quelle = q.id"
>
{{ q.label }}
<span v-if="q.count != null" class="zahl">{{ q.count }}</span>
</button>
</div>
<input <input
v-if="!ohneSuche" v-if="!ohneSuche"
v-model="search" v-model="search"
@@ -298,6 +314,12 @@ h4 {
gap: 0.3rem; gap: 0.3rem;
} }
.ansicht .zahl {
margin-left: 0.25rem;
font-size: 0.7rem;
opacity: 0.7;
}
.ansicht button { .ansicht button {
flex: 1; flex: 1;
padding: 0.4rem 0.5rem; padding: 0.4rem 0.5rem;

View File

@@ -79,7 +79,7 @@ async function open(movie) {
v-model="beschreibung" v-model="beschreibung"
:maxlength="MAX" :maxlength="MAX"
rows="3" rows="3"
placeholder="Was suchst du? Zum Beispiel: langsamer Folk-Horror ohne viel Gore, gern aus Europa, ab 2010 …" placeholder="Was suchst du?"
@keydown.ctrl.enter="fragen" @keydown.ctrl.enter="fragen"
></textarea> ></textarea>
<div class="zeile"> <div class="zeile">
@@ -89,7 +89,7 @@ async function open(movie) {
</label> </label>
<span class="rest"></span> <span class="rest"></span>
<button type="button" class="los" :disabled="!kannFragen" @click="fragen"> <button type="button" class="los" :disabled="!kannFragen" @click="fragen">
{{ laden ? 'Überlegt …' : 'Vorschlagen' }} {{ laden ? 'Sucht …' : 'Suchen' }}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, reactive, ref } from 'vue' import { computed, reactive, ref } from "vue";
const props = defineProps({ const props = defineProps({
items: { type: Array, default: () => [] }, items: { type: Array, default: () => [] },
@@ -7,272 +7,310 @@ const props = defineProps({
host: Boolean, host: Boolean,
// Ohne gewählten Namen bleibt der Reiter zum Mitlesen da // Ohne gewählten Namen bleibt der Reiter zum Mitlesen da
angemeldet: Boolean, angemeldet: Boolean,
}) });
const emit = defineEmits(['changed']) const emit = defineEmits(["changed"]);
const entwurf = ref('') const entwurf = ref("");
// Der Host darf jeden Wunsch anfassen, alle anderen nur die eigenen // Der Host darf jeden Wunsch anfassen, alle anderen nur die eigenen
const bearbeitet = ref(null) const bearbeitet = ref(null);
const bearbeitung = ref('') const bearbeitung = ref("");
function darfAendern(item) { function darfAendern(item) {
return props.host || item.mine return props.host || item.mine;
} }
function bearbeiten(item) { function bearbeiten(item) {
bearbeitet.value = item.id bearbeitet.value = item.id;
bearbeitung.value = item.text bearbeitung.value = item.text;
} }
async function speichern() { async function speichern() {
const text = bearbeitung.value.trim() const text = bearbeitung.value.trim();
if (text) { if (text) {
await fetch(`/api/features/${bearbeitet.value}`, { await fetch(`/api/features/${bearbeitet.value}`, {
method: 'PATCH', method: "PATCH",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }), body: JSON.stringify({ text }),
}) });
emit('changed') emit("changed");
} }
bearbeitet.value = null bearbeitet.value = null;
} }
// Die Liste kommt vom Server schon sortiert: offene nach Stimmen, dann // Die Liste kommt vom Server schon sortiert: offene nach Stimmen, dann
// die erledigten. Hier nur noch in zwei Abschnitte teilen. // die erledigten. Hier nur noch in zwei Abschnitte teilen.
const offene = computed(() => props.items.filter((w) => !w.done_at)) const offene = computed(() => props.items.filter((w) => !w.done_at));
const erledigte = computed(() => props.items.filter((w) => w.done_at)) const erledigte = computed(() => props.items.filter((w) => w.done_at));
async function anlegen() { async function anlegen() {
if (!entwurf.value.trim()) return if (!entwurf.value.trim()) return;
await fetch('/api/features', { await fetch("/api/features", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: entwurf.value }), body: JSON.stringify({ text: entwurf.value }),
}) });
entwurf.value = '' entwurf.value = "";
emit('changed') emit("changed");
} }
async function loeschen(item) { async function loeschen(item) {
await fetch(`/api/features/${item.id}`, { method: 'DELETE' }) await fetch(`/api/features/${item.id}`, { method: "DELETE" });
emit('changed') emit("changed");
} }
async function abstimmen(item) { async function abstimmen(item) {
await fetch(`/api/features/${item.id}/vote`, { method: 'POST' }) await fetch(`/api/features/${item.id}/vote`, { method: "POST" });
emit('changed') emit("changed");
} }
async function erledigen(item, done) { async function erledigen(item, done) {
await fetch(`/api/features/${item.id}/done`, { await fetch(`/api/features/${item.id}/done`, {
method: 'PATCH', method: "PATCH",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ done }), body: JSON.stringify({ done }),
}) });
emit('changed') emit("changed");
} }
// Ergänzungen: aufgeklappt je Wunsch, mit eigenem Entwurf // Ergänzungen: aufgeklappt je Wunsch, mit eigenem Entwurf
const aufgeklappt = reactive(new Set()) const aufgeklappt = reactive(new Set());
const notizEntwurf = reactive({}) const notizEntwurf = reactive({});
function umschalten(id) { function umschalten(id) {
if (aufgeklappt.has(id)) aufgeklappt.delete(id) if (aufgeklappt.has(id)) aufgeklappt.delete(id);
else aufgeklappt.add(id) else aufgeklappt.add(id);
} }
function ergaenzungsLabel(item) { function ergaenzungsLabel(item) {
const n = item.notes.length const n = item.notes.length;
if (!n) return 'Ergänzen' if (!n) return "Ergänzen";
return n === 1 ? '1 Ergänzung' : `${n} Ergänzungen` return n === 1 ? "1 Ergänzung" : `${n} Ergänzungen`;
} }
async function anmerken(item) { async function anmerken(item) {
const text = (notizEntwurf[item.id] || '').trim() const text = (notizEntwurf[item.id] || "").trim();
if (!text) return if (!text) return;
await fetch(`/api/features/${item.id}/notes`, { await fetch(`/api/features/${item.id}/notes`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }), body: JSON.stringify({ text }),
}) });
notizEntwurf[item.id] = '' notizEntwurf[item.id] = "";
emit('changed') emit("changed");
} }
async function notizLoeschen(notiz) { async function notizLoeschen(notiz) {
await fetch(`/api/feature-notes/${notiz.id}`, { method: 'DELETE' }) await fetch(`/api/feature-notes/${notiz.id}`, { method: "DELETE" });
emit('changed') emit("changed");
} }
function datum(iso) { function datum(iso) {
return new Date(iso).toLocaleDateString('de-DE', { return new Date(iso).toLocaleDateString("de-DE", {
day: '2-digit', day: "2-digit",
month: '2-digit', month: "2-digit",
year: 'numeric', year: "numeric",
}) });
} }
</script> </script>
<template> <template>
<div class="wuensche-tab"> <div class="wuensche-tab">
<div v-if="angemeldet" class="neu">
<textarea
v-model="entwurf"
rows="2"
maxlength="500"
placeholder="Was fehlt dir auf der Seite?"
@keydown.ctrl.enter="anlegen"
></textarea>
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">
Eintragen
</button>
</div>
<p v-if="!items.length" class="note">Noch keine Wünsche.</p> <p v-if="!items.length" class="note">Noch keine Wünsche.</p>
<section v-for="[titel, liste] in [['Offen', offene], ['Erledigt', erledigte]]" :key="titel"> <!-- Links das Offene samt Eingabe, rechts das Erledigte -->
<template v-if="liste.length"> <div class="spalten">
<h3> <div v-if="angemeldet" class="neu">
{{ titel }} <textarea
<span class="anzahl">{{ liste.length }}</span> v-model="entwurf"
</h3> rows="2"
maxlength="500"
placeholder="Was fehlt dir auf der Seite?"
@keydown.ctrl.enter="anlegen"
></textarea>
<button type="button" :disabled="!entwurf.trim()" @click="anlegen">
Eintragen
</button>
</div>
<ul class="liste"> <section
<li v-for="w in liste" :key="w.id" :class="{ erledigt: w.done_at }"> v-for="[titel, liste] in [
<!-- Eine Stimme je Person; nochmal klicken nimmt sie zurück --> ['Offen', offene],
<button ['Erledigt', erledigte],
type="button" ]"
class="stimme" :key="titel"
:class="{ an: w.voted, hat: w.votes > 0 }" :class="titel === 'Offen' ? 'spalte-offen' : 'spalte-erledigt'"
:disabled="!angemeldet" >
:title="w.voted ? 'Stimme zurücknehmen' : 'Dafür stimmen'" <template v-if="liste.length">
@click="abstimmen(w)" <h3>
> {{ titel }}
▲ {{ w.votes }} <span class="anzahl">{{ liste.length }}</span>
</button> </h3>
<div class="inhalt"> <ul class="liste">
<template v-if="bearbeitet === w.id"> <li v-for="w in liste" :key="w.id" :class="{ erledigt: w.done_at }">
<textarea <!-- Eine Stimme je Person; nochmal klicken nimmt sie zurück -->
v-model="bearbeitung" <button
rows="2" type="button"
maxlength="500" class="stimme"
@keydown.ctrl.enter="speichern" :class="{ an: w.voted, hat: w.votes > 0 }"
@keydown.esc="bearbeitet = null" :disabled="!angemeldet"
></textarea> :title="w.voted ? 'Stimme zurücknehmen' : 'Dafür stimmen'"
<div class="zeile"> @click="abstimmen(w)"
<button type="button" class="klein" @click="speichern">Speichern</button> >
<button type="button" class="klein" @click="bearbeitet = null"> ▲ {{ w.votes }}
Abbrechen </button>
</button>
</div>
</template>
<template v-else> <div class="inhalt">
<p class="text">{{ w.text }}</p> <template v-if="bearbeitet === w.id">
<p class="meta"> <textarea
<span :class="{ eigen: w.mine }">{{ w.name || 'Anonym' }}</span> v-model="bearbeitung"
<span class="punkt">·</span>{{ datum(w.created_at) }} rows="2"
<template v-if="w.done_at">
<span class="punkt">·</span>erledigt {{ datum(w.done_at) }}
</template>
<span class="punkt">·</span>
<button
type="button"
class="link"
:class="{ offen: aufgeklappt.has(w.id) }"
@click="umschalten(w.id)"
>
{{ ergaenzungsLabel(w) }}
</button>
</p>
</template>
<div v-if="aufgeklappt.has(w.id)" class="notizen">
<p v-for="n in w.notes" :key="n.id" class="notiz">
<span class="wer" :class="{ eigen: n.mine }">{{ n.name || 'Anonym' }}</span>
<span class="notiz-text">{{ n.text }}</span>
<button
v-if="host"
type="button"
class="weg sichtbar"
title="Ergänzung entfernen"
@click="notizLoeschen(n)"
>
×
</button>
</p>
<div v-if="angemeldet" class="notiz-neu">
<input
v-model="notizEntwurf[w.id]"
type="text"
maxlength="500" maxlength="500"
placeholder="Ergänzung …" @keydown.ctrl.enter="speichern"
@keydown.enter="anmerken(w)" @keydown.esc="bearbeitet = null"
/> ></textarea>
<button <div class="zeile">
type="button" <button type="button" class="klein" @click="speichern">
class="klein" Speichern
:disabled="!(notizEntwurf[w.id] || '').trim()" </button>
@click="anmerken(w)" <button
> type="button"
Anhängen class="klein"
</button> @click="bearbeitet = null"
>
Abbrechen
</button>
</div>
</template>
<template v-else>
<p class="text">{{ w.text }}</p>
<p class="meta">
<span :class="{ eigen: w.mine }">{{
w.name || "Anonym"
}}</span>
<span class="punkt">·</span>{{ datum(w.created_at) }}
<template v-if="w.done_at">
<span class="punkt">·</span>erledigt
{{ datum(w.done_at) }}
</template>
<span class="punkt">·</span>
<button
type="button"
class="link"
:class="{ offen: aufgeklappt.has(w.id) }"
@click="umschalten(w.id)"
>
{{ ergaenzungsLabel(w) }}
</button>
</p>
</template>
<div v-if="aufgeklappt.has(w.id)" class="notizen">
<p v-for="n in w.notes" :key="n.id" class="notiz">
<span class="wer" :class="{ eigen: n.mine }">{{
n.name || "Anonym"
}}</span>
<span class="notiz-text">{{ n.text }}</span>
<button
v-if="host"
type="button"
class="weg sichtbar"
title="Ergänzung entfernen"
@click="notizLoeschen(n)"
>
×
</button>
</p>
<div v-if="angemeldet" class="notiz-neu">
<input
v-model="notizEntwurf[w.id]"
type="text"
maxlength="500"
placeholder="Ergänzung …"
@keydown.enter="anmerken(w)"
/>
<button
type="button"
class="klein"
:disabled="!(notizEntwurf[w.id] || '').trim()"
@click="anmerken(w)"
>
Anhängen
</button>
</div>
</div> </div>
</div> </div>
</div>
<!-- Erst beim Überfahren sichtbar, sonst ist jede Zeile voller Knöpfe --> <!-- Erst beim Überfahren sichtbar, sonst ist jede Zeile voller Knöpfe -->
<div class="aktionen"> <div class="aktionen">
<button <button
v-if="host" v-if="host"
type="button" type="button"
class="weg" class="weg"
:title="w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'" :title="
@click="erledigen(w, !w.done_at)" w.done_at ? 'Wieder öffnen' : 'Als erledigt markieren'
> "
{{ w.done_at ? '↺' : '✓' }} @click="erledigen(w, !w.done_at)"
</button> >
<button {{ w.done_at ? "↺" : "✓" }}
v-if="darfAendern(w) && bearbeitet !== w.id" </button>
type="button" <button
class="weg" v-if="darfAendern(w) && bearbeitet !== w.id"
title="Wunsch bearbeiten" type="button"
@click="bearbeiten(w)" class="weg"
> title="Wunsch bearbeiten"
✎ @click="bearbeiten(w)"
</button> >
<button ✎
v-if="darfAendern(w)" </button>
type="button" <button
class="weg" v-if="darfAendern(w)"
title="Wunsch entfernen" type="button"
@click="loeschen(w)" class="weg"
> title="Wunsch entfernen"
× @click="loeschen(w)"
</button> >
</div> ×
</li> </button>
</ul> </div>
</template> </li>
</section> </ul>
</template>
</section>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.wuensche-tab { .spalten {
max-width: 46rem; display: grid;
grid-template-areas:
"neu erledigt"
"offen erledigt";
grid-template-columns: 1fr 1fr;
grid-template-rows: auto 1fr;
gap: 0 2.5rem;
} }
.neu { .neu {
display: flex; display: flex;
grid-area: neu;
gap: 0.5rem; gap: 0.5rem;
align-items: flex-end; align-items: flex-end;
margin-bottom: 1.75rem; margin-bottom: 1.75rem;
} }
.spalte-offen {
grid-area: offen;
}
.spalte-erledigt {
grid-area: erledigt;
}
.neu textarea { .neu textarea {
flex: 1; flex: 1;
padding: 0.6rem 0.8rem; padding: 0.6rem 0.8rem;
@@ -517,6 +555,16 @@ h3 {
} }
} }
@media (max-width: 900px) {
.spalten {
grid-template-areas:
"neu"
"offen"
"erledigt";
grid-template-columns: 1fr;
}
}
@media (max-width: 640px), (pointer: coarse) { @media (max-width: 640px), (pointer: coarse) {
.stimme { .stimme {
padding: 0.4rem 0.7rem; padding: 0.4rem 0.7rem;

View File

@@ -29,10 +29,14 @@ async function hostStand() {
} }
const TAB_IDS = [ const TAB_IDS = [
'netflix', 'canon', 'search', 'aehnlich', 'wheel', 'merkliste', 'watched', 'katalog', 'suche', 'wheel', 'merkliste', 'watched', 'wuensche', 'verwaltung', 'stream',
'wuensche', 'verwaltung', 'stream',
] ]
// Netflix und Kanon waren einmal zwei Reiter — heute eine Quelle im Katalog
const QUELLEN = ['netflix', 'canon']
// Ebenso die drei Suchen: heute ein Reiter mit Umschalter darüber
const SUCHARTEN = ['search', 'aehnlich', 'ki']
function gemerkt(schluessel, standard) { function gemerkt(schluessel, standard) {
const wert = ref(localStorage.getItem(schluessel) ?? standard) const wert = ref(localStorage.getItem(schluessel) ?? standard)
watch(wert, (neu) => localStorage.setItem(schluessel, neu)) watch(wert, (neu) => localStorage.setItem(schluessel, neu))
@@ -50,11 +54,21 @@ export function useEinstellungen() {
localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus') localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus')
) )
// Nach dem Neuladen soll derselbe Reiter offen sein // Nach dem Neuladen soll derselbe Reiter offen sein. Ein gemerkter
// alter Reiter "netflix" oder "canon" landet im Katalog.
const zuletzt = localStorage.getItem('filmabend_tab') const zuletzt = localStorage.getItem('filmabend_tab')
const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'netflix') const activeTab = ref(TAB_IDS.includes(zuletzt) ? zuletzt : 'katalog')
watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id)) watch(activeTab, (id) => localStorage.setItem('filmabend_tab', id))
// Welche Liste der Katalog zeigt: Netflix oder den Horror-Kanon
const quelle = gemerkt('filmabend_quelle', QUELLEN.includes(zuletzt) ? zuletzt : 'netflix')
if (!QUELLEN.includes(quelle.value)) quelle.value = 'netflix'
// Welche Suche offen ist: Titel, Ähnliche oder KI
const suchArt = gemerkt('filmabend_suchart', SUCHARTEN.includes(zuletzt) ? zuletzt : 'search')
if (!SUCHARTEN.includes(suchArt.value)) suchArt.value = 'search'
if (SUCHARTEN.includes(zuletzt)) activeTab.value = 'suche'
// Wer den Abend leitet, darf ziehen und die Runde leeren. Zwei Dinge // Wer den Abend leitet, darf ziehen und die Runde leeren. Zwei Dinge
// hängen daran: ob die Sitzung überhaupt freigeschaltet ist — das weiß // hängen daran: ob die Sitzung überhaupt freigeschaltet ist — das weiß
// der Server — und ob die Knöpfe gerade sichtbar sind. Letzteres schaltet // der Server — und ob die Knöpfe gerade sichtbar sind. Letzteres schaltet
@@ -100,5 +114,5 @@ export function useEinstellungen() {
onMounted(() => window.addEventListener('keydown', tastatur)) onMounted(() => window.addEventListener('keydown', tastatur))
onUnmounted(() => window.removeEventListener('keydown', tastatur)) onUnmounted(() => window.removeEventListener('keydown', tastatur))
return { ansicht, ohneGesehene, activeTab, host } return { ansicht, ohneGesehene, activeTab, quelle, suchArt, host }
} }

View File

@@ -28,7 +28,9 @@ const SUCH_GENRES = [
'Western', 'Western',
].map((genre) => ({ genre, count: null })) ].map((genre) => ({ genre, count: null }))
export function useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, ohneGesehene }) { export function useFilter({
activeTab, quelle, netflixMovies, canonMovies, gesehenIds, ohneGesehene,
}) {
const search = ref('') const search = ref('')
const sort = ref('random') const sort = ref('random')
// Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert // Suche und Ähnlichkeitsuche haben eine eigene Voreinstellung: TMDB liefert
@@ -38,8 +40,8 @@ export function useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, o
const include = ref([]) const include = ref([])
const exclude = ref([]) const exclude = ref([])
const suchReiter = () => ['search', 'aehnlich', 'ki'].includes(activeTab.value) const suchReiter = () => activeTab.value === 'suche'
const pool = () => (activeTab.value === 'netflix' ? netflixMovies.value : canonMovies.value) const pool = () => (quelle.value === 'netflix' ? netflixMovies.value : canonMovies.value)
const grenzen = computed(() => { const grenzen = computed(() => {
if (suchReiter()) return SUCH_GRENZEN if (suchReiter()) return SUCH_GRENZEN
@@ -80,7 +82,7 @@ export function useFilter({ activeTab, netflixMovies, canonMovies, gesehenIds, o
return true return true
} }
// Nur Genres anbieten, die im offenen Reiter auch Treffer haben // Nur Genres anbieten, die in der gewählten Quelle auch Treffer haben
const genreOptions = computed(() => { const genreOptions = computed(() => {
if (suchReiter()) return SUCH_GENRES if (suchReiter()) return SUCH_GENRES
const counts = new Map() const counts = new Map()