Testversion 1
This commit is contained in:
@@ -2,16 +2,12 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# uv installieren
|
|
||||||
RUN pip install uv
|
RUN pip install uv
|
||||||
|
|
||||||
# Nur dependency files zuerst kopieren (Docker Cache!)
|
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
RUN uv sync --frozen --no-dev
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
# Rest kopieren
|
COPY . .
|
||||||
COPY app.py .
|
|
||||||
|
|
||||||
EXPOSE 5000
|
EXPOSE 8000
|
||||||
CMD ["uv", "run", "app.py"]
|
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.
367
backend/app.py
367
backend/app.py
@@ -1,67 +1,346 @@
|
|||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
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__)
|
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():
|
TEXTS = {
|
||||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
"de": {
|
||||||
conn = sqlite3.connect(DB_PATH)
|
"brand": "Svenja & Dominic",
|
||||||
conn.row_factory = sqlite3.Row
|
"subtitle": "Willkommen zu unserer Hochzeits-App",
|
||||||
return conn
|
"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:
|
def get_lang() -> str:
|
||||||
conn.execute("""
|
lang = session.get("lang", "de")
|
||||||
CREATE TABLE IF NOT EXISTS rsvps (
|
return lang if lang in TEXTS else "de"
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
attending INTEGER NOT NULL, -- 1 = ja, 0 = nein
|
def t(key: str) -> str:
|
||||||
plus_one INTEGER NOT NULL DEFAULT 0, -- 1 = ja, 0 = nein
|
return TEXTS[get_lang()].get(key, key)
|
||||||
created_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
""")
|
@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 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()
|
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")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@app.post("/api/rsvp")
|
|
||||||
def create_rsvp():
|
@app.get("/")
|
||||||
data = request.get_json(force=True, silent=True) or {}
|
def landing():
|
||||||
name = (data.get("name") or "").strip()
|
if "guest_id" in session:
|
||||||
attending = data.get("attending")
|
return redirect(url_for("dashboard"))
|
||||||
plus_one = data.get("plus_one", 0)
|
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:
|
if not name:
|
||||||
return jsonify({"error": "name is required"}), 400
|
flash("Bitte Namen eingeben.")
|
||||||
if attending not in (0, 1, True, False):
|
return redirect(url_for("landing"))
|
||||||
return jsonify({"error": "attending must be 0/1"}), 400
|
|
||||||
|
|
||||||
attending_int = 1 if attending in (1, True) else 0
|
if event_password != app.config["EVENT_PASSWORD"]:
|
||||||
plus_one_int = 1 if plus_one in (1, True) else 0
|
flash("Ungültiges Event-Passwort.")
|
||||||
|
return redirect(url_for("landing"))
|
||||||
|
|
||||||
with get_db() as conn:
|
guest_id = upsert_guest(name)
|
||||||
conn.execute(
|
session["guest_id"] = guest_id
|
||||||
"INSERT INTO rsvps (name, attending, plus_one, created_at) VALUES (?, ?, ?, ?)",
|
session["guest_name"] = name
|
||||||
(name, attending_int, plus_one_int, datetime.utcnow().isoformat())
|
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")
|
return render_template("rsvp.html", guest=guest)
|
||||||
def list_rsvps():
|
|
||||||
with get_db() as conn:
|
|
||||||
rows = conn.execute(
|
@app.route("/upload", methods=["GET", "POST"])
|
||||||
"SELECT id, name, attending, plus_one, created_at FROM rsvps ORDER BY id DESC"
|
@login_required
|
||||||
).fetchall()
|
def upload():
|
||||||
return jsonify([dict(r) for r in rows])
|
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 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__":
|
if __name__ == "__main__":
|
||||||
init_db()
|
app.run(host="0.0.0.0", port=8000)
|
||||||
app.run(host="0.0.0.0", port=5000)
|
|
||||||
|
|||||||
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"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=3.1.2",
|
"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 = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "flask" },
|
{ name = "flask" },
|
||||||
|
{ name = "gunicorn" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[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]]
|
[[package]]
|
||||||
name = "blinker"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "itsdangerous"
|
name = "itsdangerous"
|
||||||
version = "2.2.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "3.1.5"
|
version = "3.1.5"
|
||||||
|
|||||||
Binary file not shown.
@@ -6,16 +6,21 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:80"
|
- "127.0.0.1:8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/public:/usr/share/nginx/html:ro
|
- ./frontend/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
container_name: wedding-backend
|
container_name: wedding-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5000:5000"
|
- "127.0.0.1:5000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/uploads:/app/uploads
|
- ./data/uploads:/app/uploads
|
||||||
- ./data/db:/app/db
|
- ./data/db:/app/db
|
||||||
environment:
|
environment:
|
||||||
- DB_PATH=/app/db/app.sqlite3
|
- 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,19 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<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>
|
</head>
|
||||||
<body style="font-family: system-ui; padding: 40px;">
|
<body>
|
||||||
<h1>Hallo Welt 👋</h1>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user