update
This commit is contained in:
12
Makefile
12
Makefile
@@ -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'
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
BIN
backend/horror.db.bak
Normal file
Binary file not shown.
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user