8 Commits

38 changed files with 4443 additions and 779 deletions
+14
View File
@@ -0,0 +1,14 @@
.git
.venv
venv
__pycache__
*.pyc
*.pyo
*.pyd
.pytest_cache
.mypy_cache
node_modules
dist
build
data
instance
+21
View File
@@ -0,0 +1,21 @@
{
"id": "io.hnz.nouri",
"title": "Nouri",
"author": "Florian Heinz",
"description": "Private Flask app for meals, shopping and gentle food planning",
"tagline": "einfach essen planen",
"version": "0.4.0",
"upstreamVersion": "0.4.0",
"healthCheckPath": "/",
"httpPort": 8000,
"manifestVersion": 2,
"addons": {
"localstorage": {
"sqlite": {
"paths": [
"/app/data/nouri.sqlite3"
]
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
FROM python:3.13-slim
WORKDIR /app/code
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update && apt-get install -y --no-install-recommends sqlite3 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/code/
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app/code
RUN chmod +x /app/code/start.sh
EXPOSE 8000
CMD ["/app/code/start.sh"]
+24 -5
View File
@@ -2,7 +2,7 @@
Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten. Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten.
## Merkmale in Version 0.2 ## Merkmale in Version 0.4
- Lebensmittel und Mahlzeitenideen anlegen - Lebensmittel und Mahlzeitenideen anlegen
- Fotos lokal hochladen - Fotos lokal hochladen
@@ -12,7 +12,17 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein
- Tagesplan mit schnellen Vorschlägen je Tageszeit - Tagesplan mit schnellen Vorschlägen je Tageszeit
- Wochenansicht für die nächsten 7 Tage - Wochenansicht für die nächsten 7 Tage
- einfache Suche und Filter für Lebensmittel und Mahlzeitenideen - einfache Suche und Filter für Lebensmittel und Mahlzeitenideen
- einfache Benutzeranmeldung für einen Haushalt - mehrere Haushaltsnutzer mit Rollen
- gemeinsame und persönliche Inhalte
- Profilseite und Passwortänderung
- kleine Admin-Verwaltung für Nutzer
- kompaktere mobile Navigation mit Bottom-Bar
- Tagesvorlagen und Wochenvorlagen
- kleine Pakete für wiederkehrende Einkaufs- oder Planungsbausteine
- sanfte Hinweise und Vorschläge aus Zuhause, Archiv und bisherigen Planungen
- globale Kategorien pro Haushalt
- „Für wen?“ direkt an Lebensmitteln und Mahlzeiten
- Mobile-Mehr-Menü als Sheet statt eigener Seite
## Lokal starten ## Lokal starten
@@ -35,10 +45,19 @@ Wichtige Umgebungsvariablen:
- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron - `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5` - `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
## Migration von 0.1 auf 0.2 ## Migration von 0.3 auf 0.4
Beim Start führt Nouri das Schema erneut mit `CREATE ... IF NOT EXISTS` aus und gleicht die festen Tageszeiten ab. Vorhandene Daten bleiben erhalten; neue Indizes und aktualisierte Tageszeit-Namen werden automatisch ergänzt. Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite weiter: Vorlagen, kleine Pakete, Kategorien pro Haushalt, Zielnutzer an Lebensmitteln und zusätzliche Einkaufs-Kontexte werden ergänzt. Vorhandene 0.3-Daten bleiben erhalten und werden weiterverwendet.
## Cloudron-Hinweis ## Cloudron-Hinweis
Für Cloudron später `NOURI_DATA_DIR=/app/data` setzen, damit Datenbank und Uploads persistent liegen. Für Cloudron ist die App jetzt so vorbereitet, dass Datenbank und Uploads unter `/app/data` liegen. Das Startskript setzt `NOURI_DATA_DIR=/app/data`, legt die SQLite-Datei dort an und startet die App per `gunicorn`.
Lokale Testdaten und produktive Cloudron-Daten bleiben bewusst getrennt:
- lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data`
- auf Cloudron nutzt Nouri `/app/data`
- `data/` ist in `.gitignore` und `.dockerignore` ausgeschlossen und wird weder eingecheckt noch ins Image kopiert
- `/app/data` ist auf Cloudron persistent und bleibt bei App-Updates erhalten
Wenn die App auf Cloudron bereits installiert ist, bitte **kein neues `cloudron install`** ausführen. Stattdessen die bestehende App aktualisieren, also ein neues Image bzw. Paket bauen und dann die vorhandene Installation updaten.
+38 -4
View File
@@ -5,11 +5,20 @@ import secrets
from datetime import date, timedelta from datetime import date, timedelta
from pathlib import Path from pathlib import Path
from flask import Flask, send_from_directory from flask import Flask, g, send_from_directory
from . import db from . import db
from .admin import admin_bp
from .auth import auth_bp from .auth import auth_bp
from .constants import CATEGORIES, DAYPARTS, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS from .constants import (
DAYPARTS,
DEFAULT_CATEGORIES,
ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS,
ROLE_LABELS,
VISIBILITY_DESCRIPTIONS,
VISIBILITY_LABELS,
)
from .main import main_bp from .main import main_bp
@@ -17,6 +26,24 @@ WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Sam
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
def load_secret_key(data_dir: Path) -> str:
env_secret = os.environ.get("NOURI_SECRET_KEY")
if env_secret:
return env_secret
secret_path = data_dir / ".secret_key"
if secret_path.exists():
return secret_path.read_text(encoding="utf-8").strip()
secret_value = secrets.token_hex(24)
try:
with secret_path.open("x", encoding="utf-8") as handle:
handle.write(secret_value)
except FileExistsError:
return secret_path.read_text(encoding="utf-8").strip()
return secret_value
def create_app() -> Flask: def create_app() -> Flask:
root_dir = Path(__file__).resolve().parent.parent root_dir = Path(__file__).resolve().parent.parent
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve() data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
@@ -28,18 +55,21 @@ def create_app() -> Flask:
app = Flask(__name__, instance_relative_config=False) app = Flask(__name__, instance_relative_config=False)
app.config.update( app.config.update(
SECRET_KEY=os.environ.get("NOURI_SECRET_KEY", secrets.token_hex(24)), SECRET_KEY=load_secret_key(data_dir),
DATABASE_PATH=str(db_path), DATABASE_PATH=str(db_path),
DATA_DIR=str(data_dir), DATA_DIR=str(data_dir),
UPLOAD_FOLDER=str(upload_dir), UPLOAD_FOLDER=str(upload_dir),
MAX_CONTENT_LENGTH=int(os.environ.get("NOURI_MAX_UPLOAD_MB", "5")) * 1024 * 1024, MAX_CONTENT_LENGTH=int(os.environ.get("NOURI_MAX_UPLOAD_MB", "5")) * 1024 * 1024,
PERMANENT_SESSION_LIFETIME=timedelta(days=30), PERMANENT_SESSION_LIFETIME=timedelta(days=30),
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
) )
db.init_app(app) db.init_app(app)
db.init_db_if_needed(app) db.init_db_if_needed(app)
app.register_blueprint(auth_bp) app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(main_bp) app.register_blueprint(main_bp)
@app.context_processor @app.context_processor
@@ -47,11 +77,15 @@ def create_app() -> Flask:
return { return {
"item_kind_labels": ITEM_KIND_LABELS, "item_kind_labels": ITEM_KIND_LABELS,
"item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS, "item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS,
"category_suggestions": CATEGORIES, "category_suggestions": DEFAULT_CATEGORIES,
"daypart_suggestions": DAYPARTS, "daypart_suggestions": DAYPARTS,
"visibility_labels": VISIBILITY_LABELS,
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
"role_labels": ROLE_LABELS,
"today": date.today(), "today": date.today(),
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()], "weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()], "weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
"is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin",
} }
@app.get("/uploads/<path:filename>") @app.get("/uploads/<path:filename>")
+262
View File
@@ -0,0 +1,262 @@
from __future__ import annotations
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
from werkzeug.security import generate_password_hash
from .auth import admin_required, can_remove_last_admin, validate_admin_user_form
from .constants import DEFAULT_CATEGORIES, ROLE_LABELS
from .db import get_db
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
def get_household_user(user_id: int):
user = get_db().execute(
"""
SELECT users.*, households.name AS household_name
FROM users
LEFT JOIN households ON households.id = users.household_id
WHERE users.id = ? AND users.household_id = ?
""",
(user_id, g.user["household_id"]),
).fetchone()
if user is None:
raise ValueError("Der Nutzer wurde nicht gefunden.")
return user
def fetch_household_categories():
return get_db().execute(
"""
SELECT *
FROM household_categories
WHERE household_id = ?
ORDER BY is_active DESC, sort_order, LOWER(name)
""",
(g.user["household_id"],),
).fetchall()
@admin_bp.get("/users")
@admin_required
def user_list():
users = get_db().execute(
"""
SELECT *
FROM users
WHERE household_id = ?
ORDER BY is_active DESC, LOWER(COALESCE(display_name, username))
""",
(g.user["household_id"],),
).fetchall()
return render_template("admin/users_list.html", users=users, role_labels=ROLE_LABELS)
@admin_bp.route("/users/new", methods=("GET", "POST"))
@admin_required
def user_create():
form_data = {
"display_name": "",
"username": "",
"email": "",
"role": "member",
"is_active": True,
}
if request.method == "POST":
database = get_db()
form_data = {
"display_name": request.form.get("display_name", "").strip(),
"username": request.form.get("username", "").strip().lower(),
"email": request.form.get("email", "").strip().lower(),
"role": request.form.get("role", "member").strip(),
"is_active": request.form.get("is_active", "1") == "1",
}
password = request.form.get("password", "")
password_repeat = request.form.get("password_repeat", "")
error = validate_admin_user_form(
database,
username=form_data["username"],
email=form_data["email"] or None,
role=form_data["role"],
is_active=form_data["is_active"],
password=password,
password_repeat=password_repeat,
)
if error is None:
database.execute(
"""
INSERT INTO users (household_id, username, email, display_name, role, is_active, password_hash)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
g.user["household_id"],
form_data["username"],
form_data["email"] or None,
form_data["display_name"],
form_data["role"],
1 if form_data["is_active"] else 0,
generate_password_hash(password),
),
)
database.commit()
flash("Der Nutzer wurde angelegt.", "success")
return redirect(url_for("admin.user_list"))
flash(error, "error")
return render_template("admin/user_form.html", user=None, form_data=form_data, role_labels=ROLE_LABELS)
@admin_bp.route("/users/<int:user_id>/edit", methods=("GET", "POST"))
@admin_required
def user_edit(user_id: int):
try:
user = get_household_user(user_id)
except ValueError as exc:
flash(str(exc), "error")
return redirect(url_for("admin.user_list"))
form_data = {
"display_name": user["display_name"] or "",
"username": user["username"],
"email": user["email"] or "",
"role": user["role"],
"is_active": bool(user["is_active"]),
}
if request.method == "POST":
database = get_db()
form_data = {
"display_name": request.form.get("display_name", "").strip(),
"username": request.form.get("username", "").strip().lower(),
"email": request.form.get("email", "").strip().lower(),
"role": request.form.get("role", "member").strip(),
"is_active": request.form.get("is_active", "0") == "1",
}
password = request.form.get("password", "")
password_repeat = request.form.get("password_repeat", "")
error = validate_admin_user_form(
database,
username=form_data["username"],
email=form_data["email"] or None,
role=form_data["role"],
is_active=form_data["is_active"],
password=password,
password_repeat=password_repeat,
current_user_id=user_id,
)
if error is None and can_remove_last_admin(user_id, form_data["role"], form_data["is_active"]):
error = "Mindestens ein aktiver Admin sollte im Haushalt bleiben."
if error is None:
database.execute(
"""
UPDATE users
SET username = ?,
email = ?,
display_name = ?,
role = ?,
is_active = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(
form_data["username"],
form_data["email"] or None,
form_data["display_name"],
form_data["role"],
1 if form_data["is_active"] else 0,
user_id,
),
)
if password:
database.execute(
"""
UPDATE users
SET password_hash = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(generate_password_hash(password), user_id),
)
database.commit()
flash("Der Nutzer wurde aktualisiert.", "success")
return redirect(url_for("admin.user_list"))
flash(error, "error")
return render_template("admin/user_form.html", user=user, form_data=form_data, role_labels=ROLE_LABELS)
@admin_bp.route("/categories", methods=("GET", "POST"))
@admin_required
def category_settings():
if request.method == "POST":
name = request.form.get("name", "").strip()
if not name:
flash("Bitte einen Kategorienamen eintragen.", "error")
else:
existing = get_db().execute(
"""
SELECT id
FROM household_categories
WHERE household_id = ? AND LOWER(name) = LOWER(?)
""",
(g.user["household_id"], name),
).fetchone()
if existing:
get_db().execute(
"UPDATE household_categories SET is_active = 1 WHERE id = ?",
(existing["id"],),
)
flash("Die Kategorie ist wieder aktiv.", "success")
else:
sort_row = get_db().execute(
"SELECT COALESCE(MAX(sort_order), 0) AS max_sort FROM household_categories WHERE household_id = ?",
(g.user["household_id"],),
).fetchone()
get_db().execute(
"""
INSERT INTO household_categories (household_id, name, sort_order, is_active)
VALUES (?, ?, ?, 1)
""",
(g.user["household_id"], name, int(sort_row["max_sort"]) + 10),
)
flash("Die Kategorie wurde ergänzt.", "success")
get_db().commit()
return redirect(url_for("admin.category_settings"))
return render_template(
"admin/categories.html",
categories=fetch_household_categories(),
default_categories=DEFAULT_CATEGORIES,
)
@admin_bp.post("/categories/<int:category_id>/toggle")
@admin_required
def category_toggle(category_id: int):
category = get_db().execute(
"""
SELECT *
FROM household_categories
WHERE id = ? AND household_id = ?
""",
(category_id, g.user["household_id"]),
).fetchone()
if category is None:
flash("Die Kategorie wurde nicht gefunden.", "error")
return redirect(url_for("admin.category_settings"))
new_state = 0 if category["is_active"] else 1
get_db().execute(
"UPDATE household_categories SET is_active = ? WHERE id = ?",
(new_state, category_id),
)
get_db().commit()
flash("Die Kategorie wurde aktualisiert.", "success")
return redirect(url_for("admin.category_settings"))
+178 -17
View File
@@ -16,7 +16,8 @@ from flask import (
from markupsafe import Markup from markupsafe import Markup
from werkzeug.security import check_password_hash, generate_password_hash from werkzeug.security import check_password_hash, generate_password_hash
from .db import get_db, user_count from .constants import ROLE_LABELS
from .db import active_admin_count, ensure_default_household, get_db, user_count
auth_bp = Blueprint("auth", __name__, url_prefix="/auth") auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
@@ -32,6 +33,19 @@ def login_required(view):
return wrapped_view return wrapped_view
def admin_required(view):
@functools.wraps(view)
def wrapped_view(**kwargs):
if g.user is None:
return redirect(url_for("auth.login"))
if g.user["role"] != "admin":
flash("Dieser Bereich ist für Admins gedacht.", "error")
return redirect(url_for("main.dashboard"))
return view(**kwargs)
return wrapped_view
def ensure_csrf_token() -> str: def ensure_csrf_token() -> str:
token = session.get("_csrf_token") token = session.get("_csrf_token")
if not token: if not token:
@@ -39,6 +53,32 @@ def ensure_csrf_token() -> str:
return token return token
def normalize_login_value(raw: str) -> str:
return raw.strip().lower()
def validate_identity_fields(database, username: str, email: str | None, current_user_id: int | None = None) -> str | None:
if not username:
return "Bitte einen Benutzernamen eintragen."
existing_user = database.execute(
"SELECT id FROM users WHERE LOWER(username) = LOWER(?)",
(username,),
).fetchone()
if existing_user and int(existing_user["id"]) != current_user_id:
return "Dieser Benutzername ist bereits vergeben."
if email:
existing_email = database.execute(
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
(email,),
).fetchone()
if existing_email and int(existing_email["id"]) != current_user_id:
return "Diese E-Mail-Adresse wird bereits verwendet."
return None
@auth_bp.app_context_processor @auth_bp.app_context_processor
def inject_csrf_input(): def inject_csrf_input():
return { return {
@@ -56,9 +96,18 @@ def load_logged_in_user():
g.user = None g.user = None
else: else:
g.user = get_db().execute( g.user = get_db().execute(
"SELECT * FROM users WHERE id = ?", """
SELECT users.*,
households.name AS household_name
FROM users
LEFT JOIN households ON households.id = users.household_id
WHERE users.id = ?
""",
(user_id,), (user_id,),
).fetchone() ).fetchone()
if g.user is not None and not g.user["is_active"]:
session.clear()
g.user = None
endpoint = request.endpoint or "" endpoint = request.endpoint or ""
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}: if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}:
@@ -78,27 +127,32 @@ def setup():
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
if request.method == "POST": if request.method == "POST":
username = request.form.get("username", "").strip().lower() household_name = request.form.get("household_name", "").strip() or "Unser Haushalt"
username = normalize_login_value(request.form.get("username", ""))
email = normalize_login_value(request.form.get("email", "")) or None
display_name = request.form.get("display_name", "").strip() display_name = request.form.get("display_name", "").strip()
password = request.form.get("password", "") password = request.form.get("password", "")
password_repeat = request.form.get("password_repeat", "") password_repeat = request.form.get("password_repeat", "")
database = get_db()
error = None error = validate_identity_fields(database, username, email)
if not username: if error is None and not password:
error = "Bitte einen Benutzernamen eintragen."
elif not password:
error = "Bitte ein Passwort vergeben." error = "Bitte ein Passwort vergeben."
elif password != password_repeat: elif error is None and password != password_repeat:
error = "Die Passwörter stimmen nicht überein." error = "Die Passwörter stimmen nicht überein."
if error is None: if error is None:
database = get_db() database.execute(
"INSERT INTO households (name) VALUES (?)",
(household_name,),
)
household_id = ensure_default_household(database)
database.execute( database.execute(
""" """
INSERT INTO users (username, display_name, password_hash) INSERT INTO users (household_id, username, email, display_name, role, password_hash)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?, 'admin', ?)
""", """,
(username, display_name, generate_password_hash(password)), (household_id, username, email, display_name, generate_password_hash(password)),
) )
database.commit() database.commit()
flash("Der erste Haushalt-Zugang ist angelegt. Du kannst dich jetzt anmelden.", "success") flash("Der erste Haushalt-Zugang ist angelegt. Du kannst dich jetzt anmelden.", "success")
@@ -115,22 +169,28 @@ def login():
return redirect(url_for("auth.setup")) return redirect(url_for("auth.setup"))
if request.method == "POST": if request.method == "POST":
username = request.form.get("username", "").strip().lower() identity = normalize_login_value(request.form.get("username", ""))
password = request.form.get("password", "") password = request.form.get("password", "")
remember_me = request.form.get("remember_me") == "1" remember_me = request.form.get("remember_me") == "1"
database = get_db() database = get_db()
user = database.execute( user = database.execute(
"SELECT * FROM users WHERE username = ?", """
(username,), SELECT users.*, households.name AS household_name
FROM users
LEFT JOIN households ON households.id = users.household_id
WHERE LOWER(users.username) = LOWER(?) OR LOWER(COALESCE(users.email, '')) = LOWER(?)
""",
(identity, identity),
).fetchone() ).fetchone()
error = None error = None
if user is None or not check_password_hash(user["password_hash"], password): if user is None or not check_password_hash(user["password_hash"], password):
error = "Benutzername oder Passwort passen nicht zusammen." error = "Login oder Passwort passen nicht zusammen."
elif not user["is_active"]:
error = "Dieser Zugang ist derzeit nicht aktiv."
if error is None: if error is None:
session.clear() session.clear()
# Opt-in long-lived session so the shared household device stays low-friction.
session.permanent = remember_me session.permanent = remember_me
session["user_id"] = user["id"] session["user_id"] = user["id"]
ensure_csrf_token() ensure_csrf_token()
@@ -141,8 +201,109 @@ def login():
return render_template("auth/login.html") return render_template("auth/login.html")
@auth_bp.route("/profile", methods=("GET", "POST"))
@login_required
def profile():
database = get_db()
if request.method == "POST":
username = normalize_login_value(request.form.get("username", ""))
email = normalize_login_value(request.form.get("email", "")) or None
display_name = request.form.get("display_name", "").strip()
error = validate_identity_fields(database, username, email, current_user_id=g.user["id"])
if error is None:
database.execute(
"""
UPDATE users
SET username = ?, email = ?, display_name = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(username, email, display_name, g.user["id"]),
)
database.commit()
flash("Dein Profil wurde aktualisiert.", "success")
return redirect(url_for("auth.profile"))
flash(error, "error")
return render_template("auth/profile.html", role_labels=ROLE_LABELS)
@auth_bp.post("/password")
@login_required
def change_password():
current_password = request.form.get("current_password", "")
new_password = request.form.get("new_password", "")
new_password_repeat = request.form.get("new_password_repeat", "")
error = None
if not check_password_hash(g.user["password_hash"], current_password):
error = "Das aktuelle Passwort stimmt nicht."
elif not new_password:
error = "Bitte ein neues Passwort eintragen."
elif new_password != new_password_repeat:
error = "Die neuen Passwörter stimmen nicht überein."
if error is None:
get_db().execute(
"""
UPDATE users
SET password_hash = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""",
(generate_password_hash(new_password), g.user["id"]),
)
get_db().commit()
flash("Dein Passwort wurde geändert.", "success")
else:
flash(error, "error")
return redirect(url_for("auth.profile"))
@auth_bp.post("/logout") @auth_bp.post("/logout")
def logout(): def logout():
session.clear() session.clear()
flash("Du bist abgemeldet.", "info") flash("Du bist abgemeldet.", "info")
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
def validate_admin_user_form(
database,
*,
username: str,
email: str | None,
role: str,
is_active: bool,
password: str,
password_repeat: str,
current_user_id: int | None = None,
) -> str | None:
error = validate_identity_fields(database, username, email, current_user_id=current_user_id)
if error:
return error
if role not in ROLE_LABELS:
return "Bitte eine gültige Rolle auswählen."
if current_user_id is None and not password:
return "Bitte ein Passwort vergeben."
if password and password != password_repeat:
return "Die Passwörter stimmen nicht überein."
if current_user_id == g.user["id"] and not is_active:
return "Du kannst deinen eigenen Zugang hier nicht deaktivieren."
return None
def can_remove_last_admin(target_user_id: int, new_role: str, is_active: bool) -> bool:
if g.user is None:
return False
if target_user_id != g.user["id"] and g.user["role"] == "admin":
target = get_db().execute("SELECT * FROM users WHERE id = ?", (target_user_id,)).fetchone()
if target is None:
return False
if target["role"] != "admin" or not target["is_active"]:
return False
if new_role == "admin" and is_active:
return False
return active_admin_count(g.user["household_id"]) <= 1
return False
+39 -1
View File
@@ -7,7 +7,7 @@ DAYPARTS = [
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60}, {"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
] ]
CATEGORIES = [ DEFAULT_CATEGORIES = [
"Brot & Getreide", "Brot & Getreide",
"Milchprodukt", "Milchprodukt",
"Obst", "Obst",
@@ -35,3 +35,41 @@ AVAILABILITY_LABELS = {
"home": "Zuhause", "home": "Zuhause",
"archived": "Archiv", "archived": "Archiv",
} }
ROLE_LABELS = {
"admin": "Admin",
"member": "Mitglied",
}
VISIBILITY_LABELS = {
"shared": "Gemeinsam",
"personal": "Persönlich",
}
VISIBILITY_DESCRIPTIONS = {
"shared": "Gemeinsam im Haushalt sichtbar und nutzbar.",
"personal": "Nur für dich sichtbar und planbar.",
}
DAY_TEMPLATE_NAME_SUGGESTIONS = [
"Ruhiger Tag",
"Einfacher Bürotag",
"Schwieriger Tag",
"Standard-Frühstückstag",
"Tag mit wenig Energie",
]
WEEK_TEMPLATE_NAME_SUGGESTIONS = [
"Standardwoche",
"Büro-Woche",
"Leichte Woche",
"Woche mit wenig Energie",
"Frühstücks-Woche",
]
ITEM_SET_NAME_SUGGESTIONS = [
"Schnelles Frühstück",
"Sicherer Snack",
"Einfaches Abendessen",
"Einkauf für zwei Tage",
]
+239 -5
View File
@@ -8,7 +8,7 @@ from flask import Flask, current_app, g
from flask.cli import with_appcontext from flask.cli import with_appcontext
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from .constants import DAYPARTS from .constants import DAYPARTS, DEFAULT_CATEGORIES
def get_db() -> sqlite3.Connection: def get_db() -> sqlite3.Connection:
@@ -28,9 +28,221 @@ def close_db(_error=None) -> None:
database.close() database.close()
def table_columns(database: sqlite3.Connection, table_name: str) -> set[str]:
rows = database.execute(f"PRAGMA table_info({table_name})").fetchall()
return {row["name"] for row in rows}
def table_exists(database: sqlite3.Connection, table_name: str) -> bool:
row = database.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
(table_name,),
).fetchone()
return row is not None
def add_column_if_missing(database: sqlite3.Connection, table_name: str, definition: str) -> None:
column_name = definition.split()[0]
if column_name not in table_columns(database, table_name):
database.execute(f"ALTER TABLE {table_name} ADD COLUMN {definition}")
def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
database.execute(
"""
CREATE TABLE IF NOT EXISTS households (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
database.execute(
"""
CREATE TABLE IF NOT EXISTS household_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 100,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (household_id, name)
)
"""
)
if table_exists(database, "users"):
add_column_if_missing(database, "users", "household_id INTEGER")
add_column_if_missing(database, "users", "email TEXT")
add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'")
add_column_if_missing(database, "users", "is_active INTEGER NOT NULL DEFAULT 1")
add_column_if_missing(database, "users", "updated_at TEXT")
if table_exists(database, "items"):
add_column_if_missing(database, "items", "household_id INTEGER")
add_column_if_missing(database, "items", "owner_user_id INTEGER")
add_column_if_missing(database, "items", "target_user_id INTEGER")
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
if table_exists(database, "shopping_entries"):
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
add_column_if_missing(database, "shopping_entries", "owner_user_id INTEGER")
add_column_if_missing(database, "shopping_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
if table_exists(database, "plan_entries"):
add_column_if_missing(database, "plan_entries", "household_id INTEGER")
add_column_if_missing(database, "plan_entries", "owner_user_id INTEGER")
add_column_if_missing(database, "plan_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
def ensure_default_household(database: sqlite3.Connection) -> int:
household = database.execute(
"SELECT id FROM households ORDER BY id LIMIT 1"
).fetchone()
if household:
return int(household["id"])
database.execute(
"INSERT INTO households (name) VALUES (?)",
("Unser Haushalt",),
)
return int(database.execute("SELECT id FROM households ORDER BY id LIMIT 1").fetchone()["id"])
def household_ids(database: sqlite3.Connection) -> list[int]:
return [int(row["id"]) for row in database.execute("SELECT id FROM households ORDER BY id").fetchall()]
def first_user_id(database: sqlite3.Connection) -> int | None:
row = database.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
return int(row["id"]) if row else None
def sync_default_categories(database: sqlite3.Connection) -> None:
for household_id in household_ids(database):
for sort_order, name in enumerate(DEFAULT_CATEGORIES, start=10):
database.execute(
"""
INSERT OR IGNORE INTO household_categories (household_id, name, sort_order, is_active)
VALUES (?, ?, ?, 1)
""",
(household_id, name, sort_order),
)
def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
add_column_if_missing(database, "users", "household_id INTEGER")
add_column_if_missing(database, "users", "email TEXT")
add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'")
add_column_if_missing(database, "users", "is_active INTEGER NOT NULL DEFAULT 1")
add_column_if_missing(database, "users", "updated_at TEXT")
default_household_id = ensure_default_household(database)
database.execute(
"UPDATE users SET household_id = ? WHERE household_id IS NULL",
(default_household_id,),
)
database.execute("UPDATE users SET role = 'member' WHERE role IS NULL OR role = ''")
database.execute("UPDATE users SET is_active = 1 WHERE is_active IS NULL")
database.execute("UPDATE users SET email = NULL WHERE TRIM(COALESCE(email, '')) = ''")
database.execute("UPDATE users SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)")
admin_row = database.execute(
"SELECT id FROM users WHERE role = 'admin' AND is_active = 1 ORDER BY id LIMIT 1"
).fetchone()
if admin_row is None:
first_id = first_user_id(database)
if first_id is not None:
database.execute("UPDATE users SET role = 'admin' WHERE id = ?", (first_id,))
default_owner_id = first_user_id(database)
for table_name in ("items", "shopping_entries", "plan_entries"):
add_column_if_missing(database, table_name, "household_id INTEGER")
add_column_if_missing(database, table_name, "owner_user_id INTEGER")
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
add_column_if_missing(database, "items", "target_user_id INTEGER")
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
if default_owner_id is not None:
database.execute(
"""
UPDATE items
SET household_id = COALESCE(household_id, ?),
owner_user_id = COALESCE(owner_user_id, created_by, ?),
visibility = CASE WHEN visibility IS NULL OR visibility = '' THEN 'shared' ELSE visibility END
WHERE household_id IS NULL OR owner_user_id IS NULL OR visibility IS NULL OR visibility = ''
""",
(default_household_id, default_owner_id),
)
database.execute(
"""
UPDATE shopping_entries
SET household_id = COALESCE(household_id, ?),
owner_user_id = COALESCE(owner_user_id, added_by, ?),
visibility = CASE WHEN visibility IS NULL OR visibility = '' THEN 'shared' ELSE visibility END
WHERE household_id IS NULL OR owner_user_id IS NULL OR visibility IS NULL OR visibility = ''
""",
(default_household_id, default_owner_id),
)
database.execute(
"""
UPDATE plan_entries
SET household_id = COALESCE(household_id, ?),
owner_user_id = COALESCE(owner_user_id, created_by, ?),
visibility = CASE WHEN visibility IS NULL OR visibility = '' THEN 'shared' ELSE visibility END
WHERE household_id IS NULL OR owner_user_id IS NULL OR visibility IS NULL OR visibility = ''
""",
(default_household_id, default_owner_id),
)
else:
database.execute("UPDATE items SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
database.execute("UPDATE shopping_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
sync_default_categories(database)
database.execute(
"""
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
ON users (email)
WHERE email IS NOT NULL AND email != ''
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
ON items (household_id, visibility, availability_state)
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_items_target_user
ON items (target_user_id)
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility
ON plan_entries (household_id, visibility, plan_date)
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility
ON shopping_entries (household_id, visibility, is_checked)
"""
)
def apply_schema(database: sqlite3.Connection) -> None: def apply_schema(database: sqlite3.Connection) -> None:
bootstrap_legacy_schema(database)
schema_path = Path(__file__).with_name("schema.sql") schema_path = Path(__file__).with_name("schema.sql")
database.executescript(schema_path.read_text(encoding="utf-8")) database.executescript(schema_path.read_text(encoding="utf-8"))
ensure_schema_upgrades(database)
sync_dayparts(database) sync_dayparts(database)
@@ -69,6 +281,18 @@ def user_count() -> int:
return int(row["count"]) return int(row["count"])
def active_admin_count(household_id: int) -> int:
row = get_db().execute(
"""
SELECT COUNT(*) AS count
FROM users
WHERE household_id = ? AND role = 'admin' AND is_active = 1
""",
(household_id,),
).fetchone()
return int(row["count"])
@click.command("init-db") @click.command("init-db")
@with_appcontext @with_appcontext
def init_db_command() -> None: def init_db_command() -> None:
@@ -80,15 +304,25 @@ def init_db_command() -> None:
@click.argument("username") @click.argument("username")
@click.argument("password") @click.argument("password")
@click.option("--display-name", default="", help="Friendly display name.") @click.option("--display-name", default="", help="Friendly display name.")
@click.option("--email", default="", help="Optional email address.")
@click.option("--role", default="member", type=click.Choice(["admin", "member"]))
@with_appcontext @with_appcontext
def create_user_command(username: str, password: str, display_name: str) -> None: def create_user_command(username: str, password: str, display_name: str, email: str, role: str) -> None:
database = get_db() database = get_db()
household_id = ensure_default_household(database)
database.execute( database.execute(
""" """
INSERT INTO users (username, display_name, password_hash) INSERT INTO users (household_id, username, email, display_name, role, password_hash)
VALUES (?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""", """,
(username.strip().lower(), display_name.strip(), generate_password_hash(password)), (
household_id,
username.strip().lower(),
email.strip().lower() or None,
display_name.strip(),
role,
generate_password_hash(password),
),
) )
database.commit() database.commit()
click.echo(f"User '{username}' created.") click.echo(f"User '{username}' created.")
+1773 -463
View File
File diff suppressed because it is too large Load Diff
+142 -4
View File
@@ -1,11 +1,38 @@
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS households (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
email TEXT,
display_name TEXT, display_name TEXT,
role TEXT NOT NULL DEFAULT 'member',
is_active INTEGER NOT NULL DEFAULT 1,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE RESTRICT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
ON users (email)
WHERE email IS NOT NULL AND email != '';
CREATE TABLE IF NOT EXISTS household_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 100,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (household_id, name),
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS dayparts ( CREATE TABLE IF NOT EXISTS dayparts (
@@ -17,6 +44,10 @@ CREATE TABLE IF NOT EXISTS dayparts (
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER,
owner_user_id INTEGER,
target_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')), kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
name TEXT NOT NULL, name TEXT NOT NULL,
category TEXT, category TEXT,
@@ -27,6 +58,9 @@ CREATE TABLE IF NOT EXISTS items (
updated_by INTEGER, updated_by INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
); );
@@ -49,15 +83,23 @@ CREATE TABLE IF NOT EXISTS meal_components (
CREATE TABLE IF NOT EXISTS shopping_entries ( CREATE TABLE IF NOT EXISTS shopping_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
item_id INTEGER NOT NULL, item_id INTEGER NOT NULL,
added_by INTEGER, added_by INTEGER,
checked_by INTEGER, checked_by INTEGER,
needed_for_date TEXT,
needed_for_daypart_id INTEGER,
is_checked INTEGER NOT NULL DEFAULT 0, is_checked INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
checked_at TEXT, checked_at TEXT,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL, FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL
); );
CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item
@@ -66,25 +108,121 @@ WHERE is_checked = 0;
CREATE TABLE IF NOT EXISTS plan_entries ( CREATE TABLE IF NOT EXISTS plan_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
plan_date TEXT NOT NULL, plan_date TEXT NOT NULL,
daypart_id INTEGER NOT NULL, daypart_id INTEGER NOT NULL,
item_id INTEGER NOT NULL, item_id INTEGER NOT NULL,
note TEXT, note TEXT,
created_by INTEGER, created_by INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE, FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE, FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
); );
CREATE TABLE IF NOT EXISTS day_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
name TEXT NOT NULL,
description TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS day_template_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
day_template_id INTEGER NOT NULL,
daypart_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 100,
FOREIGN KEY (day_template_id) REFERENCES day_templates(id) ON DELETE CASCADE,
FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS week_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
name TEXT NOT NULL,
description TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS week_template_days (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_template_id INTEGER NOT NULL,
weekday_index INTEGER NOT NULL,
day_template_id INTEGER NOT NULL,
UNIQUE (week_template_id, weekday_index),
FOREIGN KEY (week_template_id) REFERENCES week_templates(id) ON DELETE CASCADE,
FOREIGN KEY (day_template_id) REFERENCES day_templates(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS item_sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
name TEXT NOT NULL,
description TEXT,
last_used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS item_set_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_set_id INTEGER NOT NULL,
item_id INTEGER NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 100,
UNIQUE (item_set_id, item_id),
FOREIGN KEY (item_set_id) REFERENCES item_sets(id) ON DELETE CASCADE,
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_items_kind_name CREATE INDEX IF NOT EXISTS idx_items_kind_name
ON items (kind, name); ON items (kind, name);
CREATE INDEX IF NOT EXISTS idx_items_availability_name CREATE INDEX IF NOT EXISTS idx_items_household_visibility
ON items (availability_state, name); ON items (household_id, visibility, availability_state);
CREATE INDEX IF NOT EXISTS idx_items_target_user
ON items (target_user_id);
CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item
ON item_dayparts (daypart_id, item_id); ON item_dayparts (daypart_id, item_id);
CREATE INDEX IF NOT EXISTS idx_plan_entries_plan_date_daypart CREATE INDEX IF NOT EXISTS idx_plan_entries_plan_date_daypart
ON plan_entries (plan_date, daypart_id); ON plan_entries (plan_date, daypart_id);
CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility
ON plan_entries (household_id, visibility, plan_date);
CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility
ON shopping_entries (household_id, visibility, is_checked);
CREATE INDEX IF NOT EXISTS idx_day_templates_household_visibility
ON day_templates (household_id, visibility, name);
CREATE INDEX IF NOT EXISTS idx_week_templates_household_visibility
ON week_templates (household_id, visibility, name);
CREATE INDEX IF NOT EXISTS idx_item_sets_household_visibility
ON item_sets (household_id, visibility, name);
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<circle cx="5" cy="12" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="19" cy="12" r="2"/>
</svg>

After

Width:  |  Height:  |  Size: 189 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.6 2 8l10 5.4L22 8 12 2.6Zm-7.9 8.6L2 12.4l10 5.4 10-5.4-2.1-1.2L12 15.4 4.1 11.2Zm0 4.4L2 16.8l10 5.4 10-5.4-2.1-1.2L12 19.8 4.1 15.6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

+1
View File
@@ -11,6 +11,7 @@
let draggedEntry = null; let draggedEntry = null;
board.querySelectorAll(".draggable-plan-entry").forEach((entry) => { board.querySelectorAll(".draggable-plan-entry").forEach((entry) => {
if (entry.getAttribute("draggable") !== "true") return;
entry.addEventListener("dragstart", () => { entry.addEventListener("dragstart", () => {
draggedEntry = entry; draggedEntry = entry;
entry.classList.add("is-dragging"); entry.classList.add("is-dragging");
+10 -12
View File
@@ -1,7 +1,7 @@
(() => { (() => {
const root = document.documentElement; const root = document.documentElement;
const storageKey = "nouri-theme"; const storageKey = "nouri-theme";
const toggle = () => document.querySelector("[data-theme-toggle]"); const toggles = () => Array.from(document.querySelectorAll("[data-theme-toggle]"));
const applyTheme = (theme) => { const applyTheme = (theme) => {
const resolved = theme || localStorage.getItem(storageKey) || "auto"; const resolved = theme || localStorage.getItem(storageKey) || "auto";
@@ -11,22 +11,20 @@
: resolved; : resolved;
root.dataset.theme = finalTheme; root.dataset.theme = finalTheme;
const button = toggle(); toggles().forEach((button) => {
if (button) {
button.textContent = finalTheme === "dark" ? "Hell" : "Dunkel"; button.textContent = finalTheme === "dark" ? "Hell" : "Dunkel";
} });
}; };
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
applyTheme(); applyTheme();
const button = toggle(); toggles().forEach((button) => {
if (!button) return; button.addEventListener("click", () => {
const current = root.dataset.theme === "dark" ? "dark" : "light";
button.addEventListener("click", () => { const next = current === "dark" ? "light" : "dark";
const current = root.dataset.theme === "dark" ? "dark" : "light"; localStorage.setItem(storageKey, next);
const next = current === "dark" ? "light" : "dark"; applyTheme(next);
localStorage.setItem(storageKey, next); });
applyTheme(next);
}); });
}); });
})(); })();
+67
View File
@@ -0,0 +1,67 @@
(() => {
const initMobileSheet = () => {
const sheet = document.querySelector("[data-mobile-sheet]");
const backdrop = document.querySelector("[data-mobile-sheet-backdrop]");
const openButtons = document.querySelectorAll("[data-mobile-sheet-open]");
const closeButtons = document.querySelectorAll("[data-mobile-sheet-close]");
if (!sheet || !backdrop || !openButtons.length) return;
const closeSheet = () => {
sheet.hidden = true;
backdrop.hidden = true;
document.body.classList.remove("sheet-open");
openButtons.forEach((button) => button.classList.remove("is-open"));
};
const openSheet = () => {
sheet.hidden = false;
backdrop.hidden = false;
document.body.classList.add("sheet-open");
openButtons.forEach((button) => button.classList.add("is-open"));
};
openButtons.forEach((button) => {
button.addEventListener("click", openSheet);
});
closeButtons.forEach((button) => {
button.addEventListener("click", closeSheet);
});
backdrop.addEventListener("click", closeSheet);
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeSheet();
}
});
sheet.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", closeSheet);
});
};
const initFilterInputs = () => {
document.querySelectorAll("[data-filter-input]").forEach((input) => {
const listSelector = input.getAttribute("data-filter-target");
if (!listSelector) return;
const container = document.querySelector(listSelector);
if (!container) return;
const items = Array.from(container.querySelectorAll("[data-filter-label]"));
const applyFilter = () => {
const term = input.value.trim().toLowerCase();
items.forEach((item) => {
const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase();
item.hidden = Boolean(term) && !haystack.includes(term);
});
};
input.addEventListener("input", applyFilter);
applyFilter();
});
};
document.addEventListener("DOMContentLoaded", () => {
initMobileSheet();
initFilterInputs();
});
})();
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Kategorien | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Kategorien</p>
<h1>Kategorien global anpassen</h1>
<p class="lead">Hier pflegt ihr die Auswahl für Lebensmittel und Mahlzeiten. Bestehende Einträge bleiben auch dann erhalten, wenn eine Kategorie später pausiert wird.</p>
</div>
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Zur Nutzerverwaltung</a>
</section>
<section class="panel compact-form-panel">
<form method="post" class="inline-form">
{{ csrf_input() }}
<label class="wide">
Neue Kategorie
<input type="text" name="name" placeholder="z. B. Süßes, Vorrat, Unterwegs">
</label>
<button type="submit">Kategorie ergänzen</button>
</form>
</section>
<section class="stack-list">
{% for category in categories %}
<article class="list-row stacked-mobile">
<div>
<strong>{{ category.name }}</strong>
<p class="muted">{% if category.name in default_categories %}Teil der ruhigen Standardauswahl{% else %}Eigene Haushaltskategorie{% endif %}</p>
<div class="chip-row">
{% if category.is_active %}
<span class="chip status-home">Aktiv</span>
{% else %}
<span class="chip status-archived">Pausiert</span>
{% endif %}
</div>
</div>
<div class="row-actions">
<form method="post" action="{{ url_for('admin.category_toggle', category_id=category.id) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
</button>
</form>
</div>
</article>
{% endfor %}
</section>
{% endblock %}
+53
View File
@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block title %}{% if user %}Nutzer bearbeiten{% else %}Nutzer anlegen{% endif %} | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Nutzer verwalten</p>
<h1>{% if user %}{{ user.display_name or user.username }} bearbeiten{% else %}Neuen Nutzer anlegen{% endif %}</h1>
<p class="lead">Wenig Felder, klare Rollen und ein ruhiger Zugang für den gemeinsamen Haushalt.</p>
</div>
</section>
<section class="panel form-panel">
<form method="post" class="stack-form">
{{ csrf_input() }}
<label>
Anzeigename
<input type="text" name="display_name" value="{{ form_data.display_name }}" autocomplete="name">
</label>
<label>
Benutzername
<input type="text" name="username" value="{{ form_data.username }}" autocomplete="username" required>
</label>
<label>
E-Mail
<input type="email" name="email" value="{{ form_data.email }}" autocomplete="email">
</label>
<label>
Rolle
<select name="role">
{% for value, label in role_labels.items() %}
<option value="{{ value }}" {% if form_data.role == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="inline-check">
<input type="checkbox" name="is_active" value="1" {% if form_data.is_active %}checked{% endif %}>
<span>Zugang aktiv</span>
</label>
<label>
{% if user %}Neues Passwort{% else %}Passwort{% endif %}
<input type="password" name="password" autocomplete="new-password" {% if not user %}required{% endif %}>
</label>
<label>
Passwort wiederholen
<input type="password" name="password_repeat" autocomplete="new-password" {% if not user %}required{% endif %}>
</label>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Zurück</a>
</div>
</form>
</section>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}Nutzer verwalten | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Nutzer verwalten</p>
<h1>Haushaltszugänge ruhig pflegen</h1>
<p class="lead">Admins können hier weitere Mitglieder anlegen, Rollen anpassen, Zugänge pausieren und die gemeinsamen Kategorien pflegen.</p>
</div>
<div class="hero-actions">
<a class="button" href="{{ url_for('admin.user_create') }}">Neuen Nutzer anlegen</a>
<a class="button secondary" href="{{ url_for('admin.category_settings') }}">Kategorien</a>
</div>
</section>
<section class="stack-list">
{% for user in users %}
<article class="list-row stacked-mobile">
<div>
<strong>{{ user.display_name or user.username }}</strong>
<p class="muted">
{{ user.username }}
{% if user.email %} · {{ user.email }}{% endif %}
</p>
<div class="chip-row">
<span class="chip">{{ role_labels[user.role] }}</span>
{% if user.is_active %}
<span class="chip status-home">Aktiv</span>
{% else %}
<span class="chip status-archived">Pausiert</span>
{% endif %}
{% if user.id == g.user.id %}
<span class="chip status-soft">Du</span>
{% endif %}
</div>
</div>
<div class="row-actions">
<a class="ghost-button" href="{{ url_for('admin.user_edit', user_id=user.id) }}">Bearbeiten</a>
</div>
</article>
{% endfor %}
</section>
{% endblock %}
+20 -5
View File
@@ -5,7 +5,7 @@
<div> <div>
<p class="eyebrow">Archiv</p> <p class="eyebrow">Archiv</p>
<h1>Frühere Ideen bleiben greifbar</h1> <h1>Frühere Ideen bleiben greifbar</h1>
<p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder auf die Einkaufsliste setzen.</p> <p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder einplanen oder einkaufen.</p>
</div> </div>
</section> </section>
@@ -23,6 +23,14 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<div class="filter-actions"> <div class="filter-actions">
<button type="submit">Filtern</button> <button type="submit">Filtern</button>
<a class="ghost-button" href="{{ url_for('main.archive_view') }}">Zurücksetzen</a> <a class="ghost-button" href="{{ url_for('main.archive_view') }}">Zurücksetzen</a>
@@ -43,6 +51,11 @@
</div> </div>
<div class="item-body"> <div class="item-body">
<h2>{{ item.name }}</h2> <h2>{{ item.name }}</h2>
<div class="chip-row">
<span class="chip">{{ item.visibility_label }}</span>
<span class="chip status-soft">{{ item.owner_label }}</span>
<span class="chip">{{ item.for_label }}</span>
</div>
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p> <p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
{% if item.dayparts %} {% if item.dayparts %}
<div class="chip-row"> <div class="chip-row">
@@ -64,10 +77,12 @@
<button type="submit">Wieder einkaufen</button> <button type="submit">Wieder einkaufen</button>
</form> </form>
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a> <a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
<form method="post" action="{{ url_for('main.item_restore', item_id=item.id) }}"> {% if item.can_edit %}
{{ csrf_input() }} <form method="post" action="{{ url_for('main.item_restore', item_id=item.id) }}">
<button class="ghost-button" type="submit">Zur aktiven Liste</button> {{ csrf_input() }}
</form> <button class="ghost-button" type="submit">Zur aktiven Liste</button>
</form>
{% endif %}
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
+2 -2
View File
@@ -5,12 +5,12 @@
<div class="auth-card"> <div class="auth-card">
<p class="eyebrow">Willkommen zurück</p> <p class="eyebrow">Willkommen zurück</p>
<h1>Ruhig wieder einsteigen</h1> <h1>Ruhig wieder einsteigen</h1>
<p class="lead">Nouri hilft beim Erinnern, Sichtbar-Machen und Planen. Ohne Zahlen, ohne Druck.</p> <p class="lead">Nouri bleibt ein kleiner, freundlicher Ort für euren Alltag rund um Essen, Einkauf und Planung.</p>
<form method="post" class="stack-form"> <form method="post" class="stack-form">
{{ csrf_input() }} {{ csrf_input() }}
<label> <label>
Benutzername Benutzername oder E-Mail
<input type="text" name="username" autocomplete="username" required> <input type="text" name="username" autocomplete="username" required>
</label> </label>
<label> <label>
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Mein Profil | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Mein Profil</p>
<h1>{{ g.user.display_name or g.user.username }}</h1>
<p class="lead">Dein Zugang bleibt bewusst schlicht. Hier kannst du Namen, Login-Daten und Passwort pflegen.</p>
</div>
<div class="intro-pills">
<span class="status-pill">{{ role_labels[g.user.role] }}</span>
{% if g.user.household_name %}
<span class="status-pill status-soft">{{ g.user.household_name }}</span>
{% endif %}
</div>
</section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Basisdaten</h2>
</div>
<form method="post" class="stack-form">
{{ csrf_input() }}
<label>
Anzeigename
<input type="text" name="display_name" value="{{ g.user.display_name or '' }}" autocomplete="name">
</label>
<label>
Benutzername
<input type="text" name="username" value="{{ g.user.username }}" autocomplete="username" required>
</label>
<label>
E-Mail
<input type="email" name="email" value="{{ g.user.email or '' }}" autocomplete="email">
</label>
<button type="submit">Speichern</button>
</form>
</article>
<article class="panel">
<div class="panel-head">
<h2>Passwort ändern</h2>
</div>
<form method="post" action="{{ url_for('auth.change_password') }}" class="stack-form">
{{ csrf_input() }}
<label>
Aktuelles Passwort
<input type="password" name="current_password" autocomplete="current-password" required>
</label>
<label>
Neues Passwort
<input type="password" name="new_password" autocomplete="new-password" required>
</label>
<label>
Neues Passwort wiederholen
<input type="password" name="new_password_repeat" autocomplete="new-password" required>
</label>
<button type="submit">Passwort ändern</button>
</form>
</article>
</section>
{% endblock %}
+9 -1
View File
@@ -5,14 +5,22 @@
<div class="auth-card"> <div class="auth-card">
<p class="eyebrow">Erster Start</p> <p class="eyebrow">Erster Start</p>
<h1>Den ersten Haushalt-Zugang anlegen</h1> <h1>Den ersten Haushalt-Zugang anlegen</h1>
<p class="lead">Danach könnt ihr die App gemeinsam nutzen. Die Daten bleiben lokal in dieser Installation.</p> <p class="lead">Danach könnt ihr Nouri gemeinsam nutzen. Persönliche und gemeinsame Einträge lassen sich später ruhig auseinanderhalten.</p>
<form method="post" class="stack-form"> <form method="post" class="stack-form">
{{ csrf_input() }} {{ csrf_input() }}
<label>
Haushaltsname
<input type="text" name="household_name" autocomplete="organization" placeholder="z. B. Zuhause">
</label>
<label> <label>
Benutzername Benutzername
<input type="text" name="username" autocomplete="username" required> <input type="text" name="username" autocomplete="username" required>
</label> </label>
<label>
E-Mail
<input type="email" name="email" autocomplete="email" placeholder="Optional">
</label>
<label> <label>
Anzeigename Anzeigename
<input type="text" name="display_name" autocomplete="name" placeholder="z. B. Heinz"> <input type="text" name="display_name" autocomplete="name" placeholder="z. B. Heinz">
+79 -9
View File
@@ -9,37 +9,52 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
<script defer src="{{ url_for('static', filename='js/theme.js') }}"></script> <script defer src="{{ url_for('static', filename='js/theme.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/planner.js') }}"></script> <script defer src="{{ url_for('static', filename='js/planner.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/ui.js') }}"></script>
</head> </head>
<body> <body class="{% if g.user %}has-mobile-nav{% endif %}">
<div class="page-shell"> <div class="page-shell">
<header class="site-header"> <header class="site-header">
<a class="brand" href="{{ url_for('main.dashboard') }}"> <a class="brand" href="{{ url_for('main.dashboard') }}">
<span class="brand-mark"> <span class="brand-mark">
<img src="{{ url_for('static', filename='brand/nouri-icon.svg') }}" alt=""> <img src="{{ url_for('static', filename='brand/nouri-icon.svg') }}" alt="">
</span> </span>
<span> <span class="brand-copy">
<strong>Nouri</strong> <strong>Nouri</strong>
<small>einfach essen planen</small> <small>einfach essen planen</small>
</span> </span>
</a> </a>
{% if g.user %} {% if g.user %}
<nav class="site-nav"> <nav class="site-nav desktop-nav">
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-sparkles"></span><span>Heute</span></span></a> <a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-sparkles"></span><span>Heute</span></span></a>
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-cart-shopping"></span><span>Einkauf</span></span></a>
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar"></span><span>Plan</span></span></a>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar-days"></span><span>Woche</span></span></a>
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a>
<a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></a> <a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></a>
<a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a> <a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a>
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-cart-shopping"></span><span>Einkaufsliste</span></span></a> <a href="{{ url_for('main.template_library') }}" class="{{ 'active' if (request.endpoint or '').startswith('main.day_template') or (request.endpoint or '').startswith('main.week_template') or (request.endpoint or '').startswith('main.item_set') or request.endpoint == 'main.template_library' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-layer-group"></span><span>Vorlagen</span></span></a>
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a>
<a href="{{ url_for('main.planner_day', date=today.isoformat() if today else None) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar"></span><span>Tagesplan</span></span></a>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar-days"></span><span>Woche</span></span></a>
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a> <a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a>
</nav> </nav>
<div class="header-actions">
<button class="theme-toggle" type="button" data-theme-toggle>Modus</button> <div class="header-actions desktop-actions">
<button class="theme-toggle ghost-button" type="button" data-theme-toggle>Modus</button>
<a class="user-chip" href="{{ url_for('auth.profile') }}">
<span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span>
<small>{{ role_labels[g.user.role] }}</small>
</a>
{% if g.user.role == 'admin' %}
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Nutzer</a>
{% endif %}
<form method="post" action="{{ url_for('auth.logout') }}"> <form method="post" action="{{ url_for('auth.logout') }}">
{{ csrf_input() }} {{ csrf_input() }}
<button class="ghost-button" type="submit">Abmelden</button> <button class="ghost-button" type="submit">Abmelden</button>
</form> </form>
</div> </div>
<button class="mobile-profile-link ghost-button" type="button" data-mobile-sheet-open aria-label="Mehr öffnen">
<span class="mobile-profile-avatar">{{ (g.user.display_name or g.user.username or 'N')[:1]|upper }}</span>
</button>
{% endif %} {% endif %}
</header> </header>
@@ -57,5 +72,60 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</div> </div>
{% if g.user %}
<div class="mobile-sheet-backdrop" data-mobile-sheet-backdrop hidden></div>
<aside class="mobile-more-sheet" data-mobile-sheet hidden aria-label="Mehr">
<div class="mobile-sheet-head">
<div>
<strong>{{ g.user.display_name or g.user.username }}</strong>
<small>{{ role_labels[g.user.role] }}</small>
</div>
<button class="ghost-button" type="button" data-mobile-sheet-close>Schließen</button>
</div>
<nav class="mobile-sheet-links">
<a href="{{ url_for('main.item_list', kind='food') }}">Lebensmittel</a>
<a href="{{ url_for('main.item_list', kind='meal') }}">Mahlzeiten</a>
<a href="{{ url_for('main.home_view') }}">Zuhause</a>
<a href="{{ url_for('main.archive_view') }}">Archiv</a>
<a href="{{ url_for('main.template_library') }}">Vorlagen</a>
<a href="{{ url_for('auth.profile') }}">Profil</a>
{% if g.user.role == 'admin' %}
<a href="{{ url_for('admin.user_list') }}">Nutzerverwaltung</a>
<a href="{{ url_for('admin.category_settings') }}">Kategorien</a>
{% endif %}
</nav>
<div class="mobile-sheet-actions">
<button class="ghost-button" type="button" data-theme-toggle>Modus wechseln</button>
<form method="post" action="{{ url_for('auth.logout') }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Abmelden</button>
</form>
</div>
</aside>
<nav class="mobile-bottom-nav" aria-label="Mobile Navigation">
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">
<span class="ui-icon icon-sparkles"></span>
<span>Heute</span>
</a>
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}">
<span class="ui-icon icon-cart-shopping"></span>
<span>Einkauf</span>
</a>
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}">
<span class="ui-icon icon-calendar"></span>
<span>Plan</span>
</a>
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">
<span class="ui-icon icon-calendar-days"></span>
<span>Woche</span>
</a>
<button type="button" class="mobile-nav-button" data-mobile-sheet-open>
<span class="ui-icon icon-ellipsis"></span>
<span>Mehr</span>
</button>
</nav>
{% endif %}
</body> </body>
</html> </html>
+71 -25
View File
@@ -4,12 +4,12 @@
<section class="hero"> <section class="hero">
<div> <div>
<p class="eyebrow">Heute</p> <p class="eyebrow">Heute</p>
<h1>Ein ruhiger Blick auf das, was gerade hilft</h1> <h1>Ein ruhiger Blick auf euren Alltag</h1>
<p class="lead">Du siehst auf einen Blick, was zuhause da ist, was schon eingeplant wurde und wo du schnell weitermachen kannst.</p> <p class="lead">Du siehst schnell, was zuhause da ist, was schon geplant wurde, welche Vorlagen gut passen und wo sanfte Unterstützung hilfreich sein kann.</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a> <a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
<a class="button secondary" href="{{ url_for('main.item_create', kind='meal') }}">Mahlzeitenidee anlegen</a> <a class="button secondary" href="{{ url_for('main.template_library') }}">Vorlagen öffnen</a>
</div> </div>
</section> </section>
@@ -31,6 +31,19 @@
</article> </article>
</section> </section>
{% if dashboard_hints %}
<section class="panel">
<div class="panel-head">
<h2>Sanfte Hinweise</h2>
</div>
<div class="hint-list">
{% for hint in dashboard_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</section>
{% endif %}
<section class="two-column"> <section class="two-column">
<article class="panel"> <article class="panel">
<div class="panel-head"> <div class="panel-head">
@@ -40,10 +53,15 @@
{% if today_entries %} {% if today_entries %}
<ul class="simple-list"> <ul class="simple-list">
{% for entry in today_entries %} {% for entry in today_entries %}
<li> <li class="stacked-mobile">
<div> <div>
<strong>{{ entry.daypart_name }}</strong> <strong>{{ entry.daypart_name }}</strong>
<span>{{ entry.item_name }}</span> <span>{{ entry.item_name }}</span>
<div class="chip-row">
<span class="chip">{{ entry.visibility_label }}</span>
<span class="chip status-soft">{{ entry.owner_label }}</span>
<span class="chip">{{ entry.for_label }}</span>
</div>
</div> </div>
{% if entry.availability_state == 'home' %} {% if entry.availability_state == 'home' %}
<span class="status-pill status-home">zuhause</span> <span class="status-pill status-home">zuhause</span>
@@ -52,7 +70,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="empty-state">Für heute ist noch nichts fest eingeplant. Das ist vollkommen okay.</p> <p class="empty-state">Für heute ist noch nichts fest eingeplant. Ein kleiner Anfang reicht völlig.</p>
{% endif %} {% endif %}
</article> </article>
@@ -67,7 +85,8 @@
<article class="mini-card"> <article class="mini-card">
<div class="mini-card-body"> <div class="mini-card-body">
<strong>{{ item.name }}</strong> <strong>{{ item.name }}</strong>
<small>{{ item_kind_labels[item.kind] }}</small> <small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}</small>
<small>{{ item.for_label }}</small>
{% if item.dayparts %} {% if item.dayparts %}
<div class="chip-row"> <div class="chip-row">
{% for daypart in item.dayparts %} {% for daypart in item.dayparts %}
@@ -85,24 +104,51 @@
</article> </article>
</section> </section>
<section class="panel"> <section class="two-column">
<div class="panel-head"> <article class="panel">
<h2>Nächste Tage</h2> <div class="panel-head">
<a href="{{ url_for('main.planner') }}">Wochenansicht öffnen</a> <h2>Vorlagen für später</h2>
</div> <a href="{{ url_for('main.template_library') }}">Alles ansehen</a>
<div class="week-mini-grid"> </div>
{% for card in week_cards %} {% if day_templates or week_templates %}
<a class="week-mini-card" href="{{ url_for('main.planner_day', date=card.date.isoformat()) }}"> <div class="stack-sections">
<strong>{{ weekday_short_name(card.date) }} {{ card.date.strftime('%d.%m.') }}</strong> {% for template in day_templates %}
{% if card.filled_dayparts %} <a class="mini-card" href="{{ url_for('main.day_template_edit', template_id=template.id) }}">
<span>{{ card.planned_count }} Einträge</span> <strong>{{ template.name }}</strong>
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small> <small>Tagesvorlage · {{ template.visibility_label }}</small>
{% else %} </a>
<span>Noch frei</span> {% endfor %}
<small>sanfter Einstieg für den Tag</small> {% for template in week_templates %}
{% endif %} <a class="mini-card" href="{{ url_for('main.week_template_edit', template_id=template.id) }}">
</a> <strong>{{ template.name }}</strong>
{% endfor %} <small>Wochenvorlage · {{ template.visibility_label }}</small>
</div> </a>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Vorlagen helfen später beim Wiederverwenden. Du kannst sie direkt aus einem Tag oder einer Woche heraus anlegen.</p>
{% endif %}
</article>
<article class="panel">
<div class="panel-head">
<h2>Nächste Tage</h2>
<a href="{{ url_for('main.planner') }}">Wochenansicht öffnen</a>
</div>
<div class="week-mini-grid">
{% for card in week_cards %}
<a class="week-mini-card" href="{{ url_for('main.planner_day', date=card.date.isoformat()) }}">
<strong>{{ weekday_short_name(card.date) }} {{ card.date.strftime('%d.%m.') }}</strong>
{% if card.filled_dayparts %}
<span>{{ card.planned_count }} Einträge</span>
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
{% else %}
<span>Noch frei</span>
<small>sanfter Einstieg für den Tag</small>
{% endif %}
</a>
{% endfor %}
</div>
</article>
</section> </section>
{% endblock %} {% endblock %}
+20 -5
View File
@@ -5,7 +5,7 @@
<div> <div>
<p class="eyebrow">Zuhause</p> <p class="eyebrow">Zuhause</p>
<h1>Was aktuell da ist</h1> <h1>Was aktuell da ist</h1>
<p class="lead">Sichtbar, ruhig und besser nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, wandert es nicht weg, sondern ins Archiv.</p> <p class="lead">Sichtbar, ruhig und nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, bleibt es später im Archiv greifbar.</p>
</div> </div>
</section> </section>
@@ -15,6 +15,14 @@
Suche Suche
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen"> <input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
</label> </label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label> <label>
Tageszeit Tageszeit
<select name="daypart_id"> <select name="daypart_id">
@@ -51,6 +59,11 @@
</div> </div>
<div class="item-body"> <div class="item-body">
<h3>{{ item.name }}</h3> <h3>{{ item.name }}</h3>
<div class="chip-row">
<span class="chip">{{ item.visibility_label }}</span>
<span class="chip status-soft">{{ item.owner_label }}</span>
<span class="chip">{{ item.for_label }}</span>
</div>
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p> <p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
{% if item.components %} {% if item.components %}
<p class="muted">Mit: {{ item.components|join(', ') }}</p> <p class="muted">Mit: {{ item.components|join(', ') }}</p>
@@ -58,10 +71,12 @@
</div> </div>
<div class="item-actions"> <div class="item-actions">
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a> <a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}"> {% if item.can_edit %}
{{ csrf_input() }} <form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
<button class="ghost-button" type="submit">Verbraucht / nicht mehr da</button> {{ csrf_input() }}
</form> <button class="ghost-button" type="submit">Verbraucht / nicht mehr da</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}"> <form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
{{ csrf_input() }} {{ csrf_input() }}
<button type="submit">Erneut einkaufen</button> <button type="submit">Erneut einkaufen</button>
+58 -11
View File
@@ -5,8 +5,15 @@
<div> <div>
<p class="eyebrow">{{ item_kind_labels[kind] }}</p> <p class="eyebrow">{{ item_kind_labels[kind] }}</p>
<h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1> <h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1>
<p class="lead">Nur das Nötigste: Name, Bild, Tageszeiten und eine kleine Notiz, wenn sie hilft.</p> <p class="lead">Nur das Nötigste: Name, Sichtbarkeit, für wen etwas gedacht ist, Bild, Tageszeiten und eine kleine Notiz.</p>
</div> </div>
{% if item %}
<div class="intro-pills">
<span class="status-pill">{{ item.visibility_label }}</span>
<span class="status-pill status-soft">{{ item.owner_label }}</span>
<span class="status-pill">{{ item.for_label }}</span>
</div>
{% endif %}
</section> </section>
<section class="panel form-panel"> <section class="panel form-panel">
@@ -17,14 +24,35 @@
<input type="text" name="name" value="{{ form_data.name }}" required> <input type="text" name="name" value="{{ form_data.name }}" required>
</label> </label>
<div class="dual-grid">
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<label>
Für wen?
<select name="target_user_id">
{% for option in target_user_options %}
<option value="{{ option.value }}" {% if form_data.target_user_raw == option.value %}selected{% endif %}>{{ option.label }}</option>
{% endfor %}
</select>
</label>
</div>
<label> <label>
Kategorie Kategorie
<input type="text" name="category" list="category-list" value="{{ form_data.category }}" placeholder="z. B. Obst, Warmes, Snack"> <select name="category">
<datalist id="category-list"> <option value="">Ohne Kategorie</option>
{% for category in categories %} {% for category in categories %}
<option value="{{ category }}"></option> <option value="{{ category }}" {% if form_data.category == category %}selected{% endif %}>{{ category }}</option>
{% endfor %} {% endfor %}
</datalist> </select>
</label> </label>
<label> <label>
@@ -58,20 +86,34 @@
{% if kind == 'meal' %} {% if kind == 'meal' %}
<fieldset> <fieldset>
<legend>Bestandteile der Mahlzeitenidee</legend> <legend>Bestandteile der Mahlzeitenidee</legend>
<p class="muted">Optional: Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus vorhandenen und archivierten Lebensmitteln zusammenklicken.</p> <p class="muted">Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenstellen.</p>
<div class="inline-form">
<label class="wide">
Lebensmittel suchen
<input
type="text"
name="food_search"
value="{{ form_data.food_search }}"
placeholder="z. B. Reis, Banane, Joghurt"
data-filter-input
data-filter-target="#meal-components-list"
>
</label>
<button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button>
</div>
{% if food_groups %} {% if food_groups %}
<div class="stack-sections"> <div class="stack-sections" id="meal-components-list">
{% for group in food_groups %} {% for group in food_groups %}
<div class="component-group"> <div class="component-group">
<div class="panel-head"> <div class="panel-head">
<h3>{{ group["title"] }}</h3> <h3>{{ group["title"] }}</h3>
<span>{{ group["items"]|length }} Einträge</span> <span>{{ group["items"]|length }} Einträge</span>
</div> </div>
<div class="checkbox-grid"> <div class="checkbox-grid filterable-checkbox-group" data-filter-group>
{% for food in group["items"] %} {% for food in group["items"] %}
<label class="check-option"> <label class="check-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }}">
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}> <input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
<span>{{ food.name }}</span> <span>{{ food.name }} · {{ food.visibility_label }} · {{ food.for_label }}</span>
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
@@ -93,7 +135,12 @@
</label> </label>
<label> <label>
Kategorie Kategorie
<input type="text" name="quick_food_category" value="{{ form_data.quick_food_category }}" list="category-list" placeholder="z. B. Milchprodukt"> <select name="quick_food_category">
<option value="">Ohne Kategorie</option>
{% for category in categories %}
<option value="{{ category }}" {% if form_data.quick_food_category == category %}selected{% endif %}>{{ category }}</option>
{% endfor %}
</select>
</label> </label>
<label class="wide"> <label class="wide">
Notiz Notiz
+20 -6
View File
@@ -5,7 +5,7 @@
<div> <div>
<p class="eyebrow">{{ item_kind_labels[kind] }}</p> <p class="eyebrow">{{ item_kind_labels[kind] }}</p>
<h1>{{ item_kind_labels[kind] }}</h1> <h1>{{ item_kind_labels[kind] }}</h1>
<p class="lead">Schnell gepflegte Einträge mit Foto, Tageszeiten und einem ruhigen Status zwischen Merkliste, Zuhause und Archiv.</p> <p class="lead">Gemeinsame und persönliche Ideen bleiben hier ruhig sortiert, mit einem klaren Blick darauf, für wen etwas gedacht ist.</p>
</div> </div>
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a> <a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
</section> </section>
@@ -24,6 +24,14 @@
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label> <label>
Tageszeit Tageszeit
<select name="daypart_id"> <select name="daypart_id">
@@ -56,10 +64,14 @@
<h2>{{ item.name }}</h2> <h2>{{ item.name }}</h2>
<span class="status-pill status-{{ item.availability_state }}">{{ availability_labels[item.availability_state] }}</span> <span class="status-pill status-{{ item.availability_state }}">{{ availability_labels[item.availability_state] }}</span>
</div> </div>
<div class="chip-row">
<span class="chip">{{ item.visibility_label }}</span>
<span class="chip status-soft">{{ item.owner_label }}</span>
<span class="chip">{{ item.for_label }}</span>
</div>
<p class="muted"> <p class="muted">
{% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %} {% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %}
· · {{ item_kind_labels[item.kind] }}
{{ item_kind_labels[item.kind] }}
</p> </p>
{% if item.dayparts %} {% if item.dayparts %}
<div class="chip-row"> <div class="chip-row">
@@ -76,19 +88,21 @@
{% endif %} {% endif %}
</div> </div>
<div class="item-actions"> <div class="item-actions">
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a> {% if item.can_edit %}
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
{% endif %}
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a> <a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}"> <form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
{{ csrf_input() }} {{ csrf_input() }}
<button type="submit">Auf Einkaufsliste</button> <button type="submit">Auf Einkaufsliste</button>
</form> </form>
{% if item.availability_state != 'home' %} {% if item.availability_state != 'home' and item.can_edit %}
<form method="post" action="{{ url_for('main.item_set_home', item_id=item.id) }}"> <form method="post" action="{{ url_for('main.item_set_home', item_id=item.id) }}">
{{ csrf_input() }} {{ csrf_input() }}
<button class="secondary" type="submit">Als Zuhause markieren</button> <button class="secondary" type="submit">Als Zuhause markieren</button>
</form> </form>
{% endif %} {% endif %}
{% if item.availability_state != 'archived' %} {% if item.availability_state != 'archived' and item.can_edit %}
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}"> <form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
{{ csrf_input() }} {{ csrf_input() }}
<button class="ghost-button" type="submit">Ins Archiv</button> <button class="ghost-button" type="submit">Ins Archiv</button>
+93
View File
@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}{% if template %}Tagesvorlage bearbeiten{% else %}Neue Tagesvorlage{% endif %} | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Tagesvorlage</p>
<h1>{% if template %}{{ template.name }} bearbeiten{% else %}Tagesvorlage anlegen{% endif %}</h1>
<p class="lead">Gib der Vorlage einen Namen, den du später schnell wiedererkennst. Die Einträge bleiben bewusst einfach und alltagsnah.</p>
</div>
{% if source_date %}
<span class="status-pill">Aus {{ source_date.strftime('%d.%m.%Y') }}</span>
{% endif %}
</section>
<section class="panel form-panel">
<form method="post" class="stack-form">
{{ csrf_input() }}
<label>
Name der Vorlage
<input type="text" name="name" value="{{ form_data.name }}" placeholder="{{ name_suggestions[0] }}" required>
<small class="helper-text">Zum Beispiel: Ruhiger Tag, Einfacher Bürotag oder ein ganz eigener Name.</small>
</label>
<label>
Beschreibung
<textarea name="description" rows="3" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.description }}</textarea>
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<div class="chip-row">
{% for suggestion in name_suggestions %}
<span class="chip status-soft">{{ suggestion }}</span>
{% endfor %}
</div>
<div class="stack-sections">
{% for section in daypart_sections %}
<fieldset>
<legend>{{ section.daypart.name }}</legend>
<div class="template-search-row">
<label class="wide">
Einträge filtern
<input
type="text"
placeholder="Nach Namen suchen"
data-filter-input
data-filter-target="#day-template-list-{{ section.daypart.id }}"
>
</label>
</div>
{% if section.quick_items %}
<div class="quick-add-row">
{% for item in section.quick_items %}
<label class="quick-select-card" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
<input type="checkbox" name="daypart_{{ section.daypart.id }}_item_ids" value="{{ item.id }}" {% if item.id in section.selected_ids %}checked{% endif %}>
<span>
<strong>{{ item.name }}</strong>
<small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}</small>
</span>
</label>
{% endfor %}
</div>
{% endif %}
<div class="checkbox-grid template-checkbox-grid" id="day-template-list-{{ section.daypart.id }}">
{% for item in section.list_items %}
<label class="check-option" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
<input type="checkbox" name="daypart_{{ section.daypart.id }}_item_ids" value="{{ item.id }}" {% if item.id in section.selected_ids %}checked{% endif %}>
<span>{{ item.name }} · {{ item.visibility_label }} · {{ item.for_label }}</span>
</label>
{% endfor %}
</div>
</fieldset>
{% endfor %}
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
</div>
</form>
</section>
{% endblock %}
+165
View File
@@ -0,0 +1,165 @@
{% extends "base.html" %}
{% block title %}Vorlagen | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Vorlagen</p>
<h1>Bewährtes ruhig wiederverwenden</h1>
<p class="lead">Tagesvorlagen, Wochenvorlagen und kleine Pakete helfen dabei, vertraute Muster mit wenig Tipparbeit erneut zu nutzen.</p>
</div>
<div class="hero-actions">
<a class="button" href="{{ url_for('main.day_template_create') }}">Neue Tagesvorlage</a>
<a class="button secondary" href="{{ url_for('main.week_template_create') }}">Neue Wochenvorlage</a>
<a class="button secondary" href="{{ url_for('main.item_set_create') }}">Neues Paket</a>
</div>
</section>
<section class="panel compact-form-panel">
<form method="get" class="filter-form">
<label class="wide">
Suche
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<div class="filter-actions">
<button type="submit">Filtern</button>
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurücksetzen</a>
</div>
</form>
</section>
{% if template_hints %}
<section class="panel">
<div class="panel-head">
<h2>Sanfte Hinweise</h2>
</div>
<div class="hint-list">
{% for hint in template_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</section>
{% endif %}
<section class="template-library-grid">
<article class="panel">
<div class="panel-head">
<h2>Tagesvorlagen</h2>
<a href="{{ url_for('main.day_template_create') }}">Neu anlegen</a>
</div>
{% if day_templates %}
<div class="stack-sections">
{% for template in day_templates %}
<article class="template-list-card">
<div>
<strong>{{ template.name }}</strong>
{% if template.description %}
<p class="muted">{{ template.description }}</p>
{% endif %}
<div class="chip-row">
<span class="chip">{{ template.visibility_label }}</span>
<span class="chip status-soft">{{ template.owner_label }}</span>
{% if template.last_used_at %}
<span class="chip">Zuletzt genutzt</span>
{% endif %}
</div>
</div>
<div class="row-actions">
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}">
{{ csrf_input() }}
<input type="hidden" name="target_date" value="{{ today.isoformat() }}">
<button type="submit">Heute anwenden</button>
</form>
{% if template.can_edit %}
<a class="ghost-button" href="{{ url_for('main.day_template_edit', template_id=template.id) }}">Bearbeiten</a>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Noch keine passende Tagesvorlage. Du kannst eine Vorlage direkt neu anlegen oder aus einem Tagesplan speichern.</p>
{% endif %}
</article>
<article class="panel">
<div class="panel-head">
<h2>Wochenvorlagen</h2>
<a href="{{ url_for('main.week_template_create') }}">Neu anlegen</a>
</div>
{% if week_templates %}
<div class="stack-sections">
{% for template in week_templates %}
<article class="template-list-card">
<div>
<strong>{{ template.name }}</strong>
{% if template.description %}
<p class="muted">{{ template.description }}</p>
{% endif %}
<div class="chip-row">
<span class="chip">{{ template.visibility_label }}</span>
<span class="chip status-soft">{{ template.owner_label }}</span>
</div>
</div>
<div class="row-actions">
<form method="post" action="{{ url_for('main.week_template_apply', template_id=template.id) }}">
{{ csrf_input() }}
<input type="hidden" name="target_week" value="{{ today.isoformat() }}">
<button type="submit">Diese Woche anwenden</button>
</form>
{% if template.can_edit %}
<a class="ghost-button" href="{{ url_for('main.week_template_edit', template_id=template.id) }}">Bearbeiten</a>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Noch keine Wochenvorlage. Eine gute Woche lässt sich später hier ganz leicht wiederverwenden.</p>
{% endif %}
</article>
</section>
<section class="panel">
<div class="panel-head">
<h2>Kleine Pakete</h2>
<a href="{{ url_for('main.item_set_create') }}">Neues Paket</a>
</div>
{% if item_sets %}
<div class="stack-sections">
{% for item_set in item_sets %}
<article class="template-list-card">
<div>
<strong>{{ item_set.name }}</strong>
{% if item_set.description %}
<p class="muted">{{ item_set.description }}</p>
{% endif %}
<div class="chip-row">
<span class="chip">{{ item_set.visibility_label }}</span>
<span class="chip status-soft">{{ item_set.owner_label }}</span>
</div>
</div>
<div class="row-actions">
<form method="post" action="{{ url_for('main.item_set_apply', set_id=item_set.id) }}">
{{ csrf_input() }}
<button type="submit">Auf Einkaufsliste</button>
</form>
{% if item_set.can_edit %}
<a class="ghost-button" href="{{ url_for('main.item_set_edit', set_id=item_set.id) }}">Bearbeiten</a>
{% endif %}
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Pakete eignen sich gut für kleine Bündel wie schnelles Frühstück, sicherer Snack oder Einkauf für zwei Tage.</p>
{% endif %}
</section>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}{% if item_set %}Paket bearbeiten{% else %}Neues Paket{% endif %} | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Kleines Paket</p>
<h1>{% if item_set %}{{ item_set.name }} bearbeiten{% else %}Paket anlegen{% endif %}</h1>
<p class="lead">Pakete bündeln wiederkehrende Dinge ganz leicht, zum Beispiel schnelles Frühstück, sicherer Snack oder Einkauf für zwei Tage.</p>
</div>
</section>
<section class="panel form-panel">
<form method="post" class="stack-form">
{{ csrf_input() }}
<label>
Name des Pakets
<input type="text" name="name" value="{{ form_data.name }}" placeholder="{{ name_suggestions[0] }}" required>
</label>
<label>
Beschreibung
<textarea name="description" rows="3" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.description }}</textarea>
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<div class="chip-row">
{% for suggestion in name_suggestions %}
<span class="chip status-soft">{{ suggestion }}</span>
{% endfor %}
</div>
<fieldset>
<legend>Einträge auswählen</legend>
<label>
Einträge filtern
<input type="text" placeholder="Nach Namen suchen" data-filter-input data-filter-target="#item-set-list">
</label>
<div class="stack-sections" id="item-set-list">
{% for group in item_groups %}
<div class="component-group">
<div class="panel-head">
<h3>{{ group["title"] }}</h3>
<span>{{ group["items"]|length }} Einträge</span>
</div>
<div class="checkbox-grid">
{% for item in group["items"] %}
<label class="check-option" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
<input type="checkbox" name="item_ids" value="{{ item.id }}" {% if item.id in form_data.item_ids %}checked{% endif %}>
<span>{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}</span>
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
</div>
</form>
</section>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}{% if template %}Wochenvorlage bearbeiten{% else %}Neue Wochenvorlage{% endif %} | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Wochenvorlage</p>
<h1>{% if template %}{{ template.name }} bearbeiten{% else %}Wochenvorlage anlegen{% endif %}</h1>
<p class="lead">Wochenvorlagen bleiben bewusst leicht: pro Wochentag kannst du eine bestehende Tagesvorlage zuordnen oder einen aktuellen Tag als neue Vorlage übernehmen.</p>
</div>
{% if source_week %}
<span class="status-pill">Aus Woche ab {{ source_week.strftime('%d.%m.%Y') }}</span>
{% endif %}
</section>
<section class="panel form-panel">
<form method="post" class="stack-form">
{{ csrf_input() }}
<input type="hidden" name="source_week" value="{{ form_data.source_week }}">
<label>
Name der Vorlage
<input type="text" name="name" value="{{ form_data.name }}" placeholder="{{ name_suggestions[0] }}" required>
<small class="helper-text">Ein Name wie Standardwoche, leichte Woche oder etwas ganz Eigenes reicht völlig aus.</small>
</label>
<label>
Beschreibung
<textarea name="description" rows="3" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.description }}</textarea>
</label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
</label>
<div class="chip-row">
{% for suggestion in name_suggestions %}
<span class="chip status-soft">{{ suggestion }}</span>
{% endfor %}
</div>
<div class="stack-sections">
{% for weekday_index in range(7) %}
<div class="week-template-row">
<div>
<strong>{{ weekday_labels[weekday_index] }}</strong>
<p class="muted">Du kannst eine vorhandene Tagesvorlage auswählen oder den aktuellen Tag aus der Quellwoche übernehmen.</p>
</div>
<label>
Tagesvorlage
<select name="weekday_{{ weekday_index }}_day_template_id">
<option value="">Noch offen</option>
{% for day_template in day_templates %}
<option value="{{ day_template.id }}" {% if form_data.selected_map.get(weekday_index) == day_template.id %}selected{% endif %}>{{ day_template.name }} · {{ day_template.visibility_label }}</option>
{% endfor %}
</select>
</label>
{% if form_data.source_week %}
<label class="inline-check">
<input type="checkbox" name="weekday_{{ weekday_index }}_copy_source" value="1" {% if form_data.copy_from_source.get(weekday_index) %}checked{% endif %}>
<span>Aus Quellwoche als neue Tagesvorlage übernehmen</span>
</label>
{% endif %}
</div>
{% endfor %}
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
</div>
</form>
</section>
{% endblock %}
+76 -9
View File
@@ -14,6 +14,45 @@
</div> </div>
</section> </section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Tagesvorlagen</h2>
<a href="{{ url_for('main.day_template_create', source_date=selected_date.isoformat()) }}">Als Vorlage speichern</a>
</div>
{% if day_templates %}
<div class="stack-sections">
{% for template in day_templates %}
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
{{ csrf_input() }}
<input type="hidden" name="target_date" value="{{ selected_date.isoformat() }}">
<div class="template-card">
<strong>{{ template.name }}</strong>
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
</div>
<button type="submit">Vorlage anwenden</button>
</form>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Wenn du einen Tag öfter wiederverwenden möchtest, kannst du ihn hier als Tagesvorlage speichern.</p>
{% endif %}
</article>
{% if day_hints %}
<article class="panel">
<div class="panel-head">
<h2>Sanfte Hinweise</h2>
</div>
<div class="hint-list">
{% for hint in day_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</article>
{% endif %}
</section>
<section class="planner-day-stack"> <section class="planner-day-stack">
{% for section in sections %} {% for section in sections %}
<details class="day-tile" id="daypart-{{ section.daypart.id }}" {% if section.is_open %}open{% endif %}> <details class="day-tile" id="daypart-{{ section.daypart.id }}" {% if section.is_open %}open{% endif %}>
@@ -33,6 +72,18 @@
</summary> </summary>
<div class="day-tile-body"> <div class="day-tile-body">
{% if section.suggestions %}
<div class="suggestion-row">
{% for suggestion in section.suggestions %}
<article class="suggestion-card">
<strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small>
<p>{{ suggestion.note }}</p>
</article>
{% endfor %}
</div>
{% endif %}
{% if section.quick_items %} {% if section.quick_items %}
<div class="quick-add-row"> <div class="quick-add-row">
{% for item in section.quick_items %} {% for item in section.quick_items %}
@@ -41,16 +92,17 @@
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}"> <input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}"> <input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="item_id" value="{{ item.id }}"> <input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="quick-add-button" type="submit"> <button class="quick-add-button" type="submit">
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
<small>{{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</small> <small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</small>
</button> </button>
</form> </form>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
<form method="post" class="planner-entry-form"> <form method="post" class="planner-entry-form planner-entry-form-wide">
{{ csrf_input() }} {{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}"> <input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}"> <input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
@@ -60,11 +112,19 @@
<option value="">Etwas für {{ section.daypart.name }} wählen</option> <option value="">Etwas für {{ section.daypart.name }} wählen</option>
{% for item in section.candidates %} {% for item in section.candidates %}
<option value="{{ item.id }}" {% if section.selected_item_id == item.id %}selected{% endif %}> <option value="{{ item.id }}" {% if section.selected_item_id == item.id %}selected{% endif %}>
{{ item.name }} · {{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}{% if item.dayparts and section.daypart.name not in item.dayparts %} · auch flexibel{% endif %} {{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}{% if item.availability_state == 'home' %} · zuhause{% endif %}{% if item.dayparts and section.daypart.name not in item.dayparts %} · auch flexibel{% endif %}
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<label>
Sichtbarkeit
<select name="visibility">
{% for value, label in visibility_options %}
<option value="{{ value }}" {% if section.default_visibility == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label class="wide"> <label class="wide">
Notiz Notiz
<input type="text" name="note" placeholder="Optional, wenn eine kleine Erinnerung hilft"> <input type="text" name="note" placeholder="Optional, wenn eine kleine Erinnerung hilft">
@@ -80,13 +140,20 @@
<div> <div>
<strong>{{ entry.item_name }}</strong> <strong>{{ entry.item_name }}</strong>
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small> <small>{{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
<div class="chip-row">
<span class="chip">{{ entry.visibility_label }}</span>
<span class="chip status-soft">{{ entry.owner_label }}</span>
<span class="chip">{{ entry.for_label }}</span>
</div>
</div> </div>
<div class="row-actions"> {% if entry.can_edit %}
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, date=selected_date.isoformat()) }}"> <div class="row-actions">
{{ csrf_input() }} <form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, date=selected_date.isoformat()) }}">
<button class="ghost-button" type="submit">Entfernen</button> {{ csrf_input() }}
</form> <button class="ghost-button" type="submit">Entfernen</button>
</div> </form>
</div>
{% endif %}
</div> </div>
{% if entry.note %} {% if entry.note %}
<p>{{ entry.note }}</p> <p>{{ entry.note }}</p>
+42 -3
View File
@@ -5,7 +5,7 @@
<div> <div>
<p class="eyebrow">Wochenansicht</p> <p class="eyebrow">Wochenansicht</p>
<h1>Ein sanfter Blick auf die nächsten sieben Tage</h1> <h1>Ein sanfter Blick auf die nächsten sieben Tage</h1>
<p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben. Wenn etwas noch nicht zuhause ist, landet es dabei automatisch auf der Einkaufsliste.</p> <p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und eine Woche bei Bedarf für später sichern.</p>
</div> </div>
<div class="week-nav"> <div class="week-nav">
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a> <a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
@@ -14,6 +14,45 @@
</div> </div>
</section> </section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Wochenvorlagen</h2>
<a href="{{ url_for('main.week_template_create', source_week=week_start.isoformat()) }}">Als Vorlage speichern</a>
</div>
{% if week_templates %}
<div class="stack-sections">
{% for template in week_templates %}
<form method="post" action="{{ url_for('main.week_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
{{ csrf_input() }}
<input type="hidden" name="target_week" value="{{ week_start.isoformat() }}">
<div class="template-card">
<strong>{{ template.name }}</strong>
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
</div>
<button type="submit">Vorlage anwenden</button>
</form>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Wenn eine Woche sich bewährt hat, kannst du sie hier später als Wochenvorlage wiederverwenden.</p>
{% endif %}
</article>
{% if week_hints %}
<article class="panel">
<div class="panel-head">
<h2>Sanfte Hinweise</h2>
</div>
<div class="hint-list">
{% for hint in week_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</article>
{% endif %}
</section>
<section class="week-overview-grid week-board" data-csrf-token="{{ csrf_token_value }}"> <section class="week-overview-grid week-board" data-csrf-token="{{ csrf_token_value }}">
{% for card in week_cards %} {% for card in week_cards %}
<article class="week-card"> <article class="week-card">
@@ -49,9 +88,9 @@
{% if slot.entries %} {% if slot.entries %}
<div class="week-entry-stack"> <div class="week-entry-stack">
{% for entry in slot.entries %} {% for entry in slot.entries %}
<article class="plan-chip draggable-plan-entry" draggable="true" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}"> <article class="plan-chip draggable-plan-entry" draggable="{{ 'true' if entry.can_edit else 'false' }}" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}">
<strong>{{ entry.item_name }}</strong> <strong>{{ entry.item_name }}</strong>
<small>{{ item_kind_labels[entry.item_kind] }}</small> <small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
+25 -9
View File
@@ -5,7 +5,7 @@
<div> <div>
<p class="eyebrow">Einkaufsliste</p> <p class="eyebrow">Einkaufsliste</p>
<h1>Was noch mitkommen soll</h1> <h1>Was noch mitkommen soll</h1>
<p class="lead">Abhaken legt Dinge automatisch unter Zuhause ab. So wird aus der Liste direkt sichtbarer Vorrat.</p> <p class="lead">Fehlende Lebensmittel aus einer Mahlzeit landen jetzt einzeln hier. So bleibt die Liste konkret und alltagsnah.</p>
</div> </div>
</section> </section>
@@ -15,7 +15,10 @@
<select name="item_id"> <select name="item_id">
<option value="">Bestehenden Eintrag hinzufügen</option> <option value="">Bestehenden Eintrag hinzufügen</option>
{% for item in addable_items %} {% for item in addable_items %}
<option value="{{ item.id }}">{{ item.name }} · {{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</option> <option value="{{ item.id }}">
{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}
{% if item.availability_state == 'home' %} · zuhause{% endif %}
</option>
{% endfor %} {% endfor %}
</select> </select>
<button type="submit">Auf Liste setzen</button> <button type="submit">Auf Liste setzen</button>
@@ -25,20 +28,33 @@
{% if entries %} {% if entries %}
<section class="stack-list"> <section class="stack-list">
{% for entry in entries %} {% for entry in entries %}
<article class="list-row"> <article class="list-row stacked-mobile">
<div> <div>
<strong>{{ entry.item_name }}</strong> <strong>{{ entry.item_name }}</strong>
<p class="muted">{{ item_kind_labels[entry.item_kind] }}{% if entry.display_name or entry.username %} · hinzugefügt von {{ entry.display_name or entry.username }}{% endif %}</p> <p class="muted">{{ item_kind_labels[entry.item_kind] }}</p>
<div class="chip-row">
<span class="chip">{{ entry.visibility_label }}</span>
<span class="chip status-soft">{{ entry.owner_label }}</span>
<span class="chip">{{ entry.for_label }}</span>
{% if entry.needed_for_label %}
<span class="chip status-home">
Für {{ entry.needed_for_label }}
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
</span>
{% endif %}
</div>
</div> </div>
<div class="row-actions"> <div class="row-actions">
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}"> <form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
{{ csrf_input() }} {{ csrf_input() }}
<button type="submit">Eingekauft</button> <button type="submit">Eingekauft</button>
</form> </form>
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}"> {% if entry.can_edit %}
{{ csrf_input() }} <form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}">
<button class="ghost-button" type="submit">Entfernen</button> {{ csrf_input() }}
</form> <button class="ghost-button" type="submit">Entfernen</button>
</form>
{% endif %}
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
@@ -46,7 +62,7 @@
{% else %} {% else %}
<section class="panel empty-panel"> <section class="panel empty-panel">
<h2>Die Liste ist gerade frei</h2> <h2>Die Liste ist gerade frei</h2>
<p>Einträge aus Lebensmitteln, Mahlzeitenideen oder dem Archiv lassen sich jederzeit wieder hinzufügen.</p> <p>Einträge aus Lebensmitteln, Vorlagen oder kleinen Paketen lassen sich jederzeit wieder hinzufügen.</p>
</section> </section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+1
View File
@@ -1 +1,2 @@
Flask==3.1.1 Flask==3.1.1
gunicorn==23.0.0
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
export NOURI_DATA_DIR="${NOURI_DATA_DIR:-/app/data}"
mkdir -p "${NOURI_DATA_DIR}"
touch "${NOURI_DATA_DIR}/nouri.sqlite3"
mkdir -p "${NOURI_DATA_DIR}/uploads"
exec gunicorn \
--bind 0.0.0.0:8000 \
--workers 2 \
--threads 4 \
--timeout 60 \
wsgi:app