commit 526b0353e7a27ee75e5d79bc519b3d65de8f7d08 Author: SimolZimol <70102430+SimolZimol@users.noreply.github.com> Date: Fri Aug 7 20:48:08 2026 +0200 new file: .env.example new file: .gitignore new file: Dockerfile new file: README.md new file: app.py new file: app/__init__.py new file: app/game.py new file: app/models.py new file: app/routes.py new file: app/seed.py new file: data/movies.json new file: docker-compose.yml new file: requirements.txt new file: static/css/style.css new file: static/js/game.js new file: static/js/player.js new file: templates/index.html diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..544de23 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Filmdle Umgebungsvariablen (Coolify / docker-compose) +# PostgreSQL +DB_HOST=localhost +DB_PORT=5432 +DB_USER=filmdle +DB_PASSWORD=changeme +DB_DATABASE=filmdle + +# Flask +SECRET_KEY=change-me-to-a-long-random-string + +# Spiel +MAX_ATTEMPTS=10 +# Globale Uhrzeit (UTC) fuer den Taeglichen Reset / neue Tages-Filmwahl um 09:00 UTC +DAILY_RESET_UTC=9 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9424a3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +.venv/ +venv/ +.env +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bac0972 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# Basis-Image mit Python +FROM python:3.11-slim + +# Arbeitsverzeichnis erstellen +WORKDIR /app + +# Port für Gunicorn +EXPOSE 8000 + +# Kopiere die requirements-Datei und installiere die Abhängigkeiten +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Kopiere den gesamten Projektinhalt in das Arbeitsverzeichnis +COPY . . + +# Umgebungsvariablen von Coolify / docker-compose übernehmen (zur Laufzeit) +ENV DB_HOST=$DB_HOST +ENV DB_PORT=$DB_PORT +ENV DB_USER=$DB_USER +ENV DB_PASSWORD=$DB_PASSWORD +ENV DB_DATABASE=$DB_DATABASE +ENV SECRET_KEY=$SECRET_KEY +ENV MAX_ATTEMPTS=$MAX_ATTEMPTS +ENV DAILY_RESET_UTC=$DAILY_RESET_UTC + +# Nicht-root Benutzer aus Sicherheitsgründen +RUN useradd --create-home appuser && chown -R appuser:appuser /app +USER appuser + +# Startbefehl (Production: Gunicorn) +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "app:app"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..90dac63 --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# Filmdle – Film-Quiz + +Ein "Rate den Film anhand seiner Eigenschaften"-Quiz, inspiriert von Gamedle's *Guess*. +Du bekommst einen geheimen Zielfilm und tippst ueber eine Suchleiste (mit Autocomplete). +Jeder Tipp zeigt eine Zeile mit 9 Spalten, die farblich zeigen, wie nah du dran bist +(grün = passt, gelb = teilweise, rot = passt nicht). Bei Jahr und Budget zeigen die +Pfeile ▲/▼ an, ob dein Tipp zu früh oder zu spät liegt. + +## Modi +- **Täglich**: Derselbe Film für alle pro Tag (wird um 09:00 UTC gewechselt). +- **Unbegrenzt**: Zufälliger Film pro Runde, so oft du willst. + +Für beide Modi werden getrennte Sieg- und Niederlagen-Str eaks gespeichert +(pro Spielername, in PostgreSQL). + +## Technologie +- Flask + SQLAlchemy (PostgreSQL) +- Gunicorn im Docker-Container, lauffähig auf Coolify + +## Lokale Entwicklung + +Voraussetzung: Python 3.11+, Docker (für Postgres). + +```bash +# 1. Postgres starten +docker compose up -d db + +# 2. Python venv + Abhängigkeiten +python -m venv .venv +.venv\Scripts\activate # Windows +pip install -r requirements.txt + +# 3. Umgebungsvariablen (lokal) +copy .env.example .env # Werte anpassen (DB_USER/DB_PASSWORD/DB_DATABASE = filmdle) + +# 4. App starten (laden der .env wird automatisch via python-dotenv) +python app.py +``` + +Die App läuft dann auf http://localhost:8000 + +## Datenbank-Schema + +Die Tabellen werden beim ersten Start automatisch angelegt und mit den festen +Referenzlisten (Plattformen, Genres, Studios, Universen) sowie den Filmen aus +`data/movies.json` befüllt (idempotent). + +Neue Filme einfach als Einträge in `data/movies.json` ergänzen (alle 9 Attribute +inkl. Plattformen, FSK, Studio, Budget, Universum) und Werte müssen aus den +festen Listen in `app/seed.py` stammen. + +## Deployment auf Coolify + +1. Repo in Coolify einbinden. +2. Build-Pack: `Dockerfile` verwenden. +3. Eine PostgreSQL-Ressource anlegen und folgende Umgebungsvariablen setzen: + - `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASSWORD`, `DB_DATABASE` + - `SECRET_KEY` (langer Zufallswert) + - `MAX_ATTEMPTS` (optional, Standard 10) + - `DAILY_RESET_UTC` (optional, Standard 9 = 09:00 UTC) diff --git a/app.py b/app.py new file mode 100644 index 0000000..77e8c4f --- /dev/null +++ b/app.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8000, debug=True) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..28b6499 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,39 @@ +import os + +from flask import Flask +from flask_sqlalchemy import SQLAlchemy + +db = SQLAlchemy() + + +def create_app(): + app = Flask(__name__) + + # Konfiguration aus Umgebungsvariablen (Coolify) + app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-secret-key") + app.config["MAX_ATTEMPTS"] = int(os.environ.get("MAX_ATTEMPTS", "10")) + app.config["DAILY_RESET_UTC"] = int(os.environ.get("DAILY_RESET_UTC", "9")) + + db_host = os.environ.get("DB_HOST", "localhost") + db_port = os.environ.get("DB_PORT", "5432") + db_user = os.environ.get("DB_USER", "filmdle") + db_password = os.environ.get("DB_PASSWORD", "") + db_name = os.environ.get("DB_DATABASE", "filmdle") + + app.config["SQLALCHEMY_DATABASE_URI"] = ( + f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}" + ) + app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + + db.init_app(app) + + from . import routes # noqa: F401 + + with app.app_context(): + db.create_all() + # Seed (idempotent) beim Start, damit die App sofort funktioniert + from .seed import seed_all + + seed_all() + + return app diff --git a/app/game.py b/app/game.py new file mode 100644 index 0000000..ac149d8 --- /dev/null +++ b/app/game.py @@ -0,0 +1,202 @@ +import hashlib +import random +from datetime import date, datetime, time, timedelta + +from . import db +from .models import Movie, Player, Session, Universe + +# Statusklassen fuer die Zellen +GREEN = "green" +YELLOW = "yellow" +RED = "red" + + +def get_max_attempts(): + from flask import current_app + return current_app.config.get("MAX_ATTEMPTS", 10) + + +def _game_date(today=None): + """Ermittelt den 'Spieltag' basierend auf der globalen Reset-Uhrzeit (UTC).""" + from flask import current_app + reset_hour = current_app.config.get("DAILY_RESET_UTC", 9) + now = today or datetime.utcnow() + game_date = now.date() + # Vor der Resetzeit gilt noch der Vortag + if now.time() < time(reset_hour % 24, 0, 0): + game_date = game_date - timedelta(days=1) + return game_date + + +def daily_target(game_date=None): + """Bestimmt deterministisch den taeglichen Ziel-Film (fuer alle gleich).""" + game_date = game_date or _game_date() + movies = Movie.query.order_by(Movie.id).all() + if not movies: + return None + idx = int(hashlib.sha256(str(game_date).encode()).hexdigest(), 16) % len(movies) + return movies[idx] + + +def random_target(): + movies = Movie.query.order_by(Movie.id).all() + if not movies: + return None + return random.choice(movies) + + +def get_or_create_player(nickname): + player = Player.query.filter_by(nickname=nickname).first() + if player is None: + player = Player(nickname=nickname) + db.session.add(player) + db.session.commit() + return player + + +def _series(normalized_title): + """Erstellt eine Normalisierung fuer Titel-Vergleiche (nur Buchstaben, klein).""" + return "".join(ch for ch in normalized_title.lower() if ch.isalnum()) + + +def match_platforms(guess_movie, target_movie): + guess = set(p.name for p in guess_movie.platforms) + target = set(p.name for p in target_movie.platforms) + if guess == target: + return GREEN + # gelb: mindestens eine gemeinsame Platform + if guess & target: + return YELLOW + return RED + + +def match_genres(guess_movie, target_movie): + guess = set(g.name for g in guess_movie.genres) + target = set(g.name for g in target_movie.genres) + if guess == target: + return GREEN + if guess & target: + return YELLOW + return RED + + +def match_year(guess_year, target_year): + if guess_year == target_year: + return GREEN, "equal" + if guess_year < target_year: + return YELLOW, "up" # Ziel liegt hoeher (spaeter) + return YELLOW, "down" # Ziel liegt tiefer (frueher) + + +def match_fsk(guess_fsk, target_fsk): + if guess_fsk == target_fsk: + return GREEN + if guess_fsk is None or target_fsk is None: + return YELLOW if guess_fsk == target_fsk else RED + return RED + + +def match_director(guess_director, target_director): + if not guess_director or not target_director: + return GREEN if guess_director == target_director else RED + if _series(guess_director) == _series(target_director): + return GREEN + return RED + + +def match_studio(guess_studio, target_studio): + if (guess_studio or "").lower() == (target_studio or "").lower(): + return GREEN + return RED + + +def match_budget(guess_budget, target_budget): + if guess_budget is None or target_budget is None: + return RED if guess_budget != target_budget else GREEN + if guess_budget == target_budget: + return GREEN, "equal" + if guess_budget < target_budget: + return YELLOW, "up" + return YELLOW, "down" + + +def match_universe(guess_universe, target_universe): + # Fuer beide None => green (kein Universum) + if (guess_universe or "") == (target_universe or ""): + return GREEN + if not guess_universe or not target_universe: + return RED + return RED + + +def compare(guess_movie, target_movie): + """Vergleicht einen Tipp mit dem Ziel-Film und liefert die 9 Zellen.""" + ys, ydir = match_year(guess_movie.year, target_movie.year) + bs, bdir = match_budget(guess_movie.budget, target_movie.budget) + + return { + "title": guess_movie.title, + "platforms": {"status": match_platforms(guess_movie, target_movie), "value": sorted(p.name for p in guess_movie.platforms)}, + "genres": {"status": match_genres(guess_movie, target_movie), "value": sorted(g.name for g in guess_movie.genres)}, + "year": {"status": ys, "value": guess_movie.year, "direction": ydir, "diff": target_movie.year - guess_movie.year}, + "fsk": {"status": match_fsk(guess_movie.fsk, target_movie.fsk), "value": guess_movie.fsk}, + "studio": {"status": match_studio(guess_movie.studio.name if guess_movie.studio else None, target_movie.studio.name if target_movie.studio else None), "value": guess_movie.studio.name if guess_movie.studio else None}, + "director": {"status": match_director(guess_movie.director, target_movie.director), "value": guess_movie.director or None}, + "budget": {"status": bs, "value": guess_movie.budget, "direction": bdir, "diff": (target_movie.budget - guess_movie.budget) if guess_movie.budget is not None and target_movie.budget is not None else None}, + "universe": {"status": match_universe(guess_movie.universe.name if guess_movie.universe else None, target_movie.universe.name if target_movie.universe else None), "value": guess_movie.universe.name if guess_movie.universe else None}, + } + + +def is_correct(compared): + """Prueft ob alle Zellen gruen sind (== Ziel gefunden).""" + return all(cell["status"] == GREEN for cell in compared.values() if cell is not compared["title"]) + + +def finish_session(session, won): + """Markiert eine Runde als gewonnen/verloren und aktualisiert den Str eak.""" + session.status = "won" if won else "lost" + session.won = won + db.session.commit() + + +def _streak_for(player, mode, won): + """Berechnet den aktuellen Str eak (Sieg bzw. Niederlage) fuer Spieler+Modus.""" + sessions = ( + Session.query + .filter_by(player_id=player.id, mode=mode) + .order_by(Session.id.desc()) + .all() + ) + count = 0 + for s in sessions: + if s.won is None: + continue + if s.won == won: + count += 1 + else: + break + return count + + +def get_streaks(player_id): + """Liefert getrennte Sieg-/Niederlagen-Str eaks fuer Tages- und Unbegrenzt-Modus.""" + player = Player.query.get(player_id) + if player is None: + return None + return { + "daily": { + "win": _streak_for(player, "daily", True), + "loss": _streak_for(player, "daily", False), + }, + "unlimited": { + "win": _streak_for(player, "unlimited", True), + "loss": _streak_for(player, "unlimited", False), + }, + } + + +def existing_daily_session(player_id, game_date): + """Gibt eine vorhandene, abgeschlossene Tages-Runde zurueck (falls vorhanden).""" + return Session.query.filter_by( + player_id=player_id, mode="daily", date=game_date + ).order_by(Session.id.desc()).first() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..8102d63 --- /dev/null +++ b/app/models.py @@ -0,0 +1,85 @@ +from datetime import datetime + +from . import db + +# ---- Referenz-/Nachschlagetabellen (feste Liste) ---- + +movie_genres = db.Table( + "movie_genres", + db.Column("movie_id", db.Integer, db.ForeignKey("movies.id"), primary_key=True), + db.Column("genre_id", db.Integer, db.ForeignKey("genres.id"), primary_key=True), +) + +movie_platforms = db.Table( + "movie_platforms", + db.Column("movie_id", db.Integer, db.ForeignKey("movies.id"), primary_key=True), + db.Column("platform_id", db.Integer, db.ForeignKey("platforms.id"), primary_key=True), +) + + +class Genre(db.Model): + __tablename__ = "genres" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(60), unique=True, nullable=False) + + +class Platform(db.Model): + __tablename__ = "platforms" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(60), unique=True, nullable=False) + + +class Studio(db.Model): + __tablename__ = "studios" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(60), unique=True, nullable=False) + + +class Universe(db.Model): + __tablename__ = "universes" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(60), unique=True, nullable=False) + + +# ---- Haupttabelle ---- + +class Movie(db.Model): + __tablename__ = "movies" + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False, index=True) + year = db.Column(db.Integer, nullable=False) + fsk = db.Column(db.Integer, nullable=True) # 0, 6, 12, 16, 18 + studio_id = db.Column(db.Integer, db.ForeignKey("studios.id"), nullable=True) + director = db.Column(db.String(120), nullable=True) + budget = db.Column(db.Integer, nullable=True) # in USD + universe_id = db.Column(db.Integer, db.ForeignKey("universes.id"), nullable=True) + + studio = db.relationship("Studio") + universe = db.relationship("Universe") + genres = db.relationship("Genre", secondary=movie_genres, lazy="joined") + platforms = db.relationship("Platform", secondary=movie_platforms, lazy="joined") + + +# ---- Spieler & Runden ---- + +class Player(db.Model): + __tablename__ = "players" + id = db.Column(db.Integer, primary_key=True) + nickname = db.Column(db.String(60), unique=True, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Session(db.Model): + __tablename__ = "sessions" + id = db.Column(db.Integer, primary_key=True) + player_id = db.Column(db.Integer, db.ForeignKey("players.id"), nullable=False) + mode = db.Column(db.String(20), nullable=False) # 'daily' | 'unlimited' + target_movie_id = db.Column(db.Integer, db.ForeignKey("movies.id"), nullable=False) + status = db.Column(db.String(20), nullable=False, default="in_progress") # won|lost|in_progress + attempts_used = db.Column(db.Integer, nullable=False, default=0) + won = db.Column(db.Boolean, nullable=True) # True won / False lost / None in-progress + date = db.Column(db.Date, nullable=True) # nur fuer 'daily' + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + player = db.relationship("Player") + target = db.relationship("Movie") diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..0db082f --- /dev/null +++ b/app/routes.py @@ -0,0 +1,226 @@ +from flask import Blueprint, jsonify, render_template, request + +from . import db +from .game import ( + _game_date, compare, daily_target, existing_daily_session, finish_session, + get_max_attempts, get_or_create_player, get_streaks, is_correct, random_target, +) +from .models import Movie, Player, Session + +bp = Blueprint("main", __name__) + + +# ---------- Seiten ---------- + +@bp.route("/") +def index(): + return render_template("index.html", mode="daily") + + +@bp.route("/unlimited") +def unlimited(): + return render_template("index.html", mode="unlimited") + + +# ---------- API ---------- + +@bp.route("/api/player", methods=["POST"]) +def api_player(): + data = request.get_json(silent=True) or {} + nickname = (data.get("nickname") or "").strip() + if not nickname: + return jsonify({"error": "nickname required"}), 400 + player = get_or_create_player(nickname) + return jsonify({ + "player": {"id": player.id, "nickname": player.nickname}, + "streaks": get_streaks(player.id), + }) + + +@bp.route("/api/search") +def api_search(): + q = (request.args.get("q") or "").strip().lower() + if not q: + return jsonify([]) + movies = Movie.query.filter(Movie.title.ilike(f"%{q}%")).limit(10).all() + return jsonify([ + {"id": m.id, "title": m.title, "year": m.year} + for m in movies + ]) + + +@bp.route("/api/start", methods=["POST"]) +def api_start(): + data = request.get_json(silent=True) or {} + player_id = data.get("player_id") + mode = data.get("mode", "daily") + if not player_id: + return jsonify({"error": "player_id required"}), 400 + if Player.query.get(player_id) is None: + return jsonify({"error": "player not found"}), 404 + if mode not in ("daily", "unlimited"): + return jsonify({"error": "invalid mode"}), 400 + + max_attempts = get_max_attempts() + + if mode == "daily": + game_date = _game_date() + target = daily_target(game_date) + # Bereits heute gespielt? + existing = existing_daily_session(player_id, game_date) + if existing and existing.status != "in_progress": + return jsonify({ + "session_id": existing.id, + "mode": mode, + "max_attempts": max_attempts, + "status": existing.status, + "already_played": True, + "target": _movie_public(target), + "attempts_used": existing.attempts_used, + }) + # In-progress Rueckgabe, sonst neue Runde + if existing: + return jsonify({ + "session_id": existing.id, + "mode": mode, + "max_attempts": max_attempts, + "status": existing.status, + "already_played": False, + "attempts_used": existing.attempts_used, + }) + session = Session( + player_id=player_id, mode=mode, target_movie_id=target.id, + status="in_progress", attempts_used=0, date=game_date, + ) + db.session.add(session) + db.session.commit() + return jsonify({ + "session_id": session.id, + "mode": mode, + "max_attempts": max_attempts, + "status": "in_progress", + "already_played": False, + "attempts_used": 0, + }) + + # Unbegrenzt: neue Zufallsrunde + target = random_target() + if target is None: + return jsonify({"error": "no movies"}), 500 + session = Session( + player_id=player_id, mode="unlimited", target_movie_id=target.id, + status="in_progress", attempts_used=0, + ) + db.session.add(session) + db.session.commit() + return jsonify({ + "session_id": session.id, + "mode": mode, + "max_attempts": max_attempts, + "status": "in_progress", + "already_played": False, + "attempts_used": 0, + }) + + +@bp.route("/api/guess", methods=["POST"]) +def api_guess(): + data = request.get_json(silent=True) or {} + session_id = data.get("session_id") + movie_id = data.get("movie_id") + if not session_id or not movie_id: + return jsonify({"error": "session_id and movie_id required"}), 400 + + session = Session.query.get(session_id) + if session is None: + return jsonify({"error": "session not found"}), 404 + if session.status != "in_progress": + return jsonify({"error": "session already finished", "status": session.status}), 409 + + guess = Movie.query.get(movie_id) + if guess is None: + return jsonify({"error": "movie not found"}), 404 + + target = Movie.query.get(session.target_movie_id) + max_attempts = get_max_attempts() + + compared = compare(guess, target) + session.attempts_used += 1 + used = session.attempts_used + finished = False + status = "in_progress" + won = False + + if is_correct(compared): + finish_session(session, True) + finished = True + won = True + status = "won" + elif used >= max_attempts: + finish_session(session, False) + finished = True + won = False + status = "lost" + + response = { + "session_id": session.id, + "attempts_used": used, + "max_attempts": max_attempts, + "status": status, + "finished": finished, + "won": won, + "row": compared, + } + if finished: + response["target"] = _movie_public(target) + response["streaks"] = get_streaks(session.player_id) + return jsonify(response) + + +@bp.route("/api/streaks", methods=["POST"]) +def api_streaks(): + data = request.get_json(silent=True) or {} + player_id = data.get("player_id") + if not player_id: + return jsonify({"error": "player_id required"}), 400 + return jsonify(get_streaks(player_id) or {"error": "player not found"}) + + +@bp.route("/api/new-game", methods=["POST"]) +def api_new_game(): + """Startet (fuer den Unbegrenzt-Modus) eine neue Zufallsrunde.""" + data = request.get_json(silent=True) or {} + player_id = data.get("player_id") + if not player_id or Player.query.get(player_id) is None: + return jsonify({"error": "player not found"}), 404 + target = random_target() + if target is None: + return jsonify({"error": "no movies"}), 500 + session = Session( + player_id=player_id, mode="unlimited", target_movie_id=target.id, + status="in_progress", attempts_used=0, + ) + db.session.add(session) + db.session.commit() + return jsonify({ + "session_id": session.id, + "mode": "unlimited", + "max_attempts": get_max_attempts(), + "status": "in_progress", + "attempts_used": 0, + }) + + +def _movie_public(movie): + return { + "id": movie.id, + "title": movie.title, + "year": movie.year, + "fsk": movie.fsk, + "studio": movie.studio.name if movie.studio else None, + "director": movie.director, + "budget": movie.budget, + "universe": movie.universe.name if movie.universe else None, + "genres": [g.name for g in movie.genres], + "platforms": [p.name for p in movie.platforms], + } diff --git a/app/seed.py b/app/seed.py new file mode 100644 index 0000000..341cb31 --- /dev/null +++ b/app/seed.py @@ -0,0 +1,89 @@ +import json +import os + +from . import db +from .models import Genre, Movie, Platform, Studio, Universe + +# Feste Referenzlisten (die moeglichen Werte fuer Plattform/Genre/Studio/Universum) +PLATFORMS = [ + "Netflix", "Disney+", "Prime Video", "Max", "Apple TV+", "Paramount+", + "Blu-ray", "DVD", "4K UHD", "Cinema" +] + +GENRES = [ + "Action", "Abenteuer", "Animation", "Komödie", "Krimi", "Drama", "Fantasy", + "Horror", "Mystery", "Romantik", "Sci-Fi", "Thriller", "Western", "Musical", "Krieg", "Biografie" +] + +STUDIOS = [ + "Warner Bros", "Disney", "Universal", "Paramount", "Sony Pictures", + "20th Century", "MGM", "Lionsgate", "A24", "DreamWorks", "New Line", + "Orion", "Neon", "CJ Entertainment", "Show East", "Dimension", "Netflix" +] + +UNIVERSES = [ + "MCU", "Star Wars", "DC", "Wizarding World", "Fast & Furious", "Herr der Ringe", "Spider-Man" +] + + +def _get_or_create(model, name): + if not name: + return None + obj = model.query.filter_by(name=name).first() + if obj is None: + obj = model(name=name) + db.session.add(obj) + db.session.flush() + return obj + + +def seed_all(): + """Idempotent: legt Referenzlisten und Filme an, ohne bestehende zu duplizieren.""" + # Referenzlisten + for name in PLATFORMS: + _get_or_create(Platform, name) + for name in GENRES: + _get_or_create(Genre, name) + for name in STUDIOS: + _get_or_create(Studio, name) + for name in UNIVERSES: + _get_or_create(Universe, name) + db.session.commit() + + # Filme aus JSON laden + _seed_movies_from_json() + + +def _seed_movies_from_json(): + data_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "movies.json" + ) + if not os.path.exists(data_path): + return + + with open(data_path, "r", encoding="utf-8") as f: + movies = json.load(f) + + # Nur hinzufuegen, wenn noch kein Film in der DB ist (idempotent ueber den ganzen Datensatz) + if Movie.query.count() > 0: + return + + for entry in movies: + movie = Movie( + title=entry["title"], + year=entry["year"], + fsk=entry.get("fsk"), + director=entry.get("director"), + budget=entry.get("budget"), + studio=_get_or_create(Studio, entry.get("studio")), + universe=_get_or_create(Universe, entry.get("universe")), + ) + db.session.add(movie) + db.session.flush() + + for gname in entry.get("genres", []): + movie.genres.append(_get_or_create(Genre, gname)) + for pname in entry.get("platforms", []): + movie.platforms.append(_get_or_create(Platform, pname)) + + db.session.commit() diff --git a/data/movies.json b/data/movies.json new file mode 100644 index 0000000..716fd38 --- /dev/null +++ b/data/movies.json @@ -0,0 +1,1102 @@ +[ + { + "title": "Star Wars: Episode IV – Eine neue Hoffnung", + "year": 1977, + "fsk": 12, + "studio": "20th Century", + "director": "George Lucas", + "budget": 11000000, + "universe": "Star Wars", + "genres": ["Sci-Fi", "Abenteuer", "Action"], + "platforms": ["Disney+", "Blu-ray", "DVD"] + }, + { + "title": "Die Rückkehr der Jedi-Ritter", + "year": 1983, + "fsk": 12, + "studio": "20th Century", + "director": "Richard Marquand", + "budget": 32500000, + "universe": "Star Wars", + "genres": ["Sci-Fi", "Abenteuer", "Action"], + "platforms": ["Disney+", "Blu-ray", "DVD"] + }, + { + "title": "Das Imperium schlägt zurück", + "year": 1980, + "fsk": 12, + "studio": "20th Century", + "director": "Irvin Kershner", + "budget": 18000000, + "universe": "Star Wars", + "genres": ["Sci-Fi", "Abenteuer", "Action"], + "platforms": ["Disney+", "Blu-ray", "DVD"] + }, + { + "title": "Der Pate", + "year": 1972, + "fsk": 16, + "studio": "Paramount", + "director": "Francis Ford Coppola", + "budget": 6000000, + "universe": null, + "genres": ["Krimi", "Drama"], + "platforms": ["Paramount+", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Pulp Fiction", + "year": 1994, + "fsk": 16, + "studio": "MGM", + "director": "Quentin Tarantino", + "budget": 8000000, + "universe": null, + "genres": ["Krimi", "Thriller", "Drama"], + "platforms": ["Paramount+", "Blu-ray", "DVD"] + }, + { + "title": "The Dark Knight", + "year": 2008, + "fsk": 12, + "studio": "Warner Bros", + "director": "Christopher Nolan", + "budget": 185000000, + "universe": "DC", + "genres": ["Action", "Krimi", "Thriller"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Batman Begins", + "year": 2005, + "fsk": 12, + "studio": "Warner Bros", + "director": "Christopher Nolan", + "budget": 150000000, + "universe": "DC", + "genres": ["Action", "Krimi"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "The Dark Knight Rises", + "year": 2012, + "fsk": 12, + "studio": "Warner Bros", + "director": "Christopher Nolan", + "budget": 230000000, + "universe": "DC", + "genres": ["Action", "Thriller"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Inception", + "year": 2010, + "fsk": 12, + "studio": "Warner Bros", + "director": "Christopher Nolan", + "budget": 160000000, + "universe": null, + "genres": ["Sci-Fi", "Action", "Thriller"], + "platforms": ["Max", "Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "Interstellar", + "year": 2014, + "fsk": 12, + "studio": "Paramount", + "director": "Christopher Nolan", + "budget": 165000000, + "universe": null, + "genres": ["Sci-Fi", "Drama", "Abenteuer"], + "platforms": ["Paramount+", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Avengers: Endgame", + "year": 2019, + "fsk": 12, + "studio": "Disney", + "director": "Anthony Russo, Joe Russo", + "budget": 356000000, + "universe": "MCU", + "genres": ["Action", "Sci-Fi", "Abenteuer"], + "platforms": ["Disney+", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Avengers: Infinity War", + "year": 2018, + "fsk": 12, + "studio": "Disney", + "director": "Anthony Russo, Joe Russo", + "budget": 300000000, + "universe": "MCU", + "genres": ["Action", "Sci-Fi", "Abenteuer"], + "platforms": ["Disney+", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Iron Man", + "year": 2008, + "fsk": 12, + "studio": "Paramount", + "director": "Jon Favreau", + "budget": 140000000, + "universe": "MCU", + "genres": ["Action", "Sci-Fi", "Abenteuer"], + "platforms": ["Disney+", "Blu-ray", "DVD"] + }, + { + "title": "Spider-Man: Homecoming", + "year": 2017, + "fsk": 12, + "studio": "Sony Pictures", + "director": "Jon Watts", + "budget": 175000000, + "universe": "Spider-Man", + "genres": ["Action", "Abenteuer", "Sci-Fi"], + "platforms": ["Disney+", "Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Spider-Man: No Way Home", + "year": 2021, + "fsk": 12, + "studio": "Sony Pictures", + "director": "Jon Watts", + "budget": 200000000, + "universe": "Spider-Man", + "genres": ["Action", "Abenteuer", "Sci-Fi"], + "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Der Herr der Ringe: Die Gefährten", + "year": 2001, + "fsk": 12, + "studio": "New Line", + "director": "Peter Jackson", + "budget": 93000000, + "universe": "Herr der Ringe", + "genres": ["Fantasy", "Abenteuer"], + "platforms": ["Max", "Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "Der Herr der Ringe: Die zwei Türme", + "year": 2002, + "fsk": 12, + "studio": "New Line", + "director": "Peter Jackson", + "budget": 94000000, + "universe": "Herr der Ringe", + "genres": ["Fantasy", "Abenteuer"], + "platforms": ["Max", "Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "Der Herr der Ringe: Die Rückkehr des Königs", + "year": 2003, + "fsk": 12, + "studio": "New Line", + "director": "Peter Jackson", + "budget": 94000000, + "universe": "Herr der Ringe", + "genres": ["Fantasy", "Abenteuer"], + "platforms": ["Max", "Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "Harry Potter und der Stein der Weisen", + "year": 2001, + "fsk": 6, + "studio": "Warner Bros", + "director": "Chris Columbus", + "budget": 125000000, + "universe": "Wizarding World", + "genres": ["Fantasy", "Abenteuer"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "Harry Potter und die Kammer des Schreckens", + "year": 2002, + "fsk": 6, + "studio": "Warner Bros", + "director": "Chris Columbus", + "budget": 100000000, + "universe": "Wizarding World", + "genres": ["Fantasy", "Abenteuer"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "Phantastische Tierwesen und wo sie zu finden sind", + "year": 2016, + "fsk": 6, + "studio": "Warner Bros", + "director": "David Yates", + "budget": 180000000, + "universe": "Wizarding World", + "genres": ["Fantasy", "Abenteuer"], + "platforms": ["Max", "Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "Jumanji: Willkommen im Dschungel", + "year": 2017, + "fsk": 6, + "studio": "Sony Pictures", + "director": "Jake Kasdan", + "budget": 90000000, + "universe": null, + "genres": ["Abenteuer", "Komödie", "Action"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Jumanji: The Next Level", + "year": 2019, + "fsk": 6, + "studio": "Sony Pictures", + "director": "Jake Kasdan", + "budget": 125000000, + "universe": null, + "genres": ["Abenteuer", "Komödie", "Action"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Titanic", + "year": 1997, + "fsk": 12, + "studio": "Paramount", + "director": "James Cameron", + "budget": 200000000, + "universe": null, + "genres": ["Drama", "Romantik"], + "platforms": ["Disney+", "Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "Avatar – Aufbruch nach Pandora", + "year": 2009, + "fsk": 12, + "studio": "20th Century", + "director": "James Cameron", + "budget": 237000000, + "universe": null, + "genres": ["Sci-Fi", "Abenteuer", "Action"], + "platforms": ["Disney+", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Avatar: The Way of Water", + "year": 2022, + "fsk": 12, + "studio": "20th Century", + "director": "James Cameron", + "budget": 350000000, + "universe": null, + "genres": ["Sci-Fi", "Abenteuer", "Action"], + "platforms": ["Disney+", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Fast & Furious 5", + "year": 2011, + "fsk": 12, + "studio": "Universal", + "director": "Justin Lin", + "budget": 125000000, + "universe": "Fast & Furious", + "genres": ["Action", "Krimi", "Thriller"], + "platforms": ["Netflix", "Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "The Fast and the Furious: Tokyo Drift", + "year": 2006, + "fsk": 12, + "studio": "Universal", + "director": "Justin Lin", + "budget": 85000000, + "universe": "Fast & Furious", + "genres": ["Action", "Krimi"], + "platforms": ["Netflix", "Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "No Time to Die", + "year": 2021, + "fsk": 12, + "studio": "MGM", + "director": "Cary Joji Fukunaga", + "budget": 250000000, + "universe": null, + "genres": ["Action", "Thriller", "Abenteuer"], + "platforms": ["Prime Video", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "James Bond 007 – Skyfall", + "year": 2012, + "fsk": 12, + "studio": "Sony Pictures", + "director": "Sam Mendes", + "budget": 200000000, + "universe": null, + "genres": ["Action", "Thriller", "Abenteuer"], + "platforms": ["Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "Fight Club", + "year": 1999, + "fsk": 16, + "studio": "20th Century", + "director": "David Fincher", + "budget": 63000000, + "universe": null, + "genres": ["Drama", "Thriller"], + "platforms": ["Prime Video", "Netflix", "Blu-ray", "DVD"] + }, + { + "title": "The Matrix", + "year": 1999, + "fsk": 16, + "studio": "Warner Bros", + "director": "Lana Wachowski, Lilly Wachowski", + "budget": 63000000, + "universe": null, + "genres": ["Sci-Fi", "Action"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Requiem for a Dream", + "year": 2000, + "fsk": 18, + "studio": "Lionsgate", + "director": "Darren Aronofsky", + "budget": 4500000, + "universe": null, + "genres": ["Drama"], + "platforms": ["Apple TV+", "Blu-ray", "DVD"] + }, + { + "title": "Schindlers Liste", + "year": 1993, + "fsk": 12, + "studio": "Universal", + "director": "Steven Spielberg", + "budget": 22000000, + "universe": null, + "genres": ["Drama", "Krieg", "Biografie"], + "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Jurassic Park", + "year": 1993, + "fsk": 12, + "studio": "Universal", + "director": "Steven Spielberg", + "budget": 63000000, + "universe": null, + "genres": ["Sci-Fi", "Abenteuer", "Thriller"], + "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "E.T. – Der Außerirdische", + "year": 1982, + "fsk": 6, + "studio": "Universal", + "director": "Steven Spielberg", + "budget": 10500000, + "universe": null, + "genres": ["Sci-Fi", "Drama", "Abenteuer"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Forrest Gump", + "year": 1994, + "fsk": 6, + "studio": "Paramount", + "director": "Robert Zemeckis", + "budget": 55000000, + "universe": null, + "genres": ["Drama", "Romantik", "Komödie"], + "platforms": ["Paramount+", "Blu-ray", "DVD"] + }, + { + "title": "Zurück in die Zukunft", + "year": 1985, + "fsk": 6, + "studio": "Universal", + "director": "Robert Zemeckis", + "budget": 19000000, + "universe": null, + "genres": ["Sci-Fi", "Abenteuer", "Komödie"], + "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Gladiator", + "year": 2000, + "fsk": 16, + "studio": "Universal", + "director": "Ridley Scott", + "budget": 103000000, + "universe": null, + "genres": ["Action", "Drama", "Abenteuer"], + "platforms": ["Paramount+", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Alien", + "year": 1979, + "fsk": 16, + "studio": "20th Century", + "director": "Ridley Scott", + "budget": 11000000, + "universe": null, + "genres": ["Horror", "Sci-Fi", "Thriller"], + "platforms": ["Disney+", "Blu-ray", "DVD"] + }, + { + "title": "Blade Runner", + "year": 1982, + "fsk": 16, + "studio": "Warner Bros", + "director": "Ridley Scott", + "budget": 28000000, + "universe": null, + "genres": ["Sci-Fi", "Thriller", "Mystery"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Blade Runner 2049", + "year": 2017, + "fsk": 16, + "studio": "Warner Bros", + "director": "Denis Villeneuve", + "budget": 150000000, + "universe": null, + "genres": ["Sci-Fi", "Thriller", "Mystery"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Dune", + "year": 2021, + "fsk": 12, + "studio": "Warner Bros", + "director": "Denis Villeneuve", + "budget": 165000000, + "universe": null, + "genres": ["Sci-Fi", "Abenteuer", "Drama"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Die Verurteilten", + "year": 1994, + "fsk": 12, + "studio": "Warner Bros", + "director": "Frank Darabont", + "budget": 25000000, + "universe": null, + "genres": ["Drama", "Krimi"], + "platforms": ["Max", "Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Der Pianist", + "year": 2002, + "fsk": 12, + "studio": "Universal", + "director": "Roman Polanski", + "budget": 35000000, + "universe": null, + "genres": ["Drama", "Krieg"], + "platforms": ["Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "Die Passion Christi", + "year": 2004, + "fsk": 16, + "studio": "New Line", + "director": "Mel Gibson", + "budget": 30000000, + "universe": null, + "genres": ["Drama"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "V wie Vendetta", + "year": 2005, + "fsk": 16, + "studio": "Warner Bros", + "director": "James McTeigue", + "budget": 54000000, + "universe": null, + "genres": ["Action", "Thriller", "Drama"], + "platforms": ["Max", "Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "Sin City", + "year": 2005, + "fsk": 18, + "studio": "Dimension", + "director": "Frank Miller, Robert Rodriguez", + "budget": 40000000, + "universe": null, + "genres": ["Krimi", "Thriller", "Action"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "Kill Bill: Volume 1", + "year": 2003, + "fsk": 16, + "studio": "MGM", + "director": "Quentin Tarantino", + "budget": 30000000, + "universe": null, + "genres": ["Action", "Thriller", "Krimi"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "Reservoir Dogs", + "year": 1992, + "fsk": 16, + "studio": "MGM", + "director": "Quentin Tarantino", + "budget": 1200000, + "universe": null, + "genres": ["Krimi", "Thriller"], + "platforms": ["Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "Inglourious Basterds", + "year": 2009, + "fsk": 16, + "studio": "Universal", + "director": "Quentin Tarantino", + "budget": 70000000, + "universe": null, + "genres": ["Krieg", "Action", "Drama"], + "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Django Unchained", + "year": 2012, + "fsk": 16, + "studio": "Sony Pictures", + "director": "Quentin Tarantino", + "budget": 100000000, + "universe": null, + "genres": ["Western", "Drama"], + "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "The Wolf of Wall Street", + "year": 2013, + "fsk": 16, + "studio": "Paramount", + "director": "Martin Scorsese", + "budget": 100000000, + "universe": null, + "genres": ["Drama", "Komödie", "Krimi"], + "platforms": ["Paramount+", "Blu-ray", "DVD"] + }, + { + "title": "Goodfellas", + "year": 1990, + "fsk": 16, + "studio": "Warner Bros", + "director": "Martin Scorsese", + "budget": 25000000, + "universe": null, + "genres": ["Krimi", "Drama", "Thriller"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "The Departed – Unter Feinden", + "year": 2006, + "fsk": 16, + "studio": "Warner Bros", + "director": "Martin Scorsese", + "budget": 90000000, + "universe": null, + "genres": ["Krimi", "Thriller", "Drama"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "Shutter Island", + "year": 2010, + "fsk": 16, + "studio": "Paramount", + "director": "Martin Scorsese", + "budget": 80000000, + "universe": null, + "genres": ["Mystery", "Thriller", "Drama"], + "platforms": ["Paramount+", "Blu-ray", "DVD"] + }, + { + "title": "Die Ritter der Kokosnuss", + "year": 1975, + "fsk": 6, + "studio": "Warner Bros", + "director": "Terry Gilliam, Terry Jones", + "budget": 400000, + "universe": null, + "genres": ["Komödie", "Abenteuer"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Brazil", + "year": 1985, + "fsk": 16, + "studio": "Universal", + "director": "Terry Gilliam", + "budget": 15000000, + "universe": null, + "genres": ["Sci-Fi", "Drama", "Komödie"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "12 Monkeys", + "year": 1995, + "fsk": 12, + "studio": "Universal", + "director": "Terry Gilliam", + "budget": 29000000, + "universe": null, + "genres": ["Sci-Fi", "Thriller", "Mystery"], + "platforms": ["Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "Captain Fantastic", + "year": 2016, + "fsk": 12, + "studio": "Universal", + "director": "Matt Ross", + "budget": 5000000, + "universe": null, + "genres": ["Drama", "Komödie", "Abenteuer"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "Marriage Story", + "year": 2019, + "fsk": 6, + "studio": "Netflix", + "director": "Noah Baumbach", + "budget": 18000000, + "universe": null, + "genres": ["Drama", "Romantik"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Lalaland", + "year": 2016, + "fsk": 6, + "studio": "Lionsgate", + "director": "Damien Chazelle", + "budget": 30000000, + "universe": null, + "genres": ["Musical", "Romantik", "Drama"], + "platforms": ["Blu-ray", "DVD", "Netflix"] + }, + { + "title": "Whiplash", + "year": 2014, + "fsk": 6, + "studio": "Sony Pictures", + "director": "Damien Chazelle", + "budget": 3300000, + "universe": null, + "genres": ["Drama", "Musical"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "The Silence of the Lambs", + "year": 1991, + "fsk": 16, + "studio": "Orion", + "director": "Jonathan Demme", + "budget": 19000000, + "universe": null, + "genres": ["Horror", "Thriller", "Krimi", "Mystery"], + "platforms": ["Prime Video", "Blu-ray", "DVD"] + }, + { + "title": "Se7en", + "year": 1995, + "fsk": 16, + "studio": "New Line", + "director": "David Fincher", + "budget": 33000000, + "universe": null, + "genres": ["Krimi", "Thriller", "Mystery"], + "platforms": ["Max", "Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Zodiac", + "year": 2007, + "fsk": 16, + "studio": "Paramount", + "director": "David Fincher", + "budget": 85000000, + "universe": null, + "genres": ["Krimi", "Thriller", "Mystery"], + "platforms": ["Paramount+", "Blu-ray", "DVD"] + }, + { + "title": "The Girl with the Dragon Tattoo", + "year": 2011, + "fsk": 16, + "studio": "Sony Pictures", + "director": "David Fincher", + "budget": 90000000, + "universe": null, + "genres": ["Krimi", "Thriller", "Mystery"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Mad Max: Fury Road", + "year": 2015, + "fsk": 16, + "studio": "Warner Bros", + "director": "George Miller", + "budget": 150000000, + "universe": null, + "genres": ["Action", "Sci-Fi", "Abenteuer"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Children of Men", + "year": 2006, + "fsk": 16, + "studio": "Universal", + "director": "Alfonso Cuarón", + "budget": 76000000, + "universe": null, + "genres": ["Sci-Fi", "Thriller", "Drama"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Gravity", + "year": 2013, + "fsk": 12, + "studio": "Warner Bros", + "director": "Alfonso Cuarón", + "budget": 100000000, + "universe": null, + "genres": ["Sci-Fi", "Thriller", "Drama"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Moonlight", + "year": 2016, + "fsk": 6, + "studio": "A24", + "director": "Barry Jenkins", + "budget": 4000000, + "universe": null, + "genres": ["Drama"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Hereditary", + "year": 2018, + "fsk": 16, + "studio": "A24", + "director": "Ari Aster", + "budget": 10000000, + "universe": null, + "genres": ["Horror", "Mystery", "Drama"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "Midsommar", + "year": 2019, + "fsk": 16, + "studio": "A24", + "director": "Ari Aster", + "budget": 9000000, + "universe": null, + "genres": ["Horror", "Mystery", "Thriller"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "Parasite", + "year": 2019, + "fsk": 16, + "studio": "Neon", + "director": "Bong Joon-ho", + "budget": 11000000, + "universe": null, + "genres": ["Thriller", "Drama", "Komödie"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "Oldboy", + "year": 2003, + "fsk": 18, + "studio": "Show East", + "director": "Park Chan-wook", + "budget": 3000000, + "universe": null, + "genres": ["Thriller", "Action", "Mystery"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Snowpiercer", + "year": 2013, + "fsk": 16, + "studio": "CJ Entertainment", + "director": "Bong Joon-ho", + "budget": 42000000, + "universe": null, + "genres": ["Sci-Fi", "Action", "Thriller"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Der König der Löwen", + "year": 1994, + "fsk": 0, + "studio": "Disney", + "director": "Roger Allers, Rob Minkoff", + "budget": 45000000, + "universe": null, + "genres": ["Animation", "Abenteuer", "Drama", "Musical"], + "platforms": ["Disney+", "Blu-ray", "DVD"] + }, + { + "title": "Toy Story", + "year": 1995, + "fsk": 0, + "studio": "Disney", + "director": "John Lasseter", + "budget": 30000000, + "universe": null, + "genres": ["Animation", "Abenteuer", "Komödie"], + "platforms": ["Disney+", "Blu-ray", "DVD"] + }, + { + "title": "Findet Nemo", + "year": 2003, + "fsk": 0, + "studio": "Disney", + "director": "Andrew Stanton", + "budget": 94000000, + "universe": null, + "genres": ["Animation", "Abenteuer", "Komödie"], + "platforms": ["Disney+", "Blu-ray", "DVD"] + }, + { + "title": "Shrek", + "year": 2001, + "fsk": 0, + "studio": "DreamWorks", + "director": "Andrew Adamson, Vicky Jenson", + "budget": 60000000, + "universe": null, + "genres": ["Animation", "Komödie", "Abenteuer", "Fantasy"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Shrek 2", + "year": 2004, + "fsk": 0, + "studio": "DreamWorks", + "director": "Andrew Adamson, Kelly Asbury, Conrad Vernon", + "budget": 150000000, + "universe": null, + "genres": ["Animation", "Komödie", "Abenteuer", "Fantasy"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Wie der Wind sich hebt", + "year": 2013, + "fsk": 6, + "studio": "Disney", + "director": "Hayao Miyazaki", + "budget": 30000000, + "universe": null, + "genres": ["Animation", "Drama", "Biografie"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Chihiros Reise ins Zauberland", + "year": 2001, + "fsk": 6, + "studio": "Disney", + "director": "Hayao Miyazaki", + "budget": 19000000, + "universe": null, + "genres": ["Animation", "Abenteuer", "Fantasy", "Drama"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Porco Rosso – Roter Flieger", + "year": 1992, + "fsk": 6, + "studio": "Disney", + "director": "Hayao Miyazaki", + "budget": 9000000, + "universe": null, + "genres": ["Animation", "Abenteuer", "Action"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Das wandelnde Schloss", + "year": 2004, + "fsk": 6, + "studio": "Disney", + "director": "Hayao Miyazaki", + "budget": 24000000, + "universe": null, + "genres": ["Animation", "Fantasy", "Abenteuer"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Ich – Einfach unverbesserlich", + "year": 2010, + "fsk": 0, + "studio": "Universal", + "director": "Pierre Coffin, Chris Renaud", + "budget": 69000000, + "universe": null, + "genres": ["Animation", "Komödie", "Abenteuer"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "Anomalisa", + "year": 2015, + "fsk": 12, + "studio": "Paramount", + "director": "Charlie Kaufman, Duke Johnson", + "budget": 8000000, + "universe": null, + "genres": ["Animation", "Drama", "Romantik"], + "platforms": ["Netflix", "Blu-ray", "DVD"] + }, + { + "title": "The Grand Budapest Hotel", + "year": 2014, + "fsk": 6, + "studio": "20th Century", + "director": "Wes Anderson", + "budget": 25000000, + "universe": null, + "genres": ["Komödie", "Abenteuer", "Drama"], + "platforms": ["Disney+", "Blu-ray", "DVD"] + }, + { + "title": "Joker", + "year": 2019, + "fsk": 16, + "studio": "Warner Bros", + "director": "Todd Phillips", + "budget": 55000000, + "universe": "DC", + "genres": ["Drama", "Thriller", "Krimi"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "The Batman", + "year": 2022, + "fsk": 16, + "studio": "Warner Bros", + "director": "Matt Reeves", + "budget": 200000000, + "universe": "DC", + "genres": ["Action", "Krimi", "Mystery"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Wonder Woman", + "year": 2017, + "fsk": 12, + "studio": "Warner Bros", + "director": "Patty Jenkins", + "budget": 149000000, + "universe": "DC", + "genres": ["Action", "Abenteuer", "Fantasy"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "The Godfather Part II", + "year": 1974, + "fsk": 16, + "studio": "Paramount", + "director": "Francis Ford Coppola", + "budget": 13000000, + "universe": null, + "genres": ["Krimi", "Drama"], + "platforms": ["Paramount+", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Taxi Driver", + "year": 1976, + "fsk": 16, + "studio": "Sony Pictures", + "director": "Martin Scorsese", + "budget": 1300000, + "universe": null, + "genres": ["Drama", "Thriller", "Krimi"], + "platforms": ["Netflix", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Barry Lyndon", + "year": 1975, + "fsk": 16, + "studio": "Warner Bros", + "director": "Stanley Kubrick", + "budget": 11000000, + "universe": null, + "genres": ["Drama", "Krieg"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "A Clockwork Orange", + "year": 1971, + "fsk": 18, + "studio": "Warner Bros", + "director": "Stanley Kubrick", + "budget": 2200000, + "universe": null, + "genres": ["Sci-Fi", "Krimi", "Thriller"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "Full Metal Jacket", + "year": 1987, + "fsk": 16, + "studio": "Warner Bros", + "director": "Stanley Kubrick", + "budget": 17000000, + "universe": null, + "genres": ["Krieg", "Drama"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "2001: Odyssee im Weltraum", + "year": 1968, + "fsk": 6, + "studio": "MGM", + "director": "Stanley Kubrick", + "budget": 10500000, + "universe": null, + "genres": ["Sci-Fi", "Mystery", "Abenteuer"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Eyes Wide Shut", + "year": 1999, + "fsk": 16, + "studio": "Warner Bros", + "director": "Stanley Kubrick", + "budget": 65000000, + "universe": null, + "genres": ["Mystery", "Thriller", "Drama"], + "platforms": ["Max", "Blu-ray", "DVD"] + }, + { + "title": "The Shining", + "year": 1980, + "fsk": 16, + "studio": "Warner Bros", + "director": "Stanley Kubrick", + "budget": 19000000, + "universe": null, + "genres": ["Horror", "Mystery", "Thriller"], + "platforms": ["Max", "Blu-ray", "DVD", "4K UHD"] + }, + { + "title": "Saving Private Ryan", + "year": 1998, + "fsk": 16, + "studio": "DreamWorks", + "director": "Steven Spielberg", + "budget": 70000000, + "universe": null, + "genres": ["Krieg", "Drama", "Action"], + "platforms": ["Paramount+", "Blu-ray", "DVD", "4K UHD"] + } +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5aaf14c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +# Lokale Entwicklungsumgebung fuer Filmdle +# Postgres fuer lokale Tests. Start: docker compose up -d db +services: + db: + image: postgres:16-alpine + container_name: filmdle-db + environment: + POSTGRES_USER: filmdle + POSTGRES_PASSWORD: filmdle + POSTGRES_DB: filmdle + ports: + - "5432:5432" + volumes: + - db_data:/var/lib/postgresql/data + +volumes: + db_data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..154fb6b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.0.3 +Flask-SQLAlchemy==3.1.1 +gunicorn==23.0.0 +psycopg2-binary==2.9.9 +python-dotenv==1.0.1 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..e707c46 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,358 @@ +:root { + --bg: #0b141a; + --bg-2: #101c26; + --panel: #16222e; + --panel-2: #1b2c39; + --border: #2a475e; + --border-soft: #22384a; + --text: #d7e2ea; + --muted: #8299ab; + --accent: #66c0f4; + --green: #2e9e4f; + --yellow: #c9a227; + --red: #b3362f; + --radius: 10px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: + radial-gradient(1200px 500px at 50% -10%, #1e3d52 0%, var(--bg) 55%), + linear-gradient(180deg, #0b141a 0%, #060c12 100%); + background-attachment: fixed; + color: var(--text); + font-family: "Segoe UI", Roboto, system-ui, -apple-system, sans-serif; + min-height: 100vh; +} + +header { + display: flex; + align-items: center; + gap: 24px; + padding: 14px 32px; + border-bottom: 1px solid var(--border); + background: linear-gradient(180deg, rgba(20,40,55,0.95) 0%, rgba(11,20,26,0.95) 100%); + position: sticky; + top: 0; + z-index: 10; + backdrop-filter: blur(6px); +} + +.logo { + font-size: 24px; + font-weight: 800; + letter-spacing: 2px; + color: #fff; + text-transform: uppercase; + background: linear-gradient(90deg, var(--accent), #8ab4ff); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +nav { + display: flex; + gap: 6px; + flex: 1; +} + +nav a { + color: var(--muted); + text-decoration: none; + padding: 8px 18px; + border-radius: 8px; + border: 1px solid transparent; + font-weight: 600; + font-size: 14px; + transition: all .15s; +} + +nav a:hover { color: var(--text); background: var(--panel-2); } +nav a.active { + color: #fff; + background: rgba(102, 192, 244, 0.12); + border-color: rgba(102, 192, 244, 0.4); + box-shadow: 0 0 0 1px rgba(102,192,244,0.1) inset; +} + +.player-box { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + font-size: 14px; +} +#player-name { font-weight: 700; } +#streak-label { color: var(--muted); font-size: 12px; } + +main { + max-width: 1280px; + margin: 0 auto; + padding: 24px 32px 64px; +} + +#game-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} +#mode-title { + font-size: 20px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--accent); +} +#attempts-info { color: var(--muted); font-size: 14px; font-variant-numeric: tabular-nums; } + +/* Suche */ +#search-wrapper { position: relative; margin-bottom: 24px; } + +#search-input { + width: 100%; + padding: 14px 18px; + font-size: 16px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--text); + outline: none; + transition: border-color .15s, box-shadow .15s; +} +#search-input::placeholder { color: var(--muted); } +#search-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(102,192,244,0.18); +} + +.autocomplete { + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--panel-2); + border: 1px solid var(--border); + border-top: none; + border-radius: 0 0 var(--radius) var(--radius); + max-height: 340px; + overflow-y: auto; + z-index: 20; + display: none; +} +.autocomplete.open { display: block; } +.autocomplete-item { + padding: 10px 18px; + cursor: pointer; + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; + border-left: 3px solid transparent; +} +.autocomplete-item:hover, .autocomplete-item.selected { + background: rgba(102,192,244,0.1); + border-left-color: var(--accent); +} +.autocomplete-item .year { color: var(--muted); font-size: 13px; } + +/* Brett */ +#board-wrapper { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); +} + +table#board { + width: 100%; + border-collapse: collapse; + min-width: 1000px; +} + +#board th { + padding: 12px 12px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--muted); + background: var(--panel); + border-bottom: 1px solid var(--border); + text-align: left; + white-space: nowrap; + font-weight: 600; +} + +#board td { + padding: 10px 12px; + border-bottom: 1px solid var(--border-soft); + background: var(--panel); + min-width: 96px; + font-size: 13px; + vertical-align: middle; + word-break: break-word; + line-height: 1.5; +} + +#board tr:last-child td { border-bottom: none; } + +#board td.title-cell { + background: linear-gradient(180deg, var(--panel-2), var(--panel)); + font-weight: 700; + min-width: 170px; + color: #fff; + border-right: 1px solid var(--border-soft); +} + +/* Status-Zellen */ +.cell { border-radius: 6px; } +.cell.green { background: linear-gradient(180deg, #34b25a, var(--green)); color: #fff; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12); } +.cell.yellow { background: linear-gradient(180deg, #dfb43a, var(--yellow)); color: #17190d; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.2); } +.cell.red { background: linear-gradient(180deg, #cf453c, var(--red)); color: #fff; box-shadow: inset 0 0 0 1px rgba(255,255,255,0.12); } + +.cell.green .dir-pill, .cell.red .dir-pill { background: rgba(0,0,0,0.25); color: #fff; } +.cell.yellow .dir-pill { background: rgba(0,0,0,0.18); color: #17190d; } +.cell.green .badge, .cell.red .badge { background: rgba(255,255,255,0.18); border-color: rgba(255,255,255,0.25); } +.cell.yellow .badge { background: rgba(255,255,255,0.35); border-color: rgba(0,0,0,0.15); } + +/* Badges (Icon + Label) */ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + margin: 1px 3px 1px 0; + border-radius: 5px; + font-size: 12px; + font-weight: 600; + background: rgba(255,255,255,0.08); + border: 1px solid var(--border-soft); + white-space: nowrap; +} +.badge .bicon { font-size: 13px; line-height: 1; } + +/* Zahlen (Jahr/Budget) */ +.num { font-weight: 700; font-size: 14px; margin-right: 6px; } + +/* Pfeil-Kapsel mit Zahl */ +.dir-pill { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 7px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; + background: rgba(0,0,0,0.25); + color: #fff; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.dir-pill.up { background: #216e37; color: #b8f5c6; } +.dir-pill.down { background: #8f2f29; color: #ffd0ca; } + +/* FSK Badge */ +.fsk { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 34px; + height: 22px; + padding: 0 6px; + border-radius: 5px; + font-weight: 800; + font-size: 13px; + background: rgba(0,0,0,0.35); + color: #fff; + border: 1px solid rgba(255,255,255,0.25); +} +.fsk-0 { background: #2e7d32; } +.fsk-6 { background: #2f6fbe; } +.fsk-12 { background: #f9a825; color: #201a03; } +.fsk-16 { background: #ef6c00; } +.fsk-18 { background: #b71c1c; } + +.director { font-weight: 600; } +.none { color: rgba(255,255,255,0.55); } + +/* Ergebnis */ +#result-box { + margin-top: 24px; + padding: 22px 26px; + border-radius: var(--radius); + background: var(--panel-2); + border: 1px solid var(--border); + font-size: 16px; +} +#result-box.won { border-color: var(--green); box-shadow: 0 0 0 1px var(--green) inset; } +#result-box.lost { border-color: var(--red); box-shadow: 0 0 0 1px var(--red) inset; } +#result-box .target-title { font-size: 20px; font-weight: 700; margin: 6px 0; color: #fff; } + +#result-box button { + margin-top: 14px; + padding: 11px 22px; + border: none; + border-radius: 8px; + background: linear-gradient(180deg, #7bd0f8, var(--accent)); + color: #062030; + font-weight: 700; + font-size: 15px; + cursor: pointer; + box-shadow: 0 2px 8px rgba(102,192,244,0.3); + transition: filter .15s; +} +#result-box button:hover { filter: brightness(1.08); } + +.hidden { display: none !important; } + +/* Modal */ +.modal { + position: fixed; + inset: 0; + background: rgba(4, 10, 15, 0.78); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + backdrop-filter: blur(4px); +} +.modal-content { + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 14px; + padding: 30px; + max-width: 420px; + width: 90%; + text-align: center; + box-shadow: 0 20px 50px rgba(0,0,0,0.5); +} +.modal-content h2 { margin-bottom: 8px; color: #fff; } +.modal-content p { color: var(--muted); margin-bottom: 18px; line-height: 1.5; } +.modal-content input { + width: 100%; + padding: 13px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg-2); + color: var(--text); + font-size: 15px; + margin-bottom: 14px; + outline: none; + transition: border-color .15s, box-shadow .15s; +} +.modal-content input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(102,192,244,0.18); } +.modal-content button { + width: 100%; + padding: 13px; + border: none; + border-radius: 8px; + background: linear-gradient(180deg, #7bd0f8, var(--accent)); + color: #062030; + font-weight: 700; + font-size: 15px; + cursor: pointer; + box-shadow: 0 2px 8px rgba(102,192,244,0.3); + transition: filter .15s; +} + diff --git a/static/js/game.js b/static/js/game.js new file mode 100644 index 0000000..680429e --- /dev/null +++ b/static/js/game.js @@ -0,0 +1,375 @@ +(() => { + const mode = document.body.dataset.mode; // 'daily' | 'unlimited' + const searchInput = document.getElementById("search-input"); + const autocompleteBox = document.getElementById("autocomplete"); + const boardBody = document.getElementById("board-body"); + const attemptsInfo = document.getElementById("attempts-info"); + const resultBox = document.getElementById("result-box"); + const nicknameModal = document.getElementById("nickname-modal"); + const playerNameEl = document.getElementById("player-name"); + const streakLabel = document.getElementById("streak-label"); + + let player = PlayerStore.load(); + let sessionId = null; + let maxAttempts = 10; + let finished = false; + + // ---------- Spieler ---------- + function showNicknameModal() { + if (!player) { + nicknameModal.classList.remove("hidden"); + } else { + nicknameModal.classList.add("hidden"); + playerNameEl.textContent = player.nickname; + } + renderStreaks(); + } + + async function submitNickname() { + const input = document.getElementById("nickname-input"); + const nickname = input.value.trim(); + if (!nickname) return; + const res = await fetch("/api/player", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nickname }), + }); + const data = await res.json(); + if (data.player) { + player = data.player; + PlayerStore.save(player); + PlayerStore.setStreaks(data.streaks); + nicknameModal.classList.add("hidden"); + playerNameEl.textContent = player.nickname; + renderStreaks(); + startGame(); + } + } + + function renderStreaks() { + const streaks = PlayerStore.getStreaks(); + if (!streaks) { streakLabel.textContent = ""; return; } + const s = mode === "daily" ? streaks.daily : streaks.unlimited; + if (!s) { streakLabel.textContent = ""; return; } + streakLabel.textContent = `🏆 ${s.win} Siege · 💀 ${s.loss} Niederlagen`; + } + + // ---------- Spielstart ---------- + async function startGame() { + if (!player) return; + const res = await fetch("/api/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ player_id: player.id, mode }), + }); + const data = await res.json(); + if (data.error) { alert(data.error); return; } + sessionId = data.session_id; + maxAttempts = data.max_attempts; + attemptsInfo.textContent = `Versuche: 0 / ${maxAttempts}`; + + if (data.already_played) { + finished = true; + showResult(data.status, data.target, data.attempts_used); + } + } + + // ---------- Suche / Autocomplete ---------- + let searchTimer = null; + let suggestions = []; + let selectedIndex = -1; + + searchInput.addEventListener("input", () => { + clearTimeout(searchTimer); + const q = searchInput.value.trim(); + if (!q) { closeAutocomplete(); return; } + searchTimer = setTimeout(async () => { + const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`); + suggestions = await res.json(); + renderAutocomplete(); + }, 200); + }); + + function renderAutocomplete() { + autocompleteBox.innerHTML = ""; + selectedIndex = -1; + if (!suggestions.length) { closeAutocomplete(); return; } + suggestions.forEach((s, i) => { + const div = document.createElement("div"); + div.className = "autocomplete-item"; + div.innerHTML = `${escapeHtml(s.title)}${s.year}`; + div.addEventListener("mousedown", (e) => { + e.preventDefault(); + selectSuggestion(i); + }); + div.addEventListener("mouseenter", () => { selectedIndex = i; highlight(); }); + autocompleteBox.appendChild(div); + }); + autocompleteBox.classList.add("open"); + } + + function highlight() { + [...autocompleteBox.children].forEach((el, i) => { + el.classList.toggle("selected", i === selectedIndex); + }); + } + + function closeAutocomplete() { + autocompleteBox.classList.remove("open"); + autocompleteBox.innerHTML = ""; + } + + function selectSuggestion(i) { + const s = suggestions[i]; + if (!s) return; + closeAutocomplete(); + submitGuess(s); + } + + searchInput.addEventListener("keydown", (e) => { + if (!autocompleteBox.classList.contains("open")) return; + if (e.key === "ArrowDown") { e.preventDefault(); selectedIndex = Math.min(selectedIndex + 1, suggestions.length - 1); highlight(); } + else if (e.key === "ArrowUp") { e.preventDefault(); selectedIndex = Math.max(selectedIndex - 1, 0); highlight(); } + else if (e.key === "Enter") { e.preventDefault(); selectSuggestion(selectedIndex); } + else if (e.key === "Escape") { closeAutocomplete(); } + }); + + document.addEventListener("click", (e) => { + if (!searchWrapperContains(e.target)) closeAutocomplete(); + }); + function searchWrapperContains(t) { + const w = document.getElementById("search-wrapper"); + return w.contains(t); + } + + // ---------- Tipp abgeben ---------- + async function submitGuess(movie) { + if (finished || !sessionId) return; + searchInput.value = ""; + const res = await fetch("/api/guess", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, movie_id: movie.id }), + }); + const data = await res.json(); + if (data.error) { + if (data.status) { + finished = true; + showResult(data.status, null, data.attempts_used || 0); + } else { + alert(data.error); + } + return; + } + attemptsInfo.textContent = `Versuche: ${data.attempts_used} / ${data.max_attempts}`; + addRow(data.row); + + if (data.finished) { + finished = true; + if (data.streaks) { + PlayerStore.setStreaks(data.streaks); + renderStreaks(); + } + showResult(data.status, data.target, data.attempts_used); + } + } + + // ---------- Icons (Gamedle-/Steam-Stil) ---------- + const ICONS = { + platforms: { + "Netflix": ["📺", "#e50914"], + "Disney+": ["🐭", "#3f6fc9"], + "Prime Video": ["📦", "#00a8e1"], + "Max": ["🌊", "#6a4fa3"], + "Apple TV+": ["🍎", "#9aa0a6"], + "Paramount+": ["🏔️", "#0f5cab"], + "Blu-ray": ["💿", "#2f6fbe"], + "DVD": ["💿", "#8f959b"], + "4K UHD": ["🖥️", "#7f5cd6"], + "Cinema": ["🍿", "#e53935"], + }, + genres: { + "Action": ["💥", "#ff7043"], + "Abenteuer": ["🗺️", "#a1887f"], + "Animation": ["🎨", "#ab47bc"], + "Komödie": ["😂", "#f6c445"], + "Krimi": ["🕵️", "#5c6bc0"], + "Drama": ["🎭", "#7e8b98"], + "Fantasy": ["🐉", "#7e57c2"], + "Horror": ["👻", "#546e7a"], + "Mystery": ["🔍", "#26a69a"], + "Romantik": ["💘", "#ec407a"], + "Sci-Fi": ["🚀", "#29b6f6"], + "Thriller": ["🔪", "#8d2f2f"], + "Western": ["🤠", "#a1887f"], + "Musical": ["🎵", "#f06292"], + "Krieg": ["🪖", "#6d4c41"], + "Biografie": ["📖", "#b0bec5"], + }, + studios: { + "Warner Bros": ["🎞️", "#3f6fc9"], + "Disney": ["🏰", "#2f6fbe"], + "Universal": ["🌐", "#1d8aaa"], + "Paramount": ["🏔️", "#3c5a93"], + "Sony Pictures": ["🎥", "#7f5cd6"], + "20th Century": ["🦊", "#e0892f"], + "MGM": ["🦁", "#c2a845"], + "Lionsgate": ["🦁", "#9aa0a6"], + "A24": ["⏳", "#e2a11f"], + "DreamWorks": ["🌙", "#5b6b8c"], + "New Line": ["🟦", "#2f6fbe"], + "Orion": ["⭐", "#c9c9c9"], + "Neon": ["⚡", "#1fb8e0"], + "CJ Entertainment": ["🎬", "#c93030"], + "Show East": ["🎥", "#666"], + "Dimension": ["🌀", "#9c27b0"], + "Netflix": ["📺", "#e50914"], + }, + universes: { + "MCU": ["🛡️", "#d63031"], + "Star Wars": ["🎆", "#3a3f47"], + "DC": ["🦇", "#1f2937"], + "Wizarding World": ["⚡", "#7e57c2"], + "Fast & Furious": ["🏎️", "#37474f"], + "Herr der Ringe": ["💍", "#a1887f"], + "Spider-Man": ["🕷️", "#c62828"], + }, + }; + + function iconFor(map, name) { + if (!name || !map[name]) return "▪️"; + return map[name][0]; + } + function colorFor(map, name) { + if (!map || !name || !map[name]) return null; + return map[name][1]; + } + + // Badge mit Icon + Label + function badge(map, name) { + if (!name) return ""; + const color = colorFor(map, name); + const style = color ? `style="--tag:${color}"` : ""; + return `${iconFor(map, name)}${escapeHtml(name)}`; + } + + function addRow(row) { + const tr = document.createElement("tr"); + + const titleTd = document.createElement("td"); + titleTd.className = "title-cell"; + titleTd.textContent = row.title; + tr.appendChild(titleTd); + + tr.appendChild(cell(row.platforms.value.map(v => badge(ICONS.platforms, v)).join(""), row.platforms.status)); + tr.appendChild(cell(row.genres.value.map(v => badge(ICONS.genres, v)).join(""), row.genres.status)); + tr.appendChild(cell(yearHtml(row.year), row.year.status)); + tr.appendChild(cell(fskHtml(row.fsk.value), row.fsk.status)); + tr.appendChild(cell(badge(ICONS.studios, row.studio.value), row.studio.status)); + tr.appendChild(cell(row.director.value ? `${escapeHtml(row.director.value)}` : ``, row.director.status)); + tr.appendChild(cell(budgetHtml(row.budget), row.budget.status)); + tr.appendChild(cell(badge(ICONS.universes, row.universe.value), row.universe.status)); + + boardBody.prepend(tr); + } + + function yearHtml(row) { + return `${row.value}${arrowDiff(row.direction, row.diff)}`; + } + + function fskHtml(fsk) { + if (fsk == null) return ``; + return `${fsk}`; + } + + function budgetHtml(row) { + if (row.value == null) return ``; + return `${formatMoney(row.value)}${arrowMoney(row.direction, row.diff)}`; + } + + // Pfeil mit Zahl (z.B. Jahr): ▲ 5 / ▼ 3 + function arrowDiff(dir, diff) { + if (dir === "equal" || diff == null) return ""; + const cls = dir === "up" ? "up" : "down"; + const gly = dir === "up" ? "▲" : "▼"; + return `${gly} ${Math.abs(diff)}`; + } + + // Pfeil mit Differenz in Geld (Budget) + function arrowMoney(dir, diff) { + if (dir === "equal" || diff == null) return ""; + const cls = dir === "up" ? "up" : "down"; + const gly = dir === "up" ? "▲" : "▼"; + const n = Math.abs(diff); + const txt = n >= 1000000 ? (n / 1000000).toFixed(0) + " Mio $" : Math.round(n / 1000) + " Tsd $"; + return `${gly} ${txt}`; + } + + function cell(inner, status) { + const td = document.createElement("td"); + td.innerHTML = inner; + td.classList.add("cell", status); + return td; + } + + function formatMoney(n) { + if (n >= 1000000) return (n / 1000000).toFixed(0) + " Mio $"; + return n.toLocaleString("de-DE") + " $"; + } + + // ---------- Ergebnis ---------- + function showResult(status, target, attemptsUsed) { + resultBox.classList.remove("hidden", "won", "lost"); + if (status === "won") { + resultBox.classList.add("won"); + resultBox.innerHTML = `
🎉 Gewonnen! Du hast den Film in ${attemptsUsed} Versuch(en) erraten.
${target ? `
${escapeHtml(target.title)} (${target.year})
` : ""}${newGameButton()}`; + } else { + resultBox.classList.add("lost"); + resultBox.innerHTML = `
😞 Verloren! Du hattest ${attemptsUsed} Versuch(e).
${target ? `Der gesuchte Film war:
${escapeHtml(target.title)} (${target.year})
` : ""}${newGameButton()}`; + } + } + + function newGameButton() { + const label = mode === "unlimited" ? "Neues Spiel" : "Zurück zu Täglich"; + if (mode === "unlimited") { + return ``; + } + return ""; + } + + document.addEventListener("click", async (e) => { + if (e.target.id === "nickname-submit") await submitNickname(); + if (e.target.id === "new-game-btn") { + finished = false; + resultBox.classList.add("hidden"); + resultBox.innerHTML = ""; + boardBody.innerHTML = ""; + const res = await fetch("/api/new-game", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ player_id: player.id }), + }); + const data = await res.json(); + sessionId = data.session_id; + maxAttempts = data.max_attempts; + attemptsInfo.textContent = `Versuche: 0 / ${maxAttempts}`; + } + }); + + document.getElementById("nickname-input").addEventListener("keydown", (e) => { + if (e.key === "Enter") submitNickname(); + }); + + // ---------- Init ---------- + function escapeHtml(s) { + if (s == null) return ""; + const div = document.createElement("div"); + div.textContent = String(s); + return div.innerHTML; + } + + window.addEventListener("load", () => { + showNicknameModal(); + if (player) startGame(); + }); +})(); diff --git a/static/js/player.js b/static/js/player.js new file mode 100644 index 0000000..7c3c732 --- /dev/null +++ b/static/js/player.js @@ -0,0 +1,36 @@ +// Spieler-Verwaltung (Nickname -> player_id in localStorage) +const PlayerStore = (() => { + const KEY = "filmdle_player"; + const STREAK_KEY = "filmdle_streaks"; + + function save(player) { + localStorage.setItem(KEY, JSON.stringify(player)); + } + + function load() { + try { + return JSON.parse(localStorage.getItem(KEY)); + } catch (e) { + return null; + } + } + + function getStreaks() { + try { + return JSON.parse(localStorage.getItem(STREAK_KEY)); + } catch (e) { + return null; + } + } + + function setStreaks(streaks) { + localStorage.setItem(STREAK_KEY, JSON.stringify(streaks)); + } + + function clear() { + localStorage.removeItem(KEY); + localStorage.removeItem(STREAK_KEY); + } + + return { save, load, getStreaks, setStreaks, clear }; +})(); diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..9060b10 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,69 @@ + + + + + + Filmdle – Film Quiz + + + +
+

🎬 FILMDLE

+ +
+ + +
+
+ +
+
+ + +
+

{{ 'Tägliches Rätsel' if mode == 'daily' else 'Unbegrenztes Spiel' }}

+
+
+ +
+ +
+
+ +
+ + + + + + + + + + + + + + + +
TitelPlattformenGenresJahrFSKStudioRegisseurBudgetUniversum
+
+ + +
+
+ + + + +