update
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 = {}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user