Testversion 1
This commit is contained in:
@@ -2,16 +2,12 @@ FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# uv installieren
|
||||
RUN pip install uv
|
||||
|
||||
# Nur dependency files zuerst kopieren (Docker Cache!)
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
# Rest kopieren
|
||||
COPY app.py .
|
||||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["uv", "run", "app.py"]
|
||||
EXPOSE 8000
|
||||
CMD ["uv", "run", "gunicorn", "-b", "0.0.0.0:8000", "app:app"]
|
||||
|
||||
BIN
backend/__pycache__/app.cpython-312.pyc
Normal file
BIN
backend/__pycache__/app.cpython-312.pyc
Normal file
Binary file not shown.
361
backend/app.py
361
backend/app.py
@@ -1,67 +1,346 @@
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from flask import Flask, request, jsonify
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
flash,
|
||||
g,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_from_directory,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
app = Flask(__name__)
|
||||
base_dir = Path(__file__).resolve().parent
|
||||
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "change-me-in-production")
|
||||
app.config["DB_PATH"] = os.environ.get("DB_PATH", str(base_dir / "app.sqlite3"))
|
||||
app.config["UPLOAD_FOLDER"] = os.environ.get("UPLOAD_FOLDER", str(base_dir / "uploads"))
|
||||
app.config["EVENT_PASSWORD"] = os.environ.get("EVENT_PASSWORD", "wedding2026")
|
||||
app.config["MAX_CONTENT_LENGTH"] = int(os.environ.get("MAX_UPLOAD_BYTES", str(8 * 1024 * 1024)))
|
||||
app.config["LOCATION_NAME"] = os.environ.get("LOCATION_NAME", "Schlossgarten Venue")
|
||||
app.config["LOCATION_ADDRESS"] = os.environ.get(
|
||||
"LOCATION_ADDRESS", "Musterstraße 12, 12345 Musterstadt"
|
||||
)
|
||||
app.config["LOCATION_WEBSITE_URL"] = os.environ.get(
|
||||
"LOCATION_WEBSITE_URL", "https://example.com/location"
|
||||
)
|
||||
app.config["GOOGLE_MAPS_EMBED_URL"] = os.environ.get(
|
||||
"GOOGLE_MAPS_EMBED_URL",
|
||||
"https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d0",
|
||||
)
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "/app/db/app.sqlite3")
|
||||
ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png"}
|
||||
|
||||
def get_db():
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
TEXTS = {
|
||||
"de": {
|
||||
"brand": "Svenja & Dominic",
|
||||
"subtitle": "Willkommen zu unserer Hochzeits-App",
|
||||
"login": "Login",
|
||||
"name": "Dein Name",
|
||||
"event_password": "Event-Passwort",
|
||||
"login_submit": "Weiter zum Dashboard",
|
||||
"dashboard": "Dashboard",
|
||||
"logout": "Abmelden",
|
||||
"rsvp": "RSVP",
|
||||
"upload": "Upload",
|
||||
"gallery": "Galerie",
|
||||
"info": "Infos",
|
||||
"save": "Speichern",
|
||||
"attending": "Ich komme",
|
||||
"not_attending": "Ich komme nicht",
|
||||
"plus_one": "Ich bringe eine Begleitperson mit",
|
||||
"file": "Bild auswählen",
|
||||
"upload_submit": "Foto hochladen",
|
||||
"schedule": "Ablauf",
|
||||
"hotels": "Hotels",
|
||||
"taxi": "Taxi",
|
||||
"location": "Location",
|
||||
"visit_location": "Zur Location-Webseite",
|
||||
},
|
||||
"en": {
|
||||
"brand": "Svenja & Dominic",
|
||||
"subtitle": "Welcome to our wedding app",
|
||||
"login": "Login",
|
||||
"name": "Your name",
|
||||
"event_password": "Event password",
|
||||
"login_submit": "Open dashboard",
|
||||
"dashboard": "Dashboard",
|
||||
"logout": "Logout",
|
||||
"rsvp": "RSVP",
|
||||
"upload": "Upload",
|
||||
"gallery": "Gallery",
|
||||
"info": "Info",
|
||||
"save": "Save",
|
||||
"attending": "I will attend",
|
||||
"not_attending": "I cannot attend",
|
||||
"plus_one": "I will bring a plus-one",
|
||||
"file": "Select image",
|
||||
"upload_submit": "Upload photo",
|
||||
"schedule": "Schedule",
|
||||
"hotels": "Hotels",
|
||||
"taxi": "Taxi",
|
||||
"location": "Location",
|
||||
"visit_location": "Visit location website",
|
||||
},
|
||||
}
|
||||
|
||||
def init_db():
|
||||
with get_db() as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS rsvps (
|
||||
|
||||
def get_lang() -> str:
|
||||
lang = session.get("lang", "de")
|
||||
return lang if lang in TEXTS else "de"
|
||||
|
||||
|
||||
def t(key: str) -> str:
|
||||
return TEXTS[get_lang()].get(key, key)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_common() -> dict:
|
||||
return {
|
||||
"t": t,
|
||||
"lang": get_lang(),
|
||||
"guest_name": session.get("guest_name"),
|
||||
"location_name": app.config["LOCATION_NAME"],
|
||||
"location_address": app.config["LOCATION_ADDRESS"],
|
||||
"location_website_url": app.config["LOCATION_WEBSITE_URL"],
|
||||
"google_maps_embed_url": app.config["GOOGLE_MAPS_EMBED_URL"],
|
||||
}
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
db_path = app.config["DB_PATH"]
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
if "db" not in g:
|
||||
g.db = sqlite3.connect(db_path)
|
||||
g.db.row_factory = sqlite3.Row
|
||||
return g.db
|
||||
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_db(_error) -> None:
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
db_path = app.config["DB_PATH"]
|
||||
db_dir = os.path.dirname(db_path)
|
||||
if db_dir:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS guests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
attending INTEGER NOT NULL, -- 1 = ja, 0 = nein
|
||||
plus_one INTEGER NOT NULL DEFAULT 0, -- 1 = ja, 0 = nein
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
attending INTEGER,
|
||||
plus_one INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS uploads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL,
|
||||
uploaded_by INTEGER NOT NULL,
|
||||
uploaded_at TEXT NOT NULL,
|
||||
FOREIGN KEY(uploaded_by) REFERENCES guests(id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def login_required(view):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if "guest_id" not in session:
|
||||
return redirect(url_for("landing"))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def is_allowed_file(filename: str) -> bool:
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
|
||||
def upsert_guest(name: str) -> int:
|
||||
now = datetime.utcnow().isoformat()
|
||||
db = get_db()
|
||||
row = db.execute("SELECT id FROM guests WHERE name = ?", (name,)).fetchone()
|
||||
if row:
|
||||
return int(row["id"])
|
||||
|
||||
cursor = db.execute(
|
||||
"INSERT INTO guests (name, created_at) VALUES (?, ?)",
|
||||
(name, now),
|
||||
)
|
||||
db.commit()
|
||||
return int(cursor.lastrowid)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/api/rsvp")
|
||||
def create_rsvp():
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
attending = data.get("attending")
|
||||
plus_one = data.get("plus_one", 0)
|
||||
|
||||
@app.get("/")
|
||||
def landing():
|
||||
if "guest_id" in session:
|
||||
return redirect(url_for("dashboard"))
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login():
|
||||
name = (request.form.get("name") or "").strip()
|
||||
event_password = request.form.get("event_password") or ""
|
||||
|
||||
if not name:
|
||||
return jsonify({"error": "name is required"}), 400
|
||||
if attending not in (0, 1, True, False):
|
||||
return jsonify({"error": "attending must be 0/1"}), 400
|
||||
flash("Bitte Namen eingeben.")
|
||||
return redirect(url_for("landing"))
|
||||
|
||||
attending_int = 1 if attending in (1, True) else 0
|
||||
plus_one_int = 1 if plus_one in (1, True) else 0
|
||||
if event_password != app.config["EVENT_PASSWORD"]:
|
||||
flash("Ungültiges Event-Passwort.")
|
||||
return redirect(url_for("landing"))
|
||||
|
||||
with get_db() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO rsvps (name, attending, plus_one, created_at) VALUES (?, ?, ?, ?)",
|
||||
(name, attending_int, plus_one_int, datetime.utcnow().isoformat())
|
||||
guest_id = upsert_guest(name)
|
||||
session["guest_id"] = guest_id
|
||||
session["guest_name"] = name
|
||||
return redirect(url_for("dashboard"))
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("landing"))
|
||||
|
||||
|
||||
@app.post("/lang/<code>")
|
||||
def set_lang(code: str):
|
||||
if code in TEXTS:
|
||||
session["lang"] = code
|
||||
return redirect(request.referrer or url_for("landing"))
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
@login_required
|
||||
def dashboard():
|
||||
return render_template("dashboard.html")
|
||||
|
||||
|
||||
@app.route("/rsvp", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def rsvp():
|
||||
db = get_db()
|
||||
|
||||
if request.method == "POST":
|
||||
attending_raw = request.form.get("attending")
|
||||
plus_one = 1 if request.form.get("plus_one") == "on" else 0
|
||||
|
||||
if attending_raw not in {"yes", "no"}:
|
||||
flash("Bitte eine RSVP-Auswahl treffen.")
|
||||
return redirect(url_for("rsvp"))
|
||||
|
||||
attending = 1 if attending_raw == "yes" else 0
|
||||
if not attending:
|
||||
plus_one = 0
|
||||
|
||||
db.execute(
|
||||
"UPDATE guests SET attending = ?, plus_one = ? WHERE id = ?",
|
||||
(attending, plus_one, session["guest_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
db.commit()
|
||||
flash("RSVP gespeichert.")
|
||||
return redirect(url_for("rsvp"))
|
||||
|
||||
return jsonify({"ok": True})
|
||||
guest = db.execute(
|
||||
"SELECT attending, plus_one FROM guests WHERE id = ?",
|
||||
(session["guest_id"],),
|
||||
).fetchone()
|
||||
|
||||
@app.get("/api/rsvps")
|
||||
def list_rsvps():
|
||||
with get_db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, attending, plus_one, created_at FROM rsvps ORDER BY id DESC"
|
||||
return render_template("rsvp.html", guest=guest)
|
||||
|
||||
|
||||
@app.route("/upload", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def upload():
|
||||
if request.method == "POST":
|
||||
file = request.files.get("photo")
|
||||
if file is None or file.filename == "":
|
||||
flash("Bitte eine Bilddatei auswählen.")
|
||||
return redirect(url_for("upload"))
|
||||
|
||||
if not is_allowed_file(file.filename):
|
||||
flash("Nur JPG/JPEG/PNG sind erlaubt.")
|
||||
return redirect(url_for("upload"))
|
||||
|
||||
safe_name = secure_filename(file.filename)
|
||||
ext = safe_name.rsplit(".", 1)[1].lower()
|
||||
stored_name = f"{uuid.uuid4().hex}.{ext}"
|
||||
|
||||
upload_dir = app.config["UPLOAD_FOLDER"]
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
file.save(os.path.join(upload_dir, stored_name))
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO uploads (filename, uploaded_by, uploaded_at) VALUES (?, ?, ?)",
|
||||
(stored_name, session["guest_id"], datetime.utcnow().isoformat()),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
flash("Upload erfolgreich.")
|
||||
return redirect(url_for("gallery"))
|
||||
|
||||
return render_template("upload.html")
|
||||
|
||||
|
||||
@app.get("/gallery")
|
||||
@login_required
|
||||
def gallery():
|
||||
db = get_db()
|
||||
images = db.execute(
|
||||
"""
|
||||
SELECT uploads.filename, uploads.uploaded_at, guests.name AS uploaded_by
|
||||
FROM uploads
|
||||
JOIN guests ON guests.id = uploads.uploaded_by
|
||||
ORDER BY uploads.id DESC
|
||||
"""
|
||||
).fetchall()
|
||||
return jsonify([dict(r) for r in rows])
|
||||
return render_template("gallery.html", images=images)
|
||||
|
||||
|
||||
@app.get("/uploads/<path:filename>")
|
||||
@login_required
|
||||
def serve_upload(filename: str):
|
||||
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
|
||||
|
||||
|
||||
@app.get("/info/<page>")
|
||||
@login_required
|
||||
def info(page: str):
|
||||
allowed = {"schedule", "hotels", "taxi", "location"}
|
||||
if page not in allowed:
|
||||
return redirect(url_for("dashboard"))
|
||||
return render_template("info.html", page=page)
|
||||
|
||||
|
||||
init_db()
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
app.run(host="0.0.0.0", port=5000)
|
||||
app.run(host="0.0.0.0", port=8000)
|
||||
|
||||
BIN
backend/app.sqlite3
Normal file
BIN
backend/app.sqlite3
Normal file
Binary file not shown.
@@ -6,4 +6,5 @@ readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"flask>=3.1.2",
|
||||
"gunicorn>=23.0.0",
|
||||
]
|
||||
|
||||
172
backend/static/styles.css
Normal file
172
backend/static/styles.css
Normal file
@@ -0,0 +1,172 @@
|
||||
:root {
|
||||
--cream: #f8f2e8;
|
||||
--beige: #efe3d3;
|
||||
--forest: #274235;
|
||||
--gold: #b8914c;
|
||||
--ink: #1f1f1f;
|
||||
--card: #fffdf9;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: "Source Sans 3", sans-serif;
|
||||
background: radial-gradient(circle at top, #fff, var(--cream));
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
font-family: "Playfair Display", serif;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid rgba(39, 66, 53, 0.12);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: var(--forest);
|
||||
text-decoration: none;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.host {
|
||||
color: rgba(31, 31, 31, 0.7);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: min(960px, 92vw);
|
||||
margin: 1.5rem auto 3rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid rgba(39, 66, 53, 0.1);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 10px 32px rgba(39, 66, 53, 0.08);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(145deg, #fff, var(--beige));
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.link-card {
|
||||
color: var(--forest);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 90px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.link-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 14px 30px rgba(39, 66, 53, 0.16);
|
||||
}
|
||||
|
||||
.form-card {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
input[type="file"] {
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
border: 1px solid #d9ceb9;
|
||||
border-radius: 12px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.radio-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
padding: 0.6rem 0.9rem;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
background: var(--forest);
|
||||
text-decoration: none;
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
color: var(--forest);
|
||||
background: transparent;
|
||||
border: 1px solid rgba(39, 66, 53, 0.2);
|
||||
}
|
||||
|
||||
.flash {
|
||||
padding: 0.7rem 0.9rem;
|
||||
border-radius: 10px;
|
||||
background: #f2f7f3;
|
||||
border: 1px solid rgba(39, 66, 53, 0.2);
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.map-wrap iframe {
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
margin: 0.8rem 0;
|
||||
}
|
||||
45
backend/templates/base.html
Normal file
45
backend/templates/base.html
Normal file
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ lang }}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ t('brand') }}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&family=Source+Sans+3:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<a class="brand" href="{{ url_for('dashboard') if guest_name else url_for('landing') }}">{{ t('brand') }}</a>
|
||||
<div class="host">{{ request.host }}</div>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<form method="post" action="{{ url_for('set_lang', code='de') }}">
|
||||
<button class="btn btn-ghost" type="submit">DE</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('set_lang', code='en') }}">
|
||||
<button class="btn btn-ghost" type="submit">EN</button>
|
||||
</form>
|
||||
{% if guest_name %}
|
||||
<form method="post" action="{{ url_for('logout') }}">
|
||||
<button class="btn btn-ghost" type="submit">{{ t('logout') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
<p class="flash">{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
17
backend/templates/dashboard.html
Normal file
17
backend/templates/dashboard.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1>{{ t('dashboard') }}</h1>
|
||||
<p>Hallo {{ guest_name }}.</p>
|
||||
</section>
|
||||
|
||||
<section class="card-grid">
|
||||
<a class="card link-card" href="{{ url_for('rsvp') }}">{{ t('rsvp') }}</a>
|
||||
<a class="card link-card" href="{{ url_for('upload') }}">{{ t('upload') }}</a>
|
||||
<a class="card link-card" href="{{ url_for('gallery') }}">{{ t('gallery') }}</a>
|
||||
<a class="card link-card" href="{{ url_for('info', page='schedule') }}">{{ t('schedule') }}</a>
|
||||
<a class="card link-card" href="{{ url_for('info', page='hotels') }}">{{ t('hotels') }}</a>
|
||||
<a class="card link-card" href="{{ url_for('info', page='taxi') }}">{{ t('taxi') }}</a>
|
||||
<a class="card link-card" href="{{ url_for('info', page='location') }}">{{ t('location') }}</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
20
backend/templates/gallery.html
Normal file
20
backend/templates/gallery.html
Normal file
@@ -0,0 +1,20 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1>{{ t('gallery') }}</h1>
|
||||
{% if images %}
|
||||
<div class="gallery-grid">
|
||||
{% for image in images %}
|
||||
<figure class="gallery-item">
|
||||
<a href="{{ url_for('serve_upload', filename=image['filename']) }}" target="_blank" rel="noopener">
|
||||
<img src="{{ url_for('serve_upload', filename=image['filename']) }}" alt="Upload von {{ image['uploaded_by'] }}" loading="lazy" />
|
||||
</a>
|
||||
<figcaption>von {{ image['uploaded_by'] }}</figcaption>
|
||||
</figure>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Noch keine Bilder vorhanden.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
31
backend/templates/info.html
Normal file
31
backend/templates/info.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<section class="card">
|
||||
<h1>
|
||||
{% if page == 'schedule' %}{{ t('schedule') }}{% endif %}
|
||||
{% if page == 'hotels' %}{{ t('hotels') }}{% endif %}
|
||||
{% if page == 'taxi' %}{{ t('taxi') }}{% endif %}
|
||||
{% if page == 'location' %}{{ t('location') }}{% endif %}
|
||||
</h1>
|
||||
|
||||
{% if page == 'schedule' %}
|
||||
<p>15:00 Trauung, 17:00 Empfang, 19:00 Dinner.</p>
|
||||
{% elif page == 'hotels' %}
|
||||
<p>Empfehlungen folgen. Bitte frühzeitig buchen.</p>
|
||||
{% elif page == 'taxi' %}
|
||||
<p>Taxi-Service: 01234 / 567890 (24/7).</p>
|
||||
{% elif page == 'location' %}
|
||||
<p><strong>{{ location_name }}</strong></p>
|
||||
<p>{{ location_address }}</p>
|
||||
<div class="map-wrap">
|
||||
<iframe
|
||||
src="{{ google_maps_embed_url }}"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer-when-downgrade"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
<a class="btn" href="{{ location_website_url }}" target="_blank" rel="noopener">{{ t('visit_location') }}</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
24
backend/templates/login.html
Normal file
24
backend/templates/login.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<section class="hero card">
|
||||
<h1>{{ t('subtitle') }}</h1>
|
||||
<p>Passwortgeschützter Zugriff für unsere Gäste.</p>
|
||||
</section>
|
||||
|
||||
<section class="card form-card">
|
||||
<h2>{{ t('login') }}</h2>
|
||||
<form method="post" action="{{ url_for('login') }}" class="form-grid">
|
||||
<label>
|
||||
{{ t('name') }}
|
||||
<input type="text" name="name" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
{{ t('event_password') }}
|
||||
<input type="password" name="event_password" required />
|
||||
</label>
|
||||
|
||||
<button class="btn" type="submit">{{ t('login_submit') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
24
backend/templates/rsvp.html
Normal file
24
backend/templates/rsvp.html
Normal file
@@ -0,0 +1,24 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<section class="card form-card">
|
||||
<h1>{{ t('rsvp') }}</h1>
|
||||
<form method="post" class="form-grid">
|
||||
<label class="radio-row">
|
||||
<input type="radio" name="attending" value="yes" {% if guest and guest['attending'] == 1 %}checked{% endif %} />
|
||||
{{ t('attending') }}
|
||||
</label>
|
||||
|
||||
<label class="radio-row">
|
||||
<input type="radio" name="attending" value="no" {% if guest and guest['attending'] == 0 %}checked{% endif %} />
|
||||
{{ t('not_attending') }}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="plus_one" {% if guest and guest['plus_one'] == 1 %}checked{% endif %} />
|
||||
{{ t('plus_one') }}
|
||||
</label>
|
||||
|
||||
<button class="btn" type="submit">{{ t('save') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
13
backend/templates/upload.html
Normal file
13
backend/templates/upload.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends 'base.html' %}
|
||||
{% block content %}
|
||||
<section class="card form-card">
|
||||
<h1>{{ t('upload') }}</h1>
|
||||
<form method="post" enctype="multipart/form-data" class="form-grid">
|
||||
<label>
|
||||
{{ t('file') }}
|
||||
<input type="file" name="photo" accept=".jpg,.jpeg,.png" required />
|
||||
</label>
|
||||
<button class="btn" type="submit">{{ t('upload_submit') }}</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
27
backend/uv.lock
generated
27
backend/uv.lock
generated
@@ -8,10 +8,14 @@ version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
{ name = "gunicorn" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "flask", specifier = ">=3.1.2" }]
|
||||
requires-dist = [
|
||||
{ name = "flask", specifier = ">=3.1.2" },
|
||||
{ name = "gunicorn", specifier = ">=23.0.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
@@ -60,6 +64,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "25.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/13/ef67f59f6a7896fdc2c1d62b5665c5219d6b0a9a1784938eb9a28e55e128/gunicorn-25.1.0.tar.gz", hash = "sha256:1426611d959fa77e7de89f8c0f32eed6aa03ee735f98c01efba3e281b1c47616", size = 594377, upload-time = "2026-02-13T11:09:58.989Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/73/4ad5b1f6a2e21cf1e85afdaad2b7b1a933985e2f5d679147a1953aaa192c/gunicorn-25.1.0-py3-none-any.whl", hash = "sha256:d0b1236ccf27f72cfe14bce7caadf467186f19e865094ca84221424e839b8b8b", size = 197067, upload-time = "2026-02-13T11:09:57.146Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
@@ -144,6 +160,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.5"
|
||||
|
||||
Binary file not shown.
@@ -6,16 +6,21 @@ services:
|
||||
ports:
|
||||
- "127.0.0.1:8080:80"
|
||||
volumes:
|
||||
- ./frontend/public:/usr/share/nginx/html:ro
|
||||
- ./frontend/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: wedding-backend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:5000:5000"
|
||||
- "127.0.0.1:5000:8000"
|
||||
volumes:
|
||||
- ./data/uploads:/app/uploads
|
||||
- ./data/db:/app/db
|
||||
environment:
|
||||
- DB_PATH=/app/db/app.sqlite3
|
||||
- UPLOAD_FOLDER=/app/uploads
|
||||
- EVENT_PASSWORD=${EVENT_PASSWORD:-wedding2026}
|
||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||
|
||||
15
frontend/nginx/default.conf
Normal file
15
frontend/nginx/default.conf
Normal file
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 10M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,18 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Svenja & Dominic</title>
|
||||
<title>Wedding App</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f8f6f2; color: #1f1f1f; }
|
||||
main { text-align: center; padding: 24px; }
|
||||
a { display: inline-block; margin-top: 12px; padding: 10px 14px; border-radius: 10px; text-decoration: none; background: #274235; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="font-family: system-ui; padding: 40px;">
|
||||
<h1>Hallo Welt 👋</h1>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Svenja & Dominic</h1>
|
||||
<p>Nginx ist erreichbar. Die App läuft im Backend.</p>
|
||||
<a href="http://127.0.0.1:5000/">Zum Login</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user