This commit is contained in:
team3
2026-09-16 12:09:38 +02:00
parent aa5c64ae23
commit 90fffbe077
15 changed files with 326 additions and 40 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: install dev stop sync build prod logs down push pull stream stream-local stream-stop mtx mtx-stop .PHONY: install dev stop sync build prod logs down pull stream stream-local stream-stop mtx mtx-stop
install: install:
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
@@ -78,7 +78,7 @@ stream-stop:
-@scripts/ton.sh ab -@scripts/ton.sh ab
@echo "OBS beendet." @echo "OBS beendet."
# ── Datenbank hin und her (lokal ausführen) ─────────────────────────────────── # ── Datenbank vom Server holen (lokal ausführen) ───────────────────────────────────
SERVER = root@178.104.67.87 SERVER = root@178.104.67.87
REMOTE = /var/www/horror REMOTE = /var/www/horror
LOKAL = backend/horror.db LOKAL = backend/horror.db
@@ -87,14 +87,6 @@ LOKAL = backend/horror.db
# in sich stimmige Kopie ziehen, statt die Datei direkt anzufassen. # in sich stimmige Kopie ziehen, statt die Datei direkt anzufassen.
SICHERN = import sqlite3,sys; q=sqlite3.connect(sys.argv[1]); z=sqlite3.connect(sys.argv[2]); q.backup(z); z.close(); q.close() SICHERN = import sqlite3,sys; q=sqlite3.connect(sys.argv[1]); z=sqlite3.connect(sys.argv[2]); q.backup(z); z.close(); q.close()
push:
@echo "Überschreibt die Datenbank auf dem Server, auch die Vorschläge."
backend/.venv/bin/python -c '$(SICHERN)' $(LOKAL) /tmp/horror-push.db
ssh $(SERVER) 'mkdir -p $(REMOTE)/data && [ ! -f $(REMOTE)/data/horror.db ] || cp $(REMOTE)/data/horror.db $(REMOTE)/data/horror.db.bak'
scp /tmp/horror-push.db $(SERVER):$(REMOTE)/data/horror.db
@rm -f /tmp/horror-push.db
@echo "Fertig. Die alte Datenbank liegt als data/horror.db.bak daneben."
pull: pull:
@echo "Überschreibt deine lokale Datenbank." @echo "Überschreibt deine lokale Datenbank."
ssh $(SERVER) 'python3 -c "$(SICHERN)" $(REMOTE)/data/horror.db /tmp/horror-pull.db' ssh $(SERVER) 'python3 -c "$(SICHERN)" $(REMOTE)/data/horror.db /tmp/horror-pull.db'

View File

@@ -56,19 +56,19 @@ make down
Die SQLite-Datei liegt im Bind-Mount `./data`, überlebt also jeden Rebuild. Die SQLite-Datei liegt im Bind-Mount `./data`, überlebt also jeden Rebuild.
### Datenbank abgleichen ### Datenbank vom Server holen
```bash ```bash
make push # lokaler Stand → Server
make pull # Server-Stand → lokal make pull # Server-Stand → lokal
``` ```
Beide ziehen erst eine in sich stimmige Kopie über SQLites `backup`, statt die Zieht erst eine in sich stimmige Kopie über SQLites `backup`, statt die Datei
Datei einer laufenden App direkt zu kopieren. Die überschriebene Datenbank einer laufenden App direkt zu kopieren. Die überschriebene lokale Datenbank
bleibt als `.bak` daneben liegen. bleibt als `.bak` daneben liegen.
`make push` überschreibt auch die Vorschläge auf dem Server. Wer schon einen Den umgekehrten Weg gibt es absichtlich nicht: Die Sammlungen, Vorschläge und
Film fürs Rad eingereicht hat, verliert ihn. Wertungen auf dem Server sind die echten. Ein lokaler Stand würde sie
überschreiben.
Ohne Docker geht es auch: Ohne Docker geht es auch:

View File

@@ -1,14 +1,19 @@
"""Was der Seite noch fehlt. """Was der Seite noch fehlt.
Jeder darf aufschreiben, was er sich wünscht. Alle sehen alle Wünsche, Jeder darf aufschreiben, was er sich wünscht, und für Wünsche anderer
ändern darf jeder nur die eigenen — wie bei den Filmsammlungen. stimmen — eine Stimme je Person. Ergänzungen hängen als Anmerkungen unter
dem Wunsch, damit der ursprüngliche Text stehen bleibt und die Diskussion
nachvollziehbar ist. Der Host hakt ab, was umgesetzt ist: der Wunsch bleibt
sichtbar, rutscht aber ans Ende.
Ändern darf jeder nur die eigenen, wie bei den Filmsammlungen.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
from . import users from . import users
from .db import connect from .db import connect
from .users import besitzer, gehoert_mir from .users import WEM, besitzer, gehoert_mir
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS features ( CREATE TABLE IF NOT EXISTS features (
@@ -16,6 +21,28 @@ CREATE TABLE IF NOT EXISTS features (
session_id TEXT NOT NULL, session_id TEXT NOT NULL,
user_id INTEGER, user_id INTEGER,
text TEXT NOT NULL, text TEXT NOT NULL,
created_at TEXT NOT NULL,
done_at TEXT
);
CREATE TABLE IF NOT EXISTS feature_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feature_id INTEGER NOT NULL,
session_id TEXT NOT NULL,
user_id INTEGER,
text TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
# Eigenes Statement, weil users.je_person() die Tabelle daraus neu bauen
# könnte — hier ohne UNIQUE in der Spaltendefinition, die Eindeutigkeit
# kommt aus den Indizes je Person.
VOTES_SCHEMA = """
CREATE TABLE IF NOT EXISTS feature_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feature_id INTEGER NOT NULL,
session_id TEXT NOT NULL,
user_id INTEGER,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
""" """
@@ -23,21 +50,64 @@ CREATE TABLE IF NOT EXISTS features (
MAX_LAENGE = 500 MAX_LAENGE = 500
def _jetzt():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def init(): def init():
with connect() as conn: with connect() as conn:
conn.executescript(SCHEMA) conn.executescript(SCHEMA)
conn.executescript(VOTES_SCHEMA)
users.user_id_nachruesten(conn, "features") users.user_id_nachruesten(conn, "features")
# Die Tabelle stand vor der Spalte schon in bestehenden Datenbanken
spalten = {r[1] for r in conn.execute("PRAGMA table_info(features)")}
if "done_at" not in spalten:
conn.execute("ALTER TABLE features ADD COLUMN done_at TEXT")
# Eine Stimme je Person und Wunsch
users.je_person(conn, "feature_votes", VOTES_SCHEMA, "feature_id")
def alle(session_id): def alle(session_id):
"""Alle Wünsche, jeder mit dem Namen seines Verfassers.""" """Alle Wünsche: offene nach Stimmen, erledigte dahinter.
Jeder Wunsch bringt den Namen des Verfassers, die Stimmenzahl, die
eigene Stimme und seine Anmerkungen mit.
"""
ich = besitzer(session_id)
with connect() as conn: with connect() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT f.*, u.name FROM features f " "SELECT f.*, u.name, "
" (SELECT COUNT(*) FROM feature_votes v WHERE v.feature_id = f.id) "
" AS votes "
"FROM features f "
"LEFT JOIN users u ON u.id = f.user_id " "LEFT JOIN users u ON u.id = f.user_id "
"ORDER BY f.created_at DESC" "ORDER BY f.done_at IS NOT NULL, votes DESC, f.created_at DESC"
).fetchall() ).fetchall()
ich = besitzer(session_id) meine_stimmen = {
r["feature_id"]
for r in conn.execute(
f"SELECT feature_id FROM feature_votes WHERE {WEM}",
(ich, ich, session_id),
)
}
notizen = conn.execute(
"SELECT n.*, u.name FROM feature_notes n "
"LEFT JOIN users u ON u.id = n.user_id "
"ORDER BY n.created_at ASC"
).fetchall()
je_wunsch = {}
for n in notizen:
je_wunsch.setdefault(n["feature_id"], []).append(
{
"id": n["id"],
"text": n["text"],
"name": n["name"],
"created_at": n["created_at"],
"mine": gehoert_mir(n, session_id, ich),
}
)
return [ return [
{ {
**{ **{
@@ -46,6 +116,8 @@ def alle(session_id):
if k not in ("session_id", "user_id") if k not in ("session_id", "user_id")
}, },
"mine": gehoert_mir(r, session_id, ich), "mine": gehoert_mir(r, session_id, ich),
"voted": r["id"] in meine_stimmen,
"notes": je_wunsch.get(r["id"], []),
} }
for r in rows for r in rows
] ]
@@ -60,12 +132,7 @@ def anlegen(session_id, text):
conn.execute( conn.execute(
"INSERT INTO features (session_id, user_id, text, created_at) " "INSERT INTO features (session_id, user_id, text, created_at) "
"VALUES (?, ?, ?, ?)", "VALUES (?, ?, ?, ?)",
( (session_id, besitzer(session_id), text, _jetzt()),
session_id,
besitzer(session_id),
text,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
),
) )
return alle(session_id) return alle(session_id)
@@ -81,10 +148,73 @@ def aendern(feature_id, text):
).rowcount ).rowcount
def erledigen(feature_id, erledigt):
"""Der Host hakt ab — oder holt einen Wunsch wieder hervor."""
with connect() as conn:
return conn.execute(
"UPDATE features SET done_at = ? WHERE id = ?",
(_jetzt() if erledigt else None, feature_id),
).rowcount
def abstimmen(feature_id, session_id):
"""Stimme setzen oder zurücknehmen. Gibt None zurück, wenn es den
Wunsch nicht gibt, sonst ob die Stimme jetzt steht."""
ich = besitzer(session_id)
with connect() as conn:
weg = conn.execute(
f"DELETE FROM feature_votes WHERE feature_id = ? AND {WEM}",
(feature_id, ich, ich, session_id),
).rowcount
if weg:
return False
da = conn.execute(
"SELECT 1 FROM features WHERE id = ?", (feature_id,)
).fetchone()
if not da:
return None
conn.execute(
"INSERT INTO feature_votes (feature_id, session_id, user_id, created_at) "
"VALUES (?, ?, ?, ?)",
(feature_id, session_id, ich, _jetzt()),
)
return True
def anmerken(feature_id, session_id, text):
"""Eine Ergänzung unter den Wunsch hängen."""
text = (text or "").strip()[:MAX_LAENGE]
if not text:
return False
with connect() as conn:
da = conn.execute(
"SELECT 1 FROM features WHERE id = ?", (feature_id,)
).fetchone()
if not da:
return False
conn.execute(
"INSERT INTO feature_notes "
"(feature_id, session_id, user_id, text, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(feature_id, session_id, besitzer(session_id), text, _jetzt()),
)
return True
def anmerkung_loeschen(note_id):
"""Ohne Sitzungsbindung — den Knopf zeigt die Oberfläche nur dem Host."""
with connect() as conn:
return conn.execute(
"DELETE FROM feature_notes WHERE id = ?", (note_id,)
).rowcount
def loeschen(feature_id): def loeschen(feature_id):
"""Ohne Sitzungsbindung — den Knopf zeigt die Oberfläche nur dem """Ohne Sitzungsbindung — den Knopf zeigt die Oberfläche nur dem
Verfasser und dem Host.""" Verfasser und dem Host. Stimmen und Anmerkungen gehen mit."""
with connect() as conn: with connect() as conn:
conn.execute("DELETE FROM feature_votes WHERE feature_id = ?", (feature_id,))
conn.execute("DELETE FROM feature_notes WHERE feature_id = ?", (feature_id,))
return conn.execute( return conn.execute(
"DELETE FROM features WHERE id = ?", (feature_id,) "DELETE FROM features WHERE id = ?", (feature_id,)
).rowcount ).rowcount

View File

@@ -380,6 +380,54 @@ def wunsch_loeschen(feature_id: int):
return {"removed": entfernt} return {"removed": entfernt}
@app.post("/api/features/{feature_id}/vote")
def wunsch_abstimmen(
feature_id: int,
response: Response,
filmabend_sid: str | None = Cookie(None),
):
"""Ein Klick setzt die Stimme, der nächste nimmt sie zurück."""
sid = sitzung(response, filmabend_sid)
steht = features.abstimmen(feature_id, sid)
if steht is None:
raise HTTPException(status_code=404, detail="Wunsch nicht gefunden")
events.senden("features")
return {"voted": steht}
@app.patch("/api/features/{feature_id}/done")
def wunsch_erledigen(feature_id: int, done: bool = Body(..., embed=True)):
"""Der Host hakt ab — den Knopf sieht nur er."""
if not features.erledigen(feature_id, done):
raise HTTPException(status_code=404, detail="Wunsch nicht gefunden")
events.senden("features")
return {"ok": True}
@app.post("/api/features/{feature_id}/notes")
def wunsch_anmerken(
feature_id: int,
response: Response,
text: str = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
if not features.anmerken(feature_id, sid, text):
raise HTTPException(status_code=400, detail="Kein Text oder Wunsch unbekannt")
events.senden("features")
return {"ok": True}
# Eigener Pfad statt /api/features/notes/{id}: der stünde sonst mit
# /api/features/{feature_id} im Weg
@app.delete("/api/feature-notes/{note_id}")
def anmerkung_loeschen(note_id: int):
entfernt = features.anmerkung_loeschen(note_id)
if entfernt:
events.senden("features")
return {"removed": entfernt}
@app.get("/api/wishlist") @app.get("/api/wishlist")
def merkliste(response: Response, filmabend_sid: str | None = Cookie(None)): def merkliste(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Was diese Sitzung sich vorgemerkt hat.""" """Was diese Sitzung sich vorgemerkt hat."""

View File

@@ -132,8 +132,13 @@ def ziehen():
treffer = random.choice(frisch) if frisch else random.randrange(len(eintraege)) treffer = random.choice(frisch) if frisch else random.randrange(len(eintraege))
schritt = 360 / len(eintraege) schritt = 360 / len(eintraege)
runden = 5 + random.randrange(3) runden = 5 + random.randrange(3)
# Der Zeiger steht oben, also muss die Mitte des Zielsegments dorthin # Der Zeiger steht oben, also muss die Mitte des Zielsegments dorthin.
winkel = runden * 360 + (360 - (treffer * schritt + schritt / 2)) # Ein einzelner Vorschlag ist ein Vollkreis mit Cover — der soll nach
# dem Dreh aufrecht stehen, also bei 0° enden statt bei 180°.
if len(eintraege) == 1:
winkel = runden * 360
else:
winkel = runden * 360 + (360 - (treffer * schritt + schritt / 2))
return { return {
"index": treffer, "index": treffer,

BIN
backend/horror.db.bak Normal file

Binary file not shown.

View File

@@ -31,6 +31,13 @@ const ANSICHT_SCHLUESSEL = 'filmabend_ansicht'
const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt') const ansicht = ref(localStorage.getItem(ANSICHT_SCHLUESSEL) || 'kompakt')
watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert)) watch(ansicht, (wert) => localStorage.setItem(ANSICHT_SCHLUESSEL, wert))
// Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus.
// Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten.
const GESEHEN_SCHLUESSEL = 'filmabend_ohne_gesehene'
const ohneGesehene = ref(localStorage.getItem(GESEHEN_SCHLUESSEL) !== 'aus')
watch(ohneGesehene, (an) => localStorage.setItem(GESEHEN_SCHLUESSEL, an ? 'an' : 'aus'))
const selected = ref(null) const selected = ref(null)
// Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir // Der Katalog liefert nur sechs Bildpfade je Film. Beim Öffnen holen wir
@@ -252,8 +259,12 @@ const netflixEntries = computed(() => {
}) })
const tabs = computed(() => [ const tabs = computed(() => [
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length }, { id: 'netflix', label: 'Netflix', count: netflixMovies.value.filter(nichtGesehen).length },
{ id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length }, {
id: 'canon',
label: 'Horror-Kanon',
count: canonGroups.value.filter((g) => g.parts.some(nichtGesehen)).length,
},
{ id: 'search', label: 'Suche', count: null }, { id: 'search', label: 'Suche', count: null },
{ id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null }, { id: 'aehnlich', label: 'Ähnlichkeitsuche', count: null },
...(host.value ...(host.value
@@ -262,7 +273,7 @@ const tabs = computed(() => [
{ id: 'wheel', label: 'Rad', count: null }, { id: 'wheel', label: 'Rad', count: null },
{ id: 'stream', label: 'Stream', count: null }, { id: 'stream', label: 'Stream', count: null },
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value }, { id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
{ id: 'wuensche', label: 'Wünsche', count: wuensche.value.length }, { id: 'wuensche', label: 'Wünsche', count: wuensche.value.filter((w) => !w.done_at).length },
...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []), ...(host.value ? [{ id: 'verwaltung', label: 'Verwaltung', count: null }] : []),
]) ])
@@ -489,8 +500,12 @@ function matches(movie, term) {
// Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will // Eingeschlossene Genres wirken als ODER: wer Horror und Action wählt, will
// beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm // beides sehen. Ein ausgeschlossenes Genre sticht das immer — ein Horrorfilm
// mit Komödie fliegt raus, wenn Komödie ausgeblendet ist. // mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
function nichtGesehen(movie) {
return !ohneGesehene.value || !gesehenIds.value.has(movie.id)
}
function passt(movie) { function passt(movie) {
return hasGenre(movie) && imBereich(movie) return nichtGesehen(movie) && hasGenre(movie) && imBereich(movie)
} }
function hasGenre(movie) { function hasGenre(movie) {
@@ -665,6 +680,7 @@ onUnmounted(() => {
<SearchTab <SearchTab
v-if="activeTab === 'search'" v-if="activeTab === 'search'"
:gesehen-ids="gesehenIds" :gesehen-ids="gesehenIds"
:ohne-gesehene="ohneGesehene"
:filter="suchFilter" :filter="suchFilter"
:ansicht="ansicht" :ansicht="ansicht"
@open="selected = $event" @open="selected = $event"
@@ -683,6 +699,7 @@ onUnmounted(() => {
<FilterSidebar <FilterSidebar
v-model:sort="suchSort" v-model:sort="suchSort"
v-model:ansicht="ansicht" v-model:ansicht="ansicht"
v-model:ohne-gesehene="ohneGesehene"
v-model:ranges="ranges" v-model:ranges="ranges"
:regler="REGLER" :regler="REGLER"
:grenzen="grenzen" :grenzen="grenzen"
@@ -779,6 +796,7 @@ onUnmounted(() => {
v-model:search="search" v-model:search="search"
v-model:sort="sort" v-model:sort="sort"
v-model:ansicht="ansicht" v-model:ansicht="ansicht"
v-model:ohne-gesehene="ohneGesehene"
v-model:ranges="ranges" v-model:ranges="ranges"
:regler="REGLER" :regler="REGLER"
:grenzen="grenzen" :grenzen="grenzen"

View File

@@ -14,6 +14,7 @@ const props = defineProps({
filter: { type: Object, default: () => ({}) }, filter: { type: Object, default: () => ({}) },
ansicht: { type: String, default: 'kompakt' }, ansicht: { type: String, default: 'kompakt' },
gesehenIds: { type: Set, default: () => new Set() }, gesehenIds: { type: Set, default: () => new Set() },
ohneGesehene: { type: Boolean, default: true },
// Kommt aus der Filmansicht: von diesem Film soll es losgehen // Kommt aus der Filmansicht: von diesem Film soll es losgehen
start: { type: Object, default: null }, start: { type: Object, default: null },
}) })
@@ -106,6 +107,7 @@ async function nachladen() {
// Genres, Jahr, Stimmen und Note filtert schon das Backend. Übrig bleibt // Genres, Jahr, Stimmen und Note filtert schon das Backend. Übrig bleibt
// die Laufzeit: die steht erst in der Detailantwort, nicht in der Liste. // die Laufzeit: die steht erst in der Detailantwort, nicht in der Liste.
function passt(movie) { function passt(movie) {
if (props.ohneGesehene && props.gesehenIds.has(movie.id)) return false
const werte = props.filter.dauer const werte = props.filter.dauer
const grenze = props.filter.grenzen?.runtime const grenze = props.filter.grenzen?.runtime
if (!werte || !grenze || movie.runtime == null) return true if (!werte || !grenze || movie.runtime == null) return true

View File

@@ -19,6 +19,7 @@ const emit = defineEmits(['toggle', 'all', 'reset'])
const search = defineModel('search', { type: String, default: '' }) const search = defineModel('search', { type: String, default: '' })
const sort = defineModel('sort', { type: String, default: 'random' }) 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 ranges = defineModel('ranges', { type: Object, default: () => ({}) }) const ranges = defineModel('ranges', { type: Object, default: () => ({}) })
// Auch ein verschobener Regler zählt als aktiver Filter // Auch ein verschobener Regler zählt als aktiver Filter
@@ -81,6 +82,11 @@ function beschriften(regler) {
Details Details
</button> </button>
</div> </div>
<label class="gesehen-schalter">
<input v-model="ohneGesehene" type="checkbox" />
Gesehene ausblenden
</label>
</div> </div>
<div class="head"> <div class="head">
@@ -158,6 +164,25 @@ function beschriften(regler) {
</template> </template>
<style scoped> <style scoped>
.gesehen-schalter {
display: flex;
gap: 0.5rem;
align-items: center;
/* Eigene Zeile unter Kompakt/Details, sonst quetscht er sich daneben */
width: 100%;
white-space: nowrap;
color: var(--muted);
font-size: 0.82rem;
cursor: pointer;
user-select: none;
}
/* .controls input zieht sonst auf volle Breite — die Checkbox bleibt schmal */
.controls .gesehen-schalter input {
width: auto;
accent-color: var(--accent);
}
.sidebar { .sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -135,10 +135,12 @@ async function oeffnen(item) {
/* Schon gelaufen — die Karte tritt zurück, kommt beim Überfahren wieder */ /* Schon gelaufen — die Karte tritt zurück, kommt beim Überfahren wieder */
.karte.gesehen { .karte.gesehen {
opacity: 0.45; opacity: 0.45;
filter: grayscale(1);
} }
.karte.gesehen:hover { .karte.gesehen:hover {
opacity: 1; opacity: 1;
filter: none;
} }
.karte { .karte {

View File

@@ -75,10 +75,12 @@ const meta = computed(() =>
Beim Überfahren kommt sie zurück. */ Beim Überfahren kommt sie zurück. */
.card.gesehen { .card.gesehen {
opacity: 0.45; opacity: 0.45;
filter: grayscale(1);
} }
.card.gesehen:hover { .card.gesehen:hover {
opacity: 1; opacity: 1;
filter: none;
} }
/* Details: breite Karte, Poster links, alles lesbar statt abgeschnitten */ /* Details: breite Karte, Poster links, alles lesbar statt abgeschnitten */

View File

@@ -8,6 +8,7 @@ const props = defineProps({
filter: { type: Object, default: () => ({}) }, filter: { type: Object, default: () => ({}) },
ansicht: { type: String, default: 'kompakt' }, ansicht: { type: String, default: 'kompakt' },
gesehenIds: { type: Set, default: () => new Set() }, gesehenIds: { type: Set, default: () => new Set() },
ohneGesehene: { type: Boolean, default: true },
}) })
const emit = defineEmits(['open']) const emit = defineEmits(['open'])
@@ -44,6 +45,11 @@ function passt(movie) {
) )
} }
// Schon gelaufene Filme fallen weg, solange der Schalter in der Leiste steht
function nichtGesehen(movie) {
return !props.ohneGesehene || !props.gesehenIds.has(movie.id)
}
async function run(term) { async function run(term) {
const meine = ++laufende const meine = ++laufende
loading.value = true loading.value = true
@@ -58,7 +64,7 @@ async function run(term) {
const data = await resp.json() const data = await resp.json()
// Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben // Eine ältere Anfrage darf ein neueres Ergebnis nicht überschreiben
if (meine !== laufende) return if (meine !== laufende) return
results.value = stoebern ? data : data.filter(passt) results.value = (stoebern ? data : data.filter(passt)).filter(nichtGesehen)
gestoebert.value = stoebern gestoebert.value = stoebern
} catch (err) { } catch (err) {
if (meine === laufende) error.value = err.message if (meine === laufende) error.value = err.message
@@ -85,6 +91,7 @@ function leeren() {
watch(query, spaeterSuchen) watch(query, spaeterSuchen)
watch(() => props.filter, spaeterSuchen, { deep: true, immediate: true }) watch(() => props.filter, spaeterSuchen, { deep: true, immediate: true })
watch(() => props.ohneGesehene, spaeterSuchen)
async function open(movie) { async function open(movie) {
const resp = await fetch(`/api/search/${movie.id}`) const resp = await fetch(`/api/search/${movie.id}`)

View File

@@ -99,10 +99,12 @@ function step(delta) {
Beim Überfahren kommt sie zurück. */ Beim Überfahren kommt sie zurück. */
.card.gesehen { .card.gesehen {
opacity: 0.45; opacity: 0.45;
filter: grayscale(1);
} }
.card.gesehen:hover { .card.gesehen:hover {
opacity: 1; opacity: 1;
filter: none;
} }
.card { .card {

View File

@@ -120,10 +120,27 @@ const segmente = computed(() => {
// Auf der linken Hälfte stünde die Schrift kopfüber — dort läuft sie // Auf der linken Hälfte stünde die Schrift kopfüber — dort läuft sie
// von außen nach innen // von außen nach innen
const gespiegelt = mitte > 180 const gespiegelt = mitte > 180
// Das Cover liegt als Rechteck radial im Stück: von der Nabe nach
// außen, so breit wie die Sehne am Rand. So landet die Postermitte
// auf halbem Radius — dort, wo das Stück am besten zu sehen ist.
// Ein einziger Vorschlag ist ein Vollkreis — da gibt es keine Sehne,
// das Bild deckt dann den ganzen Kreis als Quadrat ab.
const voll = n === 1
const sehne = 2 * RADIUS * Math.sin(((schritt / 2) * Math.PI) / 180)
const bildBreite = voll ? 2 * RADIUS : Math.max(sehne + 12, 60)
return { return {
item, item,
farbe: FARBEN[i % FARBEN.length], farbe: FARBEN[i % FARBEN.length],
pfad: kreisstueck(start, ende), pfad: kreisstueck(start, ende),
bild: posterUrl(item.poster_path, 'w342'),
bildX: MITTE - bildBreite / 2,
bildY: MITTE - RADIUS - (voll ? 0 : 6),
bildBreite,
bildHoehe: voll ? 2 * RADIUS : RADIUS + 6,
// Im Vollkreis läge die Segmentmitte bei 180° — das Cover stünde
// kopfüber. Ohne Drehung bleibt es aufrecht.
bildDreh: voll ? 0 : mitte,
mitte,
textDreh: gespiegelt ? mitte + 90 : mitte - 90, textDreh: gespiegelt ? mitte + 90 : mitte - 90,
gespiegelt, gespiegelt,
// Unter dieser Segmentbreite passt kein lesbares Wort mehr hinein // Unter dieser Segmentbreite passt kein lesbares Wort mehr hinein
@@ -245,9 +262,41 @@ async function oeffnen(item) {
transitionDuration: `${dauer}s`, transitionDuration: `${dauer}s`,
}" }"
> >
<defs>
<!-- Zur Nabe hin wird es dunkel, damit der Titel lesbar bleibt -->
<radialGradient
id="rad-schatten"
gradientUnits="userSpaceOnUse"
:cx="MITTE"
:cy="MITTE"
:r="RADIUS"
>
<stop offset="0" stop-color="#0a0a0c" stop-opacity="0.92" />
<stop offset="0.45" stop-color="#0a0a0c" stop-opacity="0.55" />
<stop offset="1" stop-color="#0a0a0c" stop-opacity="0.1" />
</radialGradient>
<clipPath v-for="seg in segmente" :id="`seg-${seg.item.id}`" :key="seg.item.id">
<path :d="seg.pfad" />
</clipPath>
</defs>
<circle :cx="MITTE" :cy="MITTE" :r="RADIUS" fill="var(--bg-soft)" /> <circle :cx="MITTE" :cy="MITTE" :r="RADIUS" fill="var(--bg-soft)" />
<g v-for="(seg, i) in segmente" :key="seg.item.id"> <g v-for="(seg, i) in segmente" :key="seg.item.id">
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" /> <g :clip-path="`url(#seg-${seg.item.id})`">
<path :d="seg.pfad" :fill="seg.farbe" />
<image
v-if="seg.bild"
:href="seg.bild"
:x="seg.bildX"
:y="seg.bildY"
:width="seg.bildBreite"
:height="seg.bildHoehe"
preserveAspectRatio="xMidYMid slice"
:transform="`rotate(${seg.bildDreh} ${MITTE} ${MITTE})`"
/>
<path :d="seg.pfad" fill="url(#rad-schatten)" />
</g>
<!-- Der farbige Rand ist das Gegenstück zum Punkt in der Liste -->
<path :d="seg.pfad" fill="none" :stroke="seg.farbe" stroke-width="3" />
<text <text
v-if="seg.breit" v-if="seg.breit"
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`" :transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
@@ -383,9 +432,11 @@ async function oeffnen(item) {
fill: #fff; fill: #fff;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
/* Unter der Schrift liegt jetzt ein Cover statt einer Fläche — die
Kontur muss auch auf hellen Bildstellen tragen */
paint-order: stroke; paint-order: stroke;
stroke: rgba(0, 0, 0, 0.45); stroke: rgba(0, 0, 0, 0.75);
stroke-width: 3px; stroke-width: 3.5px;
} }
.zeiger { .zeiger {
@@ -567,10 +618,12 @@ async function oeffnen(item) {
/* Schon gelaufen — die Ziehung überspringt diesen Film */ /* Schon gelaufen — die Ziehung überspringt diesen Film */
.eintrag.gesehen { .eintrag.gesehen {
opacity: 0.45; opacity: 0.45;
filter: grayscale(1);
} }
.eintrag.gesehen:hover { .eintrag.gesehen:hover {
opacity: 1; opacity: 1;
filter: none;
} }
.eintrag:hover { .eintrag:hover {