13 Commits

68 changed files with 7454 additions and 696 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
+3
View File
@@ -9,3 +9,6 @@ __pycache__/
data/ data/
instance/ instance/
.cloudron-push.env
.env.local
.env.push.local
+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.5.0",
"upstreamVersion": "0.5.0",
"healthCheckPath": "/",
"httpPort": 8000,
"manifestVersion": 2,
"addons": {
"localstorage": {
"sqlite": {
"paths": [
"/app/data/nouri.sqlite3"
]
}
}
}
}
+33
View File
@@ -0,0 +1,33 @@
FROM python:3.13-slim
WORKDIR /app/code
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV PORT=8000
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
RUN groupadd --gid 1000 cloudron \
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/cloudron cloudron
COPY requirements.txt /app/code/
RUN pip install --no-cache-dir -r requirements.txt gunicorn
COPY . /app/code
# Lokale Daten für den ersten Start sichern und danach /app/code/data auf /app/data zeigen lassen
RUN if [ -d /app/code/data ]; then mv /app/code/data /app/bootstrap-data; else mkdir -p /app/bootstrap-data; fi \
&& ln -s /app/data /app/code/data \
&& chmod +x /app/code/start.sh \
&& chown -R cloudron:cloudron /app/code /app/bootstrap-data \
&& chown -h cloudron:cloudron /app/code/data
USER cloudron
EXPOSE 8000
CMD ["/app/code/start.sh"]
+52
View File
@@ -0,0 +1,52 @@
PolyForm Noncommercial License 1.0.0
Acceptance
In order to get any license under these terms, you must agree to them as
both strict obligations and conditions to all your licenses.
Copyright License
The licensor grants you a copyright license for the software to do everything
you might do with the software that would otherwise infringe the licensor's
copyright in it for any permitted purpose.
Limitations
You may use this software for private, personal, internal, or other
noncommercial purposes only.
Any use of this software or any derivative work for commercial advantage or
monetary compensation is not permitted under this license.
Distribution of modified or unmodified copies must include this license text
and all copyright notices.
Patent License
The licensor grants you a patent license for the software that covers patent
claims the licensor can license, or becomes able to license, that you would
otherwise infringe by using the software for any permitted purpose.
Notices
You must ensure that anyone who gets a copy of any part of this software from
you also gets a copy of these terms.
No Liability
As far as the law allows, the software comes as is, without any warranty or
condition, and the licensor will not be liable to you for any damages arising
out of these terms or the use or nature of the software, under any kind of
legal claim.
Definitions
The licensor is the person or entity offering these terms, and the software is
the software the licensor makes available under these terms, including any
portion of it.
Noncommercial means not primarily intended for or directed toward commercial
advantage or monetary compensation.
If you need a standardized reference text, see https://polyformproject.org/licenses/noncommercial/1.0.0/
+41
View File
@@ -0,0 +1,41 @@
# Push-Setup für Nouri
## 1. VAPID-Schlüssel erzeugen
```bash
. .venv/bin/activate
python scripts/generate_vapid_keys.py
```
Das Script gibt drei Zeilen aus:
- `NOURI_VAPID_PUBLIC_KEY`
- `NOURI_VAPID_PRIVATE_KEY`
- `NOURI_VAPID_SUBJECT`
## 2. In Cloudron eintragen
In der bestehenden Nouri-App unter `Settings``Environment Variables` diese drei Werte anlegen:
```text
NOURI_VAPID_PUBLIC_KEY=...
NOURI_VAPID_PRIVATE_KEY=...
NOURI_VAPID_SUBJECT=mailto:mail@hnz.io
```
Danach die App neu starten.
## 3. Auf dem iPhone aktivieren
1. `nouri.heinz.media` in Safari öffnen
2. `Teilen``Zum Home-Bildschirm`
3. die installierte Web-App öffnen
4. in Nouri `Optionen` öffnen
5. `Push erlauben` tippen
6. danach optional `Test-Mitteilung senden`
## 4. Bereits vorbereitete lokale Datei
Wenn lokal bereits eine Datei `.cloudron-push.env` liegt, kannst du deren Werte direkt nach Cloudron übernehmen.
Die Datei ist absichtlich in `.gitignore`, damit keine geheimen Schlüssel committed werden.
+48 -11
View File
@@ -1,16 +1,33 @@
# Nouri # Nouri
Nouri ist eine kleine private Flask-App fuer einen Haushalt, um Essensideen, Einkaeufe, vorhandene Lebensmittel und 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.1 ## Merkmale in Version 0.5
- Lebensmittel und Mahlzeitenideen anlegen - Lebensmittel und Mahlzeitenideen anlegen
- Fotos lokal hochladen - Fotos lokal hochladen
- Einkaufsliste mit Abhaken - Einkaufsliste mit Abhaken
- "Zuhause" als sichtbarer Vorrat - Zuhause als sichtbarer Vorrat mit Tageszeit-Filtern
- Archiv zum spaeteren Wiederverwenden - Archiv mit Suche und schneller Wiederaufnahme
- Tages- und Wochenplanung nach Tageszeiten - Tagesplan mit schnellen Vorschlägen je Tageszeit
- einfache Benutzeranmeldung fuer einen Haushalt - Wochenansicht für die nächsten 7 Tage
- einfache Suche und Filter für Lebensmittel und Mahlzeitenideen
- 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
- ruhige 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
- Einkaufsrhythmus mit geplantem Einkaufstag und später aktivierten Bedarfen
- ausgewogene Ergänzungsvorschläge auf Basis ruhiger Bausteine
- einfache Kombinations- und Rezeptideen aus zuhause vorhandenen Lebensmitteln
- Optionen für Erinnerungen, Hinweise und kleine Routinen
- PWA-Vorbereitung mit Web App Manifest, Service Worker und optionalem Web Push
## Lokal starten ## Lokal starten
@@ -21,18 +38,38 @@ pip install -r requirements.txt
flask --app wsgi run --debug flask --app wsgi run --debug
``` ```
Dann `http://127.0.0.1:5000` oeffnen und beim ersten Start einen ersten Haushalt-Benutzer unter `/setup` anlegen. Dann `http://127.0.0.1:5000` öffnen und beim ersten Start einen ersten Haushalt-Benutzer unter `/auth/setup` anlegen.
## Konfiguration ## Konfiguration
Die App legt Daten standardmaessig unter `./data` ab. Die App legt Daten standardmäßig unter `./data` ab.
Wichtige Umgebungsvariablen: Wichtige Umgebungsvariablen:
- `NOURI_SECRET_KEY`: Session-Secret fuer Produktion - `NOURI_SECRET_KEY`: Session-Secret für Produktion
- `NOURI_DATA_DIR`: Pfad fuer 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`
- `NOURI_VAPID_PUBLIC_KEY`: öffentlicher VAPID-Schlüssel für Web Push
- `NOURI_VAPID_PRIVATE_KEY`: privater VAPID-Schlüssel für Web Push
- `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io`
## Migration von 0.4 auf 0.5
Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite weiter: Einkaufsrhythmus, vorgemerkte Bedarfe, Nutzer-Einstellungen, Push-Registrierungen und Baustein-Zuordnungen für Kategorien werden ergänzt. Vorhandene 0.4-Daten bleiben erhalten und werden weiterverwendet.
## Cloudron-Hinweis ## Cloudron-Hinweis
Fuer Cloudron spaeter `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.
## Lizenz
Nouri ist in diesem Repository für private, nicht-kommerzielle Nutzung freigegeben. Details stehen in [LICENSE.md](LICENSE.md).
+47
View File
@@ -0,0 +1,47 @@
# Nouri 0.5.0
## Highlights
- Einkaufsrhythmus mit geplantem Einkaufstag und ruhiger Vorlauf-Logik
- Optionen für Erinnerungen, Hinweise und kleine Routinen
- Ausgewogene Ergänzungsvorschläge ohne Diät- oder Kontrollsprache
- Einfache Rezeptideen aus zuhause vorhandenen Lebensmitteln
- PWA-Vorbereitung mit Home-Screen-Nutzung, Service Worker und optionalem Web Push
- Überarbeitetes mobiles Mehr-Menü als Karten-Sheet mit Icons
## Neu in 0.5.0
### Planung und Vorschläge
- Der Tagesplan priorisiert jetzt vorhandene Mahlzeitenideen, dann passende Kombinationsvorschläge und danach einzelne Lebensmittel.
- Für Mittag- und Abendessen zeigt Nouri kleine Ergänzungsideen, wenn noch etwas gut dazupassen könnte.
- Zuhause vorhandene Lebensmittel werden zu einfachen Frühstücks- und Hauptmahlzeit-Ideen kombiniert.
### Einkauf
- Fehlende Lebensmittel aus zukünftigen Planungen landen nicht mehr sofort auf der Einkaufsliste.
- Stattdessen merkt Nouri sie zunächst für spätere Einkäufe vor und aktiviert sie passend zum eingestellten Einkaufstag.
- Auch bei Mahlzeiten werden nur die tatsächlich fehlenden Lebensmittel auf den Einkauf bezogen, nicht die Mahlzeit selbst.
### Einstellungen
- Neuer Bereich `Optionen` für Einkaufstag, Vorlauf, Hinweise, Routinen und Push.
- Hinweise lassen sich pro Person fein, aber weiterhin niedrigschwellig steuern.
- Push kann optional je Gerät aktiviert oder wieder beendet werden.
### Mobile und PWA
- Web App Manifest und Service Worker sind ergänzt.
- Nouri lässt sich besser auf dem iPhone zum Home-Bildschirm hinzufügen.
- Das mobile Mehr-Menü öffnet sich als kompaktes Karten-Sheet direkt über der Bottom Navigation.
### Haushalt und Kategorien
- Kategorien können jetzt zusätzlich einem ruhigen Baustein zugeordnet werden, zum Beispiel Proteinquelle, Gemüse oder Obst.
- Diese Zuordnung hilft nur intern bei Vorschlägen und fühlt sich nicht wie Tracking an.
## Technische Hinweise
- Neue SQLite-Tabellen und Spalten werden beim Start automatisch ergänzt.
- Für Web Push werden VAPID-Schlüssel über Umgebungsvariablen unterstützt.
- Cloudron-Version wurde auf `0.5.0` angehoben.
+72 -4
View File
@@ -2,16 +2,53 @@ from __future__ import annotations
import os import os
import secrets import secrets
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 (
BUILDER_DESCRIPTIONS,
BUILDER_LABELS,
BUILDER_OPTIONS,
DAYPARTS,
DEFAULT_CATEGORIES,
ITEM_KIND_LABELS,
ITEM_KIND_SINGULAR_LABELS,
NOTIFICATION_CHANNEL_OPTIONS,
ROLE_LABELS,
VISIBILITY_DESCRIPTIONS,
VISIBILITY_LABELS,
WEEKDAY_OPTIONS,
)
from .main import main_bp from .main import main_bp
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
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()
@@ -23,17 +60,25 @@ 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),
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
APP_VERSION="0.5.0",
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"),
) )
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
@@ -41,12 +86,35 @@ 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,
"builder_labels": BUILDER_LABELS,
"builder_descriptions": BUILDER_DESCRIPTIONS,
"builder_options": BUILDER_OPTIONS,
"daypart_suggestions": DAYPARTS, "daypart_suggestions": DAYPARTS,
"visibility_labels": VISIBILITY_LABELS,
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
"role_labels": ROLE_LABELS,
"weekday_options": WEEKDAY_OPTIONS,
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
"today": date.today(),
"app_version": app.config["APP_VERSION"],
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
"weekday_name": lambda value: WEEKDAY_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>")
def uploaded_file(filename: str): def uploaded_file(filename: str):
return send_from_directory(app.config["UPLOAD_FOLDER"], filename) return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
@app.get("/app.webmanifest")
def webmanifest():
return send_from_directory(root_dir / "nouri" / "static" / "pwa", "app.webmanifest", mimetype="application/manifest+json")
@app.get("/service-worker.js")
def service_worker():
return send_from_directory(root_dir / "nouri" / "static" / "pwa", "service-worker.js", mimetype="application/javascript")
return app return app
+291
View File
@@ -0,0 +1,291 @@
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 BUILDER_DESCRIPTIONS, BUILDER_OPTIONS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS, 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()
builder_key = request.form.get("builder_key", "neutral").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, builder_key = ? WHERE id = ?",
(builder_key, 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, builder_key, sort_order, is_active)
VALUES (?, ?, ?, ?, 1)
""",
(g.user["household_id"], name, builder_key, 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,
default_category_builders=DEFAULT_CATEGORY_BUILDERS,
builder_options=BUILDER_OPTIONS,
builder_descriptions=BUILDER_DESCRIPTIONS,
)
@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"))
@admin_bp.post("/categories/<int:category_id>/update")
@admin_required
def category_update(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"))
builder_key = request.form.get("builder_key", "neutral").strip()
get_db().execute(
"UPDATE household_categories SET builder_key = ? WHERE id = ?",
(builder_key, category_id),
)
get_db().commit()
flash("Die Zuordnung wurde aktualisiert.", "success")
return redirect(url_for("admin.category_settings"))
+185 -19
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import functools import functools
import secrets
from flask import ( from flask import (
Blueprint, Blueprint,
@@ -15,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")
@@ -31,19 +33,59 @@ 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:
token = session["_csrf_token"] = __import__("secrets").token_hex(24) token = session["_csrf_token"] = secrets.token_hex(24)
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 {
"csrf_input": lambda: Markup( "csrf_input": lambda: Markup(
f'<input type="hidden" name="csrf_token" value="{ensure_csrf_token()}">' f'<input type="hidden" name="csrf_token" value="{ensure_csrf_token()}">'
) ),
"csrf_token_value": ensure_csrf_token(),
} }
@@ -54,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"}:
@@ -76,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 Passwoerter stimmen nicht ueberein." 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")
@@ -113,20 +169,29 @@ 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"
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()
session.permanent = remember_me
session["user_id"] = user["id"] session["user_id"] = user["id"]
ensure_csrf_token() ensure_csrf_token()
return redirect(url_for("main.dashboard")) return redirect(url_for("main.dashboard"))
@@ -136,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
+95 -6
View File
@@ -1,25 +1,76 @@
DAYPARTS = [ DAYPARTS = [
{"slug": "breakfast", "name": "Fruehstueck", "sort_order": 10}, {"slug": "breakfast", "name": "Frühstück", "sort_order": 10},
{"slug": "morning-snack", "name": "Vormittagssnack", "sort_order": 20}, {"slug": "morning-snack", "name": "Vormittagssnack", "sort_order": 20},
{"slug": "lunch", "name": "Mittagessen", "sort_order": 30}, {"slug": "lunch", "name": "Mittagessen", "sort_order": 30},
{"slug": "afternoon-snack", "name": "Nachmittagssnack", "sort_order": 40}, {"slug": "afternoon-snack", "name": "Nachmittagssnack", "sort_order": 40},
{"slug": "dinner", "name": "Abendessen", "sort_order": 50}, {"slug": "dinner", "name": "Abendessen", "sort_order": 50},
{"slug": "late-snack", "name": "Spaeter 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",
"Gemuese", "Gemüse",
"Eiweissquelle", "Eiweißquelle",
"Snack", "Snack",
"Getraenk", "Getränk",
"Vorrat & Basics", "Vorrat & Basics",
"Warmes", "Warmes",
"Kleines Essen", "Kleines Essen",
] ]
DEFAULT_CATEGORY_BUILDERS = {
"Brot & Getreide": "carb",
"Milchprodukt": "dairy",
"Obst": "fruit",
"Gemüse": "veg",
"Eiweißquelle": "protein",
"Snack": "neutral",
"Getränk": "neutral",
"Vorrat & Basics": "neutral",
"Warmes": "carb",
"Kleines Essen": "neutral",
}
BUILDER_LABELS = {
"protein": "Proteinquelle",
"carb": "Kohlenhydratquelle",
"veg": "Gemüse / Ballaststoffquelle",
"nuts": "Nüsse / Samen",
"fruit": "Obst",
"dairy": "Milchprodukt",
"neutral": "Neutral / sonstiges",
}
BUILDER_DESCRIPTIONS = {
"protein": "Passt eher zu sättigenden Eiweißquellen.",
"carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.",
"veg": "Passt eher zu Gemüse oder ballaststoffreichen Begleitern.",
"nuts": "Passt eher zu Nüssen oder Samen.",
"fruit": "Passt eher zu Obst.",
"dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.",
"neutral": "Ohne feste Zuordnung, aber weiterhin gut nutzbar.",
}
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
WEEKDAY_OPTIONS = [
(0, "Montag"),
(1, "Dienstag"),
(2, "Mittwoch"),
(3, "Donnerstag"),
(4, "Freitag"),
(5, "Samstag"),
(6, "Sonntag"),
]
NOTIFICATION_CHANNEL_OPTIONS = [
("in_app", "Nur in der App"),
("push", "Nur Push"),
("both", "App und Push"),
]
ITEM_KIND_LABELS = { ITEM_KIND_LABELS = {
"food": "Lebensmittel", "food": "Lebensmittel",
"meal": "Mahlzeitenideen", "meal": "Mahlzeitenideen",
@@ -35,3 +86,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",
]
+371 -23
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, DEFAULT_CATEGORY_BUILDERS
def get_db() -> sqlite3.Connection: def get_db() -> sqlite3.Connection:
@@ -23,20 +23,349 @@ def get_db() -> sqlite3.Connection:
def close_db(_error=None) -> None: def close_db(_error=None) -> None:
db = g.pop("db", None) database = g.pop("db", None)
if db is not None: if database is not None:
db.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,
shopping_weekday INTEGER NOT NULL DEFAULT 5,
shopping_prep_days INTEGER NOT NULL DEFAULT 1,
shopping_reminder_time TEXT NOT NULL DEFAULT '18:00',
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,
builder_key TEXT NOT NULL DEFAULT 'neutral',
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, "households"):
add_column_if_missing(database, "households", "shopping_weekday INTEGER NOT NULL DEFAULT 5")
add_column_if_missing(database, "households", "shopping_prep_days INTEGER NOT NULL DEFAULT 1")
add_column_if_missing(database, "households", "shopping_reminder_time TEXT NOT NULL DEFAULT '18:00'")
if table_exists(database, "household_categories"):
add_column_if_missing(database, "household_categories", "builder_key TEXT NOT NULL DEFAULT 'neutral'")
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, "shopping_needs"):
add_column_if_missing(database, "shopping_needs", "source_item_id INTEGER")
add_column_if_missing(database, "shopping_needs", "activation_date TEXT")
add_column_if_missing(database, "shopping_needs", "is_activated INTEGER NOT NULL DEFAULT 0")
add_column_if_missing(database, "shopping_needs", "activated_at TEXT")
database.execute(
"""
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER PRIMARY KEY,
reminders_enabled INTEGER NOT NULL DEFAULT 1,
push_enabled INTEGER NOT NULL DEFAULT 0,
notification_channel TEXT NOT NULL DEFAULT 'in_app',
remind_before_shopping INTEGER NOT NULL DEFAULT 1,
remind_on_shopping_day INTEGER NOT NULL DEFAULT 1,
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
remind_week_if_sparse INTEGER NOT NULL DEFAULT 1,
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
remind_small_snack INTEGER NOT NULL DEFAULT 0,
remind_nuts INTEGER NOT NULL DEFAULT 0,
show_meal_balancing INTEGER NOT NULL DEFAULT 1,
suggest_templates INTEGER NOT NULL DEFAULT 1,
suggest_patterns INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
database.execute(
"""
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_test_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
"""
)
database.execute(
"""
CREATE TABLE IF NOT EXISTS shopping_needs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
item_id INTEGER NOT NULL,
source_item_id INTEGER,
needed_for_date TEXT NOT NULL,
needed_for_daypart_id INTEGER,
activation_date TEXT NOT NULL,
is_activated INTEGER NOT NULL DEFAULT 0,
activated_at TEXT,
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (item_id, source_item_id, needed_for_date, needed_for_daypart_id, visibility),
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 (source_item_id) REFERENCES items(id) ON DELETE SET NULL,
FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
)
"""
)
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, builder_key, sort_order, is_active)
VALUES (?, ?, ?, ?, 1)
""",
(household_id, name, DEFAULT_CATEGORY_BUILDERS.get(name, "neutral"), sort_order),
)
database.execute(
"""
UPDATE household_categories
SET builder_key = COALESCE(NULLIF(builder_key, ''), ?)
WHERE household_id = ? AND name = ?
""",
(DEFAULT_CATEGORY_BUILDERS.get(name, "neutral"), household_id, name),
)
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 households SET shopping_weekday = COALESCE(shopping_weekday, 5)")
database.execute("UPDATE households SET shopping_prep_days = COALESCE(shopping_prep_days, 1)")
database.execute(
"UPDATE households SET shopping_reminder_time = COALESCE(NULLIF(shopping_reminder_time, ''), '18:00')"
)
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(
"""
INSERT OR IGNORE INTO user_settings (user_id)
SELECT id FROM users
"""
)
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)
"""
)
database.execute(
"""
CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation
ON shopping_needs (household_id, activation_date, is_activated)
"""
)
def apply_schema(database: sqlite3.Connection) -> None:
bootstrap_legacy_schema(database)
schema_path = Path(__file__).with_name("schema.sql")
database.executescript(schema_path.read_text(encoding="utf-8"))
ensure_schema_upgrades(database)
sync_dayparts(database)
def init_db() -> None: def init_db() -> None:
database = get_db() database = get_db()
schema_path = Path(__file__).with_name("schema.sql") apply_schema(database)
database.executescript(schema_path.read_text(encoding="utf-8"))
seed_dayparts(database)
database.commit() database.commit()
def seed_dayparts(database: sqlite3.Connection) -> None: def sync_dayparts(database: sqlite3.Connection) -> None:
for entry in DAYPARTS: for entry in DAYPARTS:
database.execute( database.execute(
""" """
@@ -45,21 +374,18 @@ def seed_dayparts(database: sqlite3.Connection) -> None:
""", """,
(entry["slug"], entry["name"], entry["sort_order"]), (entry["slug"], entry["name"], entry["sort_order"]),
) )
database.execute(
"""
UPDATE dayparts
SET name = ?, sort_order = ?
WHERE slug = ?
""",
(entry["name"], entry["sort_order"], entry["slug"]),
)
def init_db_if_needed(app: Flask) -> None: def init_db_if_needed(app: Flask) -> None:
db_path = Path(app.config["DATABASE_PATH"])
needs_init = not db_path.exists()
with app.app_context(): with app.app_context():
if needs_init:
init_db()
return
database = get_db()
table = database.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users'"
).fetchone()
if table is None:
init_db() init_db()
@@ -68,6 +394,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:
@@ -79,15 +417,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.")
+2826 -322
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import json
from typing import Any
from flask import current_app
def push_is_configured() -> bool:
return bool(
current_app.config.get("VAPID_PUBLIC_KEY")
and current_app.config.get("VAPID_PRIVATE_KEY")
and current_app.config.get("VAPID_SUBJECT")
)
def push_public_key() -> str | None:
return current_app.config.get("VAPID_PUBLIC_KEY") or None
def send_push_message(subscription: dict[str, Any], *, title: str, body: str, url: str) -> tuple[bool, str | None]:
if not push_is_configured():
return False, "Push ist noch nicht konfiguriert."
try:
from pywebpush import WebPushException, webpush
except Exception:
return False, "Die Push-Bibliothek ist noch nicht installiert."
payload = json.dumps(
{
"title": title,
"body": body,
"url": url,
"icon": "/static/brand/pwa-192.png",
"badge": "/static/brand/pwa-badge.png",
}
)
try:
webpush(
subscription_info=subscription,
data=payload,
vapid_private_key=current_app.config["VAPID_PRIVATE_KEY"],
vapid_claims={"sub": current_app.config["VAPID_SUBJECT"]},
)
except WebPushException as exc: # pragma: no cover - depends on live push endpoint
return False, str(exc)
return True, None
+217 -2
View File
@@ -1,11 +1,77 @@
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS households (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
shopping_weekday INTEGER NOT NULL DEFAULT 5,
shopping_prep_days INTEGER NOT NULL DEFAULT 1,
shopping_reminder_time TEXT NOT NULL DEFAULT '18:00',
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,
builder_key TEXT NOT NULL DEFAULT 'neutral',
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 user_settings (
user_id INTEGER PRIMARY KEY,
reminders_enabled INTEGER NOT NULL DEFAULT 1,
push_enabled INTEGER NOT NULL DEFAULT 0,
notification_channel TEXT NOT NULL DEFAULT 'in_app',
remind_before_shopping INTEGER NOT NULL DEFAULT 1,
remind_on_shopping_day INTEGER NOT NULL DEFAULT 1,
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
remind_week_if_sparse INTEGER NOT NULL DEFAULT 1,
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
remind_small_snack INTEGER NOT NULL DEFAULT 0,
remind_nuts INTEGER NOT NULL DEFAULT 0,
show_meal_balancing INTEGER NOT NULL DEFAULT 1,
suggest_templates INTEGER NOT NULL DEFAULT 1,
suggest_patterns INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
user_agent TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_test_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS dayparts ( CREATE TABLE IF NOT EXISTS dayparts (
@@ -17,6 +83,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 +97,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,30 +122,172 @@ 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
ON shopping_entries (item_id) ON shopping_entries (item_id)
WHERE is_checked = 0; WHERE is_checked = 0;
CREATE TABLE IF NOT EXISTS shopping_needs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
household_id INTEGER NOT NULL,
owner_user_id INTEGER,
visibility TEXT NOT NULL DEFAULT 'shared',
item_id INTEGER NOT NULL,
source_item_id INTEGER,
needed_for_date TEXT NOT NULL,
needed_for_daypart_id INTEGER,
activation_date TEXT NOT NULL,
is_activated INTEGER NOT NULL DEFAULT 0,
activated_at TEXT,
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (item_id, source_item_id, needed_for_date, needed_for_daypart_id, visibility),
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 (source_item_id) REFERENCES items(id) ON DELETE SET NULL,
FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
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
ON items (kind, name);
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
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
ON item_dayparts (daypart_id, item_id);
CREATE INDEX IF NOT EXISTS idx_plan_entries_plan_date_daypart
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_shopping_needs_household_activation
ON shopping_needs (household_id, activation_date, is_activated);
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);
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffd7be"/>
<stop offset="100%" stop-color="#e39a63"/>
</linearGradient>
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#b5dfc8"/>
<stop offset="100%" stop-color="#72a98b"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="18" fill="url(#bg)"/>
<path d="M16 34c0-3 2.4-5.4 5.4-5.4h21.2c3 0 5.4 2.4 5.4 5.4 0 9.2-7.4 16.6-16.6 16.6h-.8C23.4 50.6 16 43.2 16 34z" fill="#fff9f4"/>
<path d="M21 25c2.4-6.7 7.2-10.6 11-10.6S40.6 18.3 43 25" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="4"/>
<path d="M40 12c5 .4 9 5 9 10.1 0 .5 0 1-.1 1.5-.8-.7-1.8-1.2-2.9-1.5-2.7-.8-4.3-3.3-4.2-6.1 0-1.3-.6-2.6-1.8-4z" fill="url(#leaf)"/>
</svg>

After

Width:  |  Height:  |  Size: 914 B

+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title">
<title>Nouri</title>
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#ffd7be"/>
<stop offset="55%" stop-color="#f5b17a"/>
<stop offset="100%" stop-color="#d58c57"/>
</linearGradient>
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#b5dfc8"/>
<stop offset="100%" stop-color="#70aa87"/>
</linearGradient>
</defs>
<rect width="256" height="256" rx="64" fill="url(#bg)"/>
<circle cx="128" cy="128" r="96" fill="rgba(255,255,255,0.16)"/>
<path d="M68 132c0-9.9 8.1-18 18-18h84c9.9 0 18 8.1 18 18 0 31.8-25.8 57.6-57.6 57.6h-4.8C93.8 189.6 68 163.8 68 132z" fill="#fff9f4"/>
<path d="M84 105c7-21.3 24-34 44-34s37 12.7 44 34" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="14"/>
<path d="M156 55c15 1 27 14.7 27 30.6 0 1.6-.1 3.1-.3 4.6-1.9-2.4-4.5-4.2-7.5-5.1-8-2.5-13.1-10.2-12.7-18.5.1-4.1-1.8-8-5.2-11.6z" fill="url(#leaf)"/>
<path d="M129 143h41" stroke="#f0a46c" stroke-linecap="round" stroke-width="10"/>
<path d="M92 96l-10 62" stroke="#fff9f4" stroke-linecap="round" stroke-width="10"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M224 112c-8.8 0-16-7.2-16-16l0-16c0-44.2 35.8-80 80-80l16 0c8.8 0 16 7.2 16 16l0 16c0 44.2-35.8 80-80 80l-16 0zM0 288c0-76.3 35.7-160 112-160 27.3 0 59.7 10.3 82.7 19.3 18.8 7.3 39.9 7.3 58.7 0 22.9-8.9 55.4-19.3 82.7-19.3 76.3 0 112 83.7 112 160 0 128-80 224-160 224-16.5 0-38.1-6.6-51.5-11.3-8.1-2.8-16.9-2.8-25 0-13.4 4.7-35 11.3-51.5 11.3-80 0-160-96-160-224z"/></svg>

After

Width:  |  Height:  |  Size: 631 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M112.2 272.6C120.4 277.3 129.9 280 140 280L532 280C542.1 280 551.6 277.3 559.8 272.6C560 275 560 277.5 560 280L560 476C560 537.9 509.9 588 448 588L224 588C162.1 588 112 537.9 112 476L112 280C112 277.5 112.1 275.1 112.2 272.6zM118.6 242C134.1 198.8 175.5 168 224 168L448 168C496.5 168 537.9 198.9 553.4 242C548.3 248.1 540.6 252 532 252L140 252C131.4 252 123.7 248.1 118.6 242zM196 350C196 357.7 202.3 364 210 364L462 364C469.7 364 476 357.7 476 350C476 342.3 469.7 336 462 336L210 336C202.3 336 196 342.3 196 350z"/><path fill="currentColor" d="M140 112C124.5 112 112 124.5 112 140L112 224C112 239.5 124.5 252 140 252L532 252C547.5 252 560 239.5 560 224L560 140C560 124.5 547.5 112 532 112L140 112zM84 140C84 109.1 109.1 84 140 84L532 84C562.9 84 588 109.1 588 140L588 224C588 254.9 562.9 280 532 280L140 280C109.1 280 84 254.9 84 224L84 140zM210 336L462 336C469.7 336 476 342.3 476 350C476 357.7 469.7 364 462 364L210 364C202.3 364 196 357.7 196 350C196 342.3 202.3 336 210 336z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M224 0c-17.7 0-32 14.3-32 32l0 3.2C119 50 64 114.6 64 192l0 21.7c0 48.1-16.4 94.8-46.4 132.4L7.8 358.3C2.7 364.6 0 372.4 0 380.5 0 400.1 15.9 416 35.5 416l376.9 0c19.6 0 35.5-15.9 35.5-35.5 0-8.1-2.7-15.9-7.8-22.2l-9.8-12.2C400.4 308.5 384 261.8 384 213.7l0-21.7c0-77.4-55-142-128-156.8l0-3.2c0-17.7-14.3-32-32-32zM162 464c7.1 27.6 32.2 48 62 48s54.9-20.4 62-48l-124 0z"/></svg>

After

Width:  |  Height:  |  Size: 637 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M96.1 352L305.7 352L189.9 481.4C186.5 475.9 181.5 471.6 175.5 468.9C130 449 97.9 404.3 96.1 352z"/><path fill="currentColor" d="M267.8 146.2C277.9 125.5 297.8 112 320 112C342.2 112 362.2 125.5 372.2 146.2C374.2 150.3 377.8 153.4 382.2 154.6C386.6 155.8 391.3 155.2 395.1 152.7C403.9 147.1 414 144 424.9 144C452.7 144 477 165.2 482.7 195C484.2 202.7 491 208.2 498.8 208C499.2 208 499.5 208 499.9 208C523.2 208 543.9 228.5 543.9 256C543.9 264.8 551.1 272 559.9 272C568.7 272 575.9 264.8 575.9 256C575.9 216.6 548.4 182.5 511 176.8C498.9 139.6 465.4 112 424.9 112C413.5 112 402.6 114.2 392.6 118.2C376.2 95.2 349.9 80 319.9 80C289.9 80 263.7 95.2 247.2 118.2C237.2 114.2 226.3 112 214.9 112C174.4 112 140.9 139.6 128.8 176.8C91.4 182.5 63.9 216.6 63.9 256C63.9 264.8 71.1 272 79.9 272C88.7 272 95.9 264.8 95.9 256C95.9 228.5 116.6 208 139.9 208C140.3 208 140.6 208 141 208C148.8 208.2 155.7 202.7 157.1 195C162.8 165.2 187.1 144 214.9 144C225.7 144 235.9 147.2 244.7 152.7C248.5 155.1 253.3 155.8 257.6 154.6C261.9 153.4 265.6 150.3 267.6 146.2zM193.3 489.1C190.6 480.1 184.1 472.7 175.5 468.9C130 449 97.9 404.3 96.1 352L543.9 352C542.1 404.3 510 449 464.5 468.9C455.9 472.7 449.3 480.1 446.7 489.1C442.8 502.3 430.5 512 416 512L224 512C209.5 512 197.3 502.4 193.3 489.1zM91.4 320C76.3 320 64 332.3 64 347.4C64 414.9 104.6 472.8 162.6 498.3C170.5 524.7 195 544 224 544L416 544C445 544 469.5 524.7 477.4 498.3C535.5 472.9 576 414.9 576 347.5C576 332.4 563.7 320.1 548.6 320.1L91.4 320z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M128 256L128 320L224 320L224 256L128 256zM128 352L128 416L224 416L224 352L128 352zM128 448L128 480C128 497.7 142.3 512 160 512L224 512L224 448L128 448zM256 256L256 320L384 320L384 256L256 256zM256 352L256 416L384 416L384 352L256 352zM256 448L256 512L384 512L384 448L256 448zM416 256L416 320L512 320L512 256L416 256zM416 352L416 416L512 416L512 352L416 352zM416 448L416 512L480 512C497.7 512 512 497.7 512 480L512 448L416 448z"/><path fill="currentColor" d="M208 64C216.8 64 224 71.2 224 80L224 128L416 128L416 80C416 71.2 423.2 64 432 64C440.8 64 448 71.2 448 80L448 128L480 128C515.3 128 544 156.7 544 192L544 480C544 515.3 515.3 544 480 544L160 544C124.7 544 96 515.3 96 480L96 192C96 156.7 124.7 128 160 128L192 128L192 80C192 71.2 199.2 64 208 64zM480 160L160 160C142.3 160 128 174.3 128 192L128 224L512 224L512 192C512 174.3 497.7 160 480 160zM512 256L416 256L416 320L512 320L512 256zM512 352L416 352L416 416L512 416L512 352zM512 448L416 448L416 512L480 512C497.7 512 512 497.7 512 480L512 448zM384 416L384 352L256 352L256 416L384 416zM256 448L256 512L384 512L384 448L256 448zM224 416L224 352L128 352L128 416L224 416zM128 448L128 480C128 497.7 142.3 512 160 512L224 512L224 448L128 448zM128 320L224 320L224 256L128 256L128 320zM256 320L384 320L384 256L256 256L256 320z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M84 224L84 252L588 252L588 224C588 162.1 537.9 112 476 112L469 112L469 140C469 147.7 462.7 154 455 154C447.3 154 441 147.7 441 140L441 112L224 112L224 140C224 147.7 217.7 154 210 154C202.3 154 196 147.7 196 140L196 112C134.1 112 84 162.1 84 224zM84 280L84 476C84 537.9 134.1 588 196 588L476 588C537.9 588 588 537.9 588 476L588 280L84 280z"/><path fill="currentColor" d="M210 56C202.3 56 196 62.3 196 70L196 140C196 147.7 202.3 154 210 154C217.7 154 224 147.7 224 140L224 70C224 62.3 217.7 56 210 56zM455 56C447.3 56 441 62.3 441 70L441 140C441 147.7 447.3 154 455 154C462.7 154 469 147.7 469 140L469 70C469 62.3 462.7 56 455 56zM84 252C76.3 252 70 258.3 70 266C70 273.7 76.3 280 84 280L588 280C595.7 280 602 273.7 602 266C602 258.3 595.7 252 588 252L84 252z"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M160.7 140.2L202.6 412.4C203 415 203.6 417.6 204.5 420L499.9 420C535.2 420 566 396 574.6 361.7L614.5 202C615.5 198 616 194 616 189.9C616 162.4 593.7 140 566.1 140L162.1 140C161.6 140 161.1 140.1 160.6 140.2z"/><path fill="currentColor" d="M56 98C56 90.3 62.3 84 70 84L110 84C134.2 84 154.8 101.6 158.4 125.5L202.5 412.3C205.7 432.8 223.3 447.9 244 447.9L546 447.9C553.7 447.9 560 454.2 560 461.9C560 469.6 553.7 475.9 546 475.9L244 475.9C209.4 475.9 180.1 450.7 174.8 416.5L130.7 129.8C129.2 119.6 120.3 112 110 112L70 112C62.3 112 56 105.7 56 98zM252 588C267.5 588 280 575.5 280 560C280 544.5 267.5 532 252 532C236.5 532 224 544.5 224 560C224 575.5 236.5 588 252 588zM252 504C282.9 504 308 529.1 308 560C308 590.9 282.9 616 252 616C221.1 616 196 590.9 196 560C196 529.1 221.1 504 252 504zM532 560C532 544.5 519.5 532 504 532C488.5 532 476 544.5 476 560C476 575.5 488.5 588 504 588C519.5 588 532 575.5 532 560zM448 560C448 529.1 473.1 504 504 504C534.9 504 560 529.1 560 560C560 590.9 534.9 616 504 616C473.1 616 448 590.9 448 560z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+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

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M241 87.1l15 20.7 15-20.7C296 52.5 336.2 32 378.9 32 452.4 32 512 91.6 512 165.1l0 2.6c0 112.2-139.9 242.5-212.9 298.2-12.4 9.4-27.6 14.1-43.1 14.1s-30.8-4.6-43.1-14.1C139.9 410.2 0 279.9 0 167.7l0-2.6C0 91.6 59.6 32 133.1 32 175.8 32 216 52.5 241 87.1z"/></svg>

After

Width:  |  Height:  |  Size: 521 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M84 303.5L336 101.9L588 303.5L588 476C588 537.9 537.9 588 476 588L448 588L448 434C448 395.3 416.7 364 378 364L294 364C255.3 364 224 395.3 224 434L224 588L196 588C134.1 588 84 537.9 84 476L84 303.5zM252 434C252 410.8 270.8 392 294 392L378 392C401.2 392 420 410.8 420 434L420 588L252 588L252 434z"/><path fill="currentColor" d="M344.7 73.1C339.6 69 332.3 69 327.2 73.1L47.2 297.1C41.2 301.9 40.2 310.7 45 316.8C49.8 322.9 58.6 323.8 64.7 319L336 102L607.3 319C613.3 323.8 622.1 322.9 627 316.8C631.9 310.7 630.9 302 624.8 297.1L344.8 73.1zM252 434C252 410.8 270.8 392 294 392L378 392C401.2 392 420 410.8 420 434L420 588C420 595.7 426.3 602 434 602C441.7 602 448 595.7 448 588L448 434C448 395.3 416.7 364 378 364L294 364C255.3 364 224 395.3 224 434L224 588C224 595.7 230.3 602 238 602C245.7 602 252 595.7 252 588L252 434z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+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
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M471.3 6.7C477.7 .6 487-1.6 495.6 1.2 505.4 4.5 512 13.7 512 24l0 186.9c0 131.2-108.1 237.1-238.8 237.1-77 0-143.4-49.5-167.5-118.7-35.4 30.8-57.7 76.1-57.7 126.7 0 13.3-10.7 24-24 24S0 469.3 0 456C0 381.1 38.2 315.1 96.1 276.3 131.4 252.7 173.5 240 216 240l80 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-80 0c-39.7 0-77.3 8.8-111 24.5 23.3-70 89.2-120.5 167-120.5 66.4 0 115.8-22.1 148.7-44 19.2-12.8 35.5-28.1 50.7-45.3z"/></svg>

After

Width:  |  Height:  |  Size: 685 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M16 64C16 28.7 44.7 0 80 0L304 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L80 512c-35.3 0-64-28.7-64-64L16 64zm64 0l0 304 224 0 0-304-224 0zM192 472c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32z"/></svg>

After

Width:  |  Height:  |  Size: 487 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M512 32C512 140.1 435.4 230.3 333.6 251.4 325.7 193.3 299.6 141 261.1 100.5 301.2 40 369.9 0 448 0l32 0c17.7 0 32 14.3 32 32zM0 96C0 78.3 14.3 64 32 64l32 0c123.7 0 224 100.3 224 224l0 192c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-160C100.3 320 0 219.7 0 96z"/></svg>

After

Width:  |  Height:  |  Size: 522 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M32 64C14.3 64 0 78.3 0 96s14.3 32 32 32l86.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 128c17.7 0 32-14.3 32-32s-14.3-32-32-32L265.3 64C253 35.7 224.8 16 192 16s-61 19.7-73.3 48L32 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l246.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48l54.7 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-54.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 224zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l54.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 448c17.7 0 32-14.3 32-32s-14.3-32-32-32l-246.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 384z"/></svg>

After

Width:  |  Height:  |  Size: 850 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M86.2 320L175.1 361.1C178.6 362.7 181.3 365.5 182.9 368.9L224 457.8L265.1 368.9C266.7 365.5 269.5 362.7 272.9 361.1L361.8 320L272.9 278.9C269.4 277.3 266.7 274.5 265.1 271.1L224 182.2L182.9 271.1C181.3 274.6 178.5 277.3 175.1 278.9L86.2 320z"/><path fill="currentColor" d="M512 48C520.8 48 528 55.2 528 64L528 112L576 112C584.8 112 592 119.2 592 128C592 136.8 584.8 144 576 144L528 144L528 192C528 200.8 520.8 208 512 208C503.2 208 496 200.8 496 192L496 144L448 144C439.2 144 432 136.8 432 128C432 119.2 439.2 112 448 112L496 112L496 64C496 55.2 503.2 48 512 48zM224 128C230.2 128 235.9 131.6 238.5 137.3L291.6 252.3L406.6 305.4C412.3 308 415.9 313.7 415.9 319.9C415.9 326.1 412.3 331.8 406.6 334.4L291.6 387.5L238.5 502.5C235.9 508.2 230.2 511.8 224 511.8C217.8 511.8 212.1 508.2 209.5 502.5L156.4 387.5L41.4 334.4C35.6 331.9 32 326.2 32 320C32 313.8 35.6 308.1 41.3 305.5L156.3 252.4L209.4 137.4C212 131.7 217.7 128.1 223.9 128.1zM224 182.2L182.9 271.1C181.3 274.6 178.5 277.3 175.1 278.9L86.2 320L175.1 361.1C178.6 362.7 181.3 365.5 182.9 368.9L224 457.8L265.1 368.9C266.7 365.5 269.5 362.7 272.9 361.1L361.8 320L272.9 278.9C269.4 277.3 266.7 274.5 265.1 271.1L224 182.2zM496 448L496 496L544 496C552.8 496 560 503.2 560 512C560 520.8 552.8 528 544 528L496 528L496 576C496 584.8 488.8 592 480 592C471.2 592 464 584.8 464 576L464 528L416 528C407.2 528 400 520.8 400 512C400 503.2 407.2 496 416 496L464 496L464 448C464 439.2 471.2 432 480 432C488.8 432 496 439.2 496 448z"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M112 112L112 252C112 313.9 162.1 364 224 364L224 350C224 342.3 230.3 336 238 336C245.7 336 252 342.3 252 350L252 364C313.9 364 364 313.9 364 252L364 112C364 81.1 338.9 56 308 56L294 56L294 238C294 245.7 287.7 252 280 252C272.3 252 266 245.7 266 238L266 56L210 56L210 238C210 245.7 203.7 252 196 252C188.3 252 182 245.7 182 238L182 56L168 56C137.1 56 112 81.1 112 112zM420 182L420 392C420 422.9 445.1 448 476 448L532 448L532 70C532 62.4 538.1 56.2 545.7 56C476.3 56.2 420 112.5 420 182z"/><path fill="currentColor" d="M210 56C210 48.3 203.7 42 196 42C188.3 42 182 48.3 182 56L182 238C182 245.7 188.3 252 196 252C203.7 252 210 245.7 210 238L210 56zM294 56C294 48.3 287.7 42 280 42C272.3 42 266 48.3 266 56L266 238C266 245.7 272.3 252 280 252C287.7 252 294 245.7 294 238L294 56zM560 70C560 62.3 553.7 56 546 56C538.3 56 532 62.3 532 70L532 602C532 609.7 538.3 616 546 616C553.7 616 560 609.7 560 602L560 70zM238 336C230.3 336 224 342.3 224 350L224 602C224 609.7 230.3 616 238 616C245.7 616 252 609.7 252 602L252 350C252 342.3 245.7 336 238 336z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+81
View File
@@ -0,0 +1,81 @@
(() => {
const getCsrfToken = () => {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute("content") : "";
};
const initWeekDragAndDrop = () => {
const board = document.querySelector(".week-board");
if (!board) return;
let draggedEntry = null;
board.querySelectorAll(".draggable-plan-entry").forEach((entry) => {
if (entry.getAttribute("draggable") !== "true") return;
entry.addEventListener("dragstart", () => {
draggedEntry = entry;
entry.classList.add("is-dragging");
});
entry.addEventListener("dragend", () => {
entry.classList.remove("is-dragging");
draggedEntry = null;
board.querySelectorAll(".drop-slot").forEach((slot) => slot.classList.remove("is-drag-over"));
});
});
board.querySelectorAll(".drop-slot").forEach((slot) => {
slot.addEventListener("dragover", (event) => {
event.preventDefault();
if (!draggedEntry) return;
slot.classList.add("is-drag-over");
});
slot.addEventListener("dragleave", () => {
slot.classList.remove("is-drag-over");
});
slot.addEventListener("drop", async (event) => {
event.preventDefault();
slot.classList.remove("is-drag-over");
if (!draggedEntry) return;
// Keep DnD lightweight: move on the server, then refresh into the canonical rendered state.
const moveUrl = draggedEntry.dataset.moveUrl;
const payload = new URLSearchParams({
csrf_token: getCsrfToken(),
target_date: slot.dataset.targetDate,
target_daypart_id: slot.dataset.targetDaypartId,
});
try {
const response = await fetch(moveUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
},
body: payload.toString(),
});
if (!response.ok) {
throw new Error("move failed");
}
const result = await response.json();
if (result.redirect_url) {
window.location.href = result.redirect_url;
} else {
window.location.reload();
}
} catch (_error) {
window.location.reload();
}
});
});
};
document.addEventListener("DOMContentLoaded", () => {
initWeekDragAndDrop();
});
})();
+96
View File
@@ -0,0 +1,96 @@
(() => {
const getCsrfToken = () => {
const meta = document.querySelector('meta[name="csrf-token"]');
return meta ? meta.getAttribute("content") : "";
};
const getPushPublicKey = () => {
const meta = document.querySelector('meta[name="nouri-push-public-key"]');
return meta ? meta.getAttribute("content") : "";
};
const urlBase64ToUint8Array = (base64String) => {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((character) => character.charCodeAt(0)));
};
const registerServiceWorker = async () => {
if (!("serviceWorker" in navigator)) return null;
return navigator.serviceWorker.register("/service-worker.js");
};
const subscribeToPush = async () => {
const publicKey = getPushPublicKey();
if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) return;
const registration = await navigator.serviceWorker.ready;
const permission = await Notification.requestPermission();
if (permission !== "granted") return;
const existing = await registration.pushManager.getSubscription();
const subscription = existing || await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
const subscriptionJson = subscription.toJSON();
const payload = new URLSearchParams({
csrf_token: getCsrfToken(),
endpoint: subscription.endpoint,
p256dh: subscriptionJson.keys && subscriptionJson.keys.p256dh ? subscriptionJson.keys.p256dh : "",
auth: subscriptionJson.keys && subscriptionJson.keys.auth ? subscriptionJson.keys.auth : "",
});
await fetch("/push/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
},
body: payload.toString(),
});
window.location.reload();
};
const unsubscribeFromPush = async () => {
if (!("serviceWorker" in navigator)) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
const payload = new URLSearchParams({ csrf_token: getCsrfToken() });
if (subscription) {
payload.set("endpoint", subscription.endpoint);
await subscription.unsubscribe();
}
await fetch("/push/unsubscribe", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
},
body: payload.toString(),
});
window.location.reload();
};
document.addEventListener("DOMContentLoaded", () => {
registerServiceWorker();
const enableButton = document.querySelector("[data-push-enable]");
const disableButton = document.querySelector("[data-push-disable]");
if (enableButton) {
enableButton.addEventListener("click", () => {
subscribeToPush().catch(() => {
window.location.reload();
});
});
}
if (disableButton) {
disableButton.addEventListener("click", () => {
unsubscribeFromPush().catch(() => {
window.location.reload();
});
});
}
});
})();
+5 -7
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,17 +11,14 @@
: 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", () => { button.addEventListener("click", () => {
const current = root.dataset.theme === "dark" ? "dark" : "light"; const current = root.dataset.theme === "dark" ? "dark" : "light";
const next = current === "dark" ? "light" : "dark"; const next = current === "dark" ? "light" : "dark";
@@ -29,4 +26,5 @@
applyTheme(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();
});
})();
+23
View File
@@ -0,0 +1,23 @@
{
"name": "Nouri",
"short_name": "Nouri",
"description": "einfach essen planen",
"lang": "de",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#fff6ef",
"theme_color": "#efab72",
"icons": [
{
"src": "/static/brand/pwa-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/brand/pwa-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+96
View File
@@ -0,0 +1,96 @@
const CACHE_NAME = "nouri-v0-5-1";
const STATIC_ASSETS = [
"/static/css/styles.css",
"/static/js/theme.js",
"/static/js/ui.js",
"/static/js/planner.js",
"/static/js/pwa.js",
"/static/brand/pwa-192.png",
"/static/brand/pwa-512.png",
"/static/brand/favicon.svg",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
).then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const requestUrl = new URL(event.request.url);
const isStaticAsset =
requestUrl.origin === self.location.origin &&
(
requestUrl.pathname.startsWith("/static/")
|| requestUrl.pathname === "/app.webmanifest"
|| requestUrl.pathname === "/service-worker.js"
);
if (event.request.mode === "navigate") {
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
return;
}
if (!isStaticAsset) {
return;
}
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) {
return cached;
}
return fetch(event.request).then((response) => {
if (!response || response.status !== 200 || response.type !== "basic") {
return response;
}
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
});
})
);
});
self.addEventListener("push", (event) => {
if (!event.data) return;
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title || "Nouri", {
body: data.body || "",
icon: data.icon || "/static/brand/pwa-192.png",
badge: data.badge || "/static/brand/pwa-badge.png",
data: { url: data.url || "/" },
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const targetUrl = event.notification.data && event.notification.data.url ? event.notification.data.url : "/";
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
for (const client of clientList) {
if (client.url.includes(targetUrl) && "focus" in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(targetUrl);
}
return null;
})
);
});
+73
View File
@@ -0,0 +1,73 @@
{% 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>
<label>
Passt eher zu
<select name="builder_key">
{% for value, label in builder_options %}
<option value="{{ value }}">{{ label }}</option>
{% endfor %}
</select>
</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 %}
· {{ builder_descriptions[category.builder_key] }}
</p>
<div class="chip-row">
<span class="chip">{{ builder_descriptions[category.builder_key].split('.')[0] }}</span>
{% 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_update', category_id=category.id) }}" class="inline-form inline-form-tight">
{{ csrf_input() }}
<label>
<span class="sr-only">Baustein</span>
<select name="builder_key">
{% for value, label in builder_options %}
<option value="{{ value }}" {% if category.builder_key == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<button class="ghost-button" type="submit">Zuordnung speichern</button>
</form>
<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 %}
+41 -4
View File
@@ -4,11 +4,40 @@
<section class="page-intro"> <section class="page-intro">
<div> <div>
<p class="eyebrow">Archiv</p> <p class="eyebrow">Archiv</p>
<h1>Fruehere 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>
<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>
Bereich
<select name="kind">
{% for value, label in kind_options %}
<option value="{{ value }}" {% if selected_kind == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</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.archive_view') }}">Zurücksetzen</a>
</div>
</form>
</section>
{% if items %} {% if items %}
<section class="card-grid"> <section class="card-grid">
{% for item in items %} {% for item in items %}
@@ -22,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">
@@ -42,18 +76,21 @@
{{ csrf_input() }} {{ csrf_input() }}
<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>
{% if item.can_edit %}
<form method="post" action="{{ url_for('main.item_restore', item_id=item.id) }}"> <form method="post" action="{{ url_for('main.item_restore', item_id=item.id) }}">
{{ csrf_input() }} {{ csrf_input() }}
<button class="ghost-button" type="submit">Zur aktiven Liste</button> <button class="ghost-button" type="submit">Zur aktiven Liste</button>
</form> </form>
{% endif %}
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
</section> </section>
{% else %} {% else %}
<section class="panel empty-panel"> <section class="panel empty-panel">
<h2>Das Archiv ist noch leer</h2> <h2>Keine passenden Archiv-Einträge</h2>
<p>Sobald etwas als verbraucht markiert wird, bleibt es hier als spaetere Erinnerung erhalten.</p> <p>Mit einer kurzen Suche findest du vertraute Dinge meist schnell wieder.</p>
</section> </section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+7 -3
View File
@@ -3,20 +3,24 @@
{% block content %} {% block content %}
<section class="auth-shell"> <section class="auth-shell">
<div class="auth-card"> <div class="auth-card">
<p class="eyebrow">Willkommen zurueck</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>
Passwort Passwort
<input type="password" name="password" autocomplete="current-password" required> <input type="password" name="password" autocomplete="current-password" required>
</label> </label>
<label class="inline-check">
<input type="checkbox" name="remember_me" value="1">
<span>Angemeldet bleiben</span>
</label>
<button type="submit">Anmelden</button> <button type="submit">Anmelden</button>
</form> </form>
</div> </div>
+70
View File
@@ -0,0 +1,70 @@
{% 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="panel compact-form-panel">
<div class="panel-head">
<h2>Optionen</h2>
<a href="{{ url_for('main.settings_view') }}">Zu den Einstellungen</a>
</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 koennt 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">
+110 -14
View File
@@ -4,36 +4,66 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Nouri{% endblock %}</title> <title>{% block title %}Nouri{% endblock %}</title>
<meta name="theme-color" content="#efab72">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Nouri">
<meta name="csrf-token" content="{{ csrf_token_value }}">
<meta name="nouri-push-public-key" content="{{ push_public_key }}">
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='brand/favicon.svg') }}">
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/pwa-192.png') }}">
<link rel="manifest" href="{{ url_for('webmanifest') }}">
<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/ui.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/pwa.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">N</span> <span class="brand-mark">
<span> <img src="{{ url_for('static', filename='brand/nouri-icon.svg') }}" alt="">
</span>
<span class="brand-copy">
<strong>Nouri</strong> <strong>Nouri</strong>
<small>freundliches Essensgedaechtnis</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 '' }}">Heute</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.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}">Lebensmittel</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.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}">Mahlzeiten</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.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}">Einkaufsliste</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 '' }}">Zuhause</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') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">Wochenplan</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.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}">Archiv</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.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-leaf"></span><span>Vorlagen</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="ghost-button" href="{{ url_for('main.settings_view') }}">Optionen</a>
<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>
@@ -50,6 +80,72 @@
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
<footer class="site-footer">
<div class="footer-copy">
<span>Version {{ app_version }}</span>
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
</div> </div>
<div class="footer-copy">
<span>&copy; 2026 <a href="https://hnz.io" target="_blank" rel="noreferrer">@ hnz.io</a></span>
</div>
</footer>
</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 card-link-grid">
<a class="menu-card" href="{{ url_for('main.item_list', kind='food') }}"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></a>
<a class="menu-card" href="{{ url_for('main.item_list', kind='meal') }}"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></a>
<a class="menu-card" href="{{ url_for('main.home_view') }}"><span class="ui-icon icon-house"></span><span>Zuhause</span></a>
<a class="menu-card" href="{{ url_for('main.archive_view') }}"><span class="ui-icon icon-archive"></span><span>Archiv</span></a>
<a class="menu-card" href="{{ url_for('main.template_library') }}"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></a>
<a class="menu-card" href="{{ url_for('main.settings_view') }}"><span class="ui-icon icon-sliders"></span><span>Optionen</span></a>
<a class="menu-card" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
{% if g.user.role == 'admin' %}
<a class="menu-card" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
<a class="menu-card" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></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>
+126 -9
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 noch eingekauft werden soll und was heute schon eingeplant ist.</p> <p class="lead">Du siehst schnell, was zuhause da ist, was schon geplant wurde, welche Vorlagen gut passen und wo heute noch etwas ergänzt werden könnte.</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<a class="button secondary" href="{{ url_for('main.item_create', kind='food') }}">Lebensmittel anlegen</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>
@@ -17,7 +17,7 @@
<article class="stat-card"> <article class="stat-card">
<span>Zuhause</span> <span>Zuhause</span>
<strong>{{ home_count }}</strong> <strong>{{ home_count }}</strong>
<small>sichtbare Eintraege</small> <small>sichtbare Einträge</small>
</article> </article>
<article class="stat-card"> <article class="stat-card">
<span>Einkaufsliste</span> <span>Einkaufsliste</span>
@@ -31,23 +31,46 @@
</article> </article>
</section> </section>
{% if dashboard_hints %}
<section class="panel">
<div class="panel-head">
<h2>Heute passend</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">
<h2>Heute im Plan</h2> <h2>Heute im Plan</h2>
<a href="{{ url_for('main.planner') }}">Wochenplan oeffnen</a> <a href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Zum Tagesplan</a>
</div> </div>
{% 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>
<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>
{% if entry.availability_state == 'home' %}
<span class="status-pill status-home">zuhause</span>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<p class="empty-state">Fuer 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>
@@ -62,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 %}
@@ -79,4 +103,97 @@
{% endif %} {% endif %}
</article> </article>
</section> </section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Was zuhause gut zusammenpasst</h2>
<a href="{{ url_for('main.home_view') }}">Zuhause öffnen</a>
</div>
{% if recipe_suggestions %}
<div class="stack-sections">
{% for suggestion in recipe_suggestions %}
<article class="template-list-card">
<div>
<strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small>
</div>
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Sobald ein paar Dinge unter Zuhause liegen, zeigt Nouri hier kleine Kombinationsideen.</p>
{% endif %}
</article>
<article class="panel">
<div class="panel-head">
<h2>Vorlagen für später</h2>
<a href="{{ url_for('main.template_library') }}">Alles ansehen</a>
</div>
{% if day_templates or week_templates %}
<div class="stack-sections">
{% for template in day_templates %}
<a class="mini-card" href="{{ url_for('main.day_template_edit', template_id=template.id) }}">
<strong>{{ template.name }}</strong>
<small>Tagesvorlage · {{ template.visibility_label }}</small>
</a>
{% endfor %}
{% for template in week_templates %}
<a class="mini-card" href="{{ url_for('main.week_template_edit', template_id=template.id) }}">
<strong>{{ template.name }}</strong>
<small>Wochenvorlage · {{ template.visibility_label }}</small>
</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>
</section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Nächster Einkauf</h2>
<a href="{{ url_for('main.shopping_list') }}">Zur Einkaufsliste</a>
</div>
{% if upcoming_entries %}
<div class="stack-sections">
{% for entry in upcoming_entries %}
<article class="template-list-card">
<div>
<strong>{{ entry.item_name }}</strong>
<small>Wird ab {{ entry.activation_label }} sichtbar · {{ entry.needed_for_label }}</small>
</div>
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Gerade ist nichts für spätere Einkäufe vorgemerkt.</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>ruhiger Einstieg für den Tag</small>
{% endif %}
</a>
{% endfor %}
</div>
</article>
</section>
{% endblock %} {% endblock %}
+64 -6
View File
@@ -5,20 +5,70 @@
<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 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>
{% if grouped %} <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>
<label>
Tageszeit
<select name="daypart_id">
<option value="">Alle Tageszeiten</option>
{% for daypart in dayparts %}
<option value="{{ daypart.id }}" {% if selected_daypart_id == daypart.id %}selected{% endif %}>{{ daypart.name }}</option>
{% endfor %}
</select>
</label>
<div class="filter-actions">
<button type="submit">Filtern</button>
<a class="ghost-button" href="{{ url_for('main.home_view') }}">Zurücksetzen</a>
</div>
</form>
</section>
{% if recipe_suggestions %}
<section class="panel">
<div class="panel-head">
<h2>Passt gut dazu</h2>
<a href="{{ url_for('main.item_create', kind='meal') }}">Neue Mahlzeit</a>
</div>
<div class="stack-sections">
{% for suggestion in recipe_suggestions %}
<article class="template-list-card">
<div>
<strong>{{ suggestion.title }}</strong>
<small>{{ suggestion.reason }}</small>
</div>
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
</article>
{% endfor %}
</div>
</section>
{% endif %}
{% if sections %}
<section class="stack-sections"> <section class="stack-sections">
{% for title, items in grouped.items() %} {% for section in sections if section["items"] %}
<article class="panel"> <article class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>{{ title }}</h2> <h2>{{ section["title"] }}</h2>
<span>{{ items|length }} Eintraege</span> <span>{{ section["items"]|length }} Einträge</span>
</div> </div>
<div class="card-grid"> <div class="card-grid">
{% for item in items %} {% for item in section["items"] %}
<article class="item-card compact"> <article class="item-card compact">
<div class="item-media"> <div class="item-media">
{% if item.photo_filename %} {% if item.photo_filename %}
@@ -29,16 +79,24 @@
</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>
{% endif %} {% endif %}
</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>
{% if 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">Verbraucht / nicht mehr da</button> <button class="ghost-button" type="submit">Verbraucht / nicht mehr da</button>
</form> </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>
+94 -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,75 @@
{% if kind == 'meal' %} {% if kind == 'meal' %}
<fieldset> <fieldset>
<legend>Bestandteile der Mahlzeitenidee</legend> <legend>Bestandteile der Mahlzeitenidee</legend>
<div class="checkbox-grid"> <p class="muted">Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenstellen.</p>
{% for food in foods %} <div class="inline-form">
<label class="check-option"> <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 %}
<div class="stack-sections" id="meal-components-list">
{% for group in food_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 filterable-checkbox-group" data-filter-group>
{% for food in group["items"] %}
<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>
</div>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Lege zuerst ein paar Lebensmittel an, damit du daraus Mahlzeitenideen bauen kannst.</p>
{% endif %}
<div class="quick-food-panel">
<div class="panel-head">
<h3>Neues Lebensmittel direkt anlegen</h3>
<span>ohne die Seite zu verlassen</span>
</div>
<div class="quick-food-grid">
<label>
Name
<input type="text" name="quick_food_name" value="{{ form_data.quick_food_name }}" placeholder="z. B. Hüttenkäse">
</label>
<label>
Kategorie
<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 class="wide">
Notiz
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
</label>
<button type="submit" name="form_action" value="quick_add_food" class="secondary">Lebensmittel anlegen und übernehmen</button>
</div>
</div>
</fieldset> </fieldset>
{% endif %} {% endif %}
<div class="form-actions"> <div class="form-actions">
<button type="submit">Speichern</button> <button type="submit" name="form_action" value="save_item">Speichern</button>
<a class="ghost-button" href="{{ url_for('main.item_list', kind=kind) }}">Zurueck</a> <a class="ghost-button" href="{{ url_for('main.item_list', kind=kind) }}">Zurück</a>
</div> </div>
</form> </form>
</section> </section>
+53 -8
View File
@@ -5,11 +5,49 @@
<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 Eintraege mit Foto, Tageszeiten und einem ruhigen Status zwischen Idee, 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>
<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>
Status
<select name="state">
{% for value, label in state_options %}
<option value="{{ value }}" {% if selected_state == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</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>
Tageszeit
<select name="daypart_id">
<option value="">Alle Tageszeiten</option>
{% for daypart in dayparts %}
<option value="{{ daypart.id }}" {% if selected_daypart_id == daypart.id %}selected{% endif %}>{{ daypart.name }}</option>
{% endfor %}
</select>
</label>
<div class="filter-actions">
<button type="submit">Filtern</button>
<a class="ghost-button" href="{{ url_for('main.item_list', kind=kind) }}">Zurücksetzen</a>
</div>
</form>
</section>
{% if items %} {% if items %}
<section class="card-grid"> <section class="card-grid">
{% for item in items %} {% for item in items %}
@@ -26,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">
@@ -46,18 +88,21 @@
{% endif %} {% endif %}
</div> </div>
<div class="item-actions"> <div class="item-actions">
{% if item.can_edit %}
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a> <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>
<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>
@@ -69,9 +114,9 @@
</section> </section>
{% else %} {% else %}
<section class="panel empty-panel"> <section class="panel empty-panel">
<h2>Noch keine Eintraege</h2> <h2>Keine passenden Einträge</h2>
<p>Der schnellste Start ist ein erstes vertrautes Lebensmittel oder eine einfache Mahlzeitenidee.</p> <p>Mit einer kleinen Suche oder einem anderen Filter findest du meist schnell wieder das Richtige.</p>
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Ersten Eintrag anlegen</a> <a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neuen Eintrag anlegen</a>
</section> </section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+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>Gerade passend</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 %}
+201
View File
@@ -0,0 +1,201 @@
{% extends "base.html" %}
{% block title %}Tagesplan | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Tagesplan</p>
<h1>{{ weekday_name(selected_date) }}, {{ selected_date.strftime('%d.%m.%Y') }}</h1>
<p class="lead">Der Tagesplan bleibt bewusst ruhig. Jede Tageszeit ist eine eigene Kachel und öffnet sich erst, wenn du sie brauchst.</p>
</div>
<div class="week-nav">
<a class="ghost-button" href="{{ url_for('main.planner_day', date=previous_day.isoformat()) }}">Vorheriger Tag</a>
<a class="ghost-button" href="{{ url_for('main.planner') }}">Zur Woche</a>
<a class="ghost-button" href="{{ url_for('main.planner_day', date=next_day.isoformat()) }}">Nächster Tag</a>
</div>
</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>Heute im Blick</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">
{% for section in sections %}
<details class="day-tile" id="daypart-{{ section.daypart.id }}" {% if section.is_open %}open{% endif %}>
<summary class="day-tile-summary">
<div class="day-tile-summary-main">
<div class="day-tile-icon"><span class="ui-icon icon-calendar"></span></div>
<div>
<h2>{{ section.daypart.name }}</h2>
{% if section.summary_items %}
<p class="muted">{{ section.summary_items|join(', ') }}</p>
{% else %}
<p class="muted">Noch frei. Öffnen, wenn du etwas ergänzen möchtest.</p>
{% endif %}
</div>
</div>
<span class="status-pill">{{ section.entries|length }} geplant</span>
</summary>
<div class="day-tile-body">
{% if section.balance_suggestion %}
<div class="suggestion-card">
<strong>{{ section.balance_suggestion.text }}</strong>
{% if section.balance_suggestion["items"] %}
<div class="quick-add-row compact-quick-row">
{% for item in section.balance_suggestion["items"] %}
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="quick-add-button compact-button" type="submit">
<span>{{ item.name }}</span>
<small>zuhause vorhanden</small>
</button>
</form>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
{% if section.meal_candidates %}
<div class="planner-subsection">
<h3>Mahlzeitenideen</h3>
<div class="quick-add-row compact-quick-row">
{% for item in section.meal_candidates %}
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="quick-add-button compact-button" type="submit">
<span>{{ item.name }}</span>
{% if item.availability_state == 'home' %}<small>zuhause vorhanden</small>{% endif %}
</button>
</form>
{% endfor %}
</div>
</div>
{% endif %}
{% if section.recipe_suggestions %}
<div class="planner-subsection">
<h3>Passt gut dazu</h3>
<div class="quick-add-row compact-quick-row">
{% for suggestion in section.recipe_suggestions %}
<form method="post" action="{{ url_for('main.planner_generated_meal') }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="meal_name" value="{{ suggestion.title }}">
<input type="hidden" name="visibility" value="shared">
{% for component_id in suggestion.component_ids %}
<input type="hidden" name="component_ids" value="{{ component_id }}">
{% endfor %}
<button class="quick-add-button compact-button" type="submit">
<span>{{ suggestion.title }}</span>
<small>{{ suggestion.reason }}</small>
</button>
</form>
{% endfor %}
</div>
</div>
{% endif %}
<div class="planner-subsection">
<label class="planner-search">
<span>Suche</span>
<input type="text" placeholder="Lebensmittel oder Mahlzeiten suchen" data-filter-input data-filter-target="#planner-list-{{ section.daypart.id }}">
</label>
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
{% for item in section.food_candidates %}
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
{{ csrf_input() }}
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="visibility" value="{{ item.visibility }}">
<button class="picker-row" type="submit">
<span>{{ item.name }}</span>
{% if item.availability_state == 'home' %}<small>zuhause</small>{% endif %}
</button>
</form>
{% endfor %}
</div>
</div>
{% if section.entries %}
<div class="planner-entry-list">
{% for entry in section.entries %}
<article class="planner-entry">
<div class="planner-entry-top">
<div>
<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>
<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>
{% if entry.can_edit %}
<div class="row-actions">
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, date=selected_date.isoformat()) }}">
{{ csrf_input() }}
<button class="ghost-button" type="submit">Entfernen</button>
</form>
</div>
{% endif %}
</div>
{% if entry.note %}
<p>{{ entry.note }}</p>
{% endif %}
</article>
{% endfor %}
</div>
{% else %}
<p class="empty-state">Hier ist noch nichts eingetragen. Ein kleiner Anfang reicht völlig.</p>
{% endif %}
</div>
</details>
{% endfor %}
</section>
{% endblock %}
+98 -55
View File
@@ -1,81 +1,124 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Wochenplan | Nouri{% endblock %} {% block title %}Wochenansicht | Nouri{% endblock %}
{% block content %} {% block content %}
<section class="page-intro"> <section class="page-intro">
<div> <div>
<p class="eyebrow">Wochenplan</p> <p class="eyebrow">Wochenansicht</p>
<h1>Struktur fuer die naechsten Tage</h1> <h1>Ein ruhiger Blick auf die nächsten sieben Tage</h1>
<p class="lead">Der Plan bleibt bewusst leichtgewichtig. Vorhandene Dinge tauchen in der Auswahl zuerst auf.</p> <p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und gleichzeitig sehen, was erst später für den Einkauf relevant wird.</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>
<span>{{ days[0].strftime('%d.%m.') }} bis {{ days[-1].strftime('%d.%m.%Y') }}</span> <span>{{ week_start.strftime('%d.%m.%Y') }} bis {{ week_end.strftime('%d.%m.%Y') }}</span>
<a class="ghost-button" href="{{ url_for('main.planner', week=next_week.isoformat()) }}">Naechste Woche</a> <a class="ghost-button" href="{{ url_for('main.planner', week=next_week.isoformat()) }}">Nächste Woche</a>
</div> </div>
</section> </section>
<section class="panel compact-form-panel"> <section class="two-column">
<form method="post" class="planner-form"> <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() }} {{ csrf_input() }}
<label> <input type="hidden" name="target_week" value="{{ week_start.isoformat() }}">
Tag <div class="template-card">
<input type="date" name="plan_date" value="{{ days[0].isoformat() }}"> <strong>{{ template.name }}</strong>
</label> <small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
<label> </div>
Tageszeit <button type="submit">Vorlage anwenden</button>
<select name="daypart_id">
{% for daypart in dayparts %}
<option value="{{ daypart.id }}">{{ daypart.name }}</option>
{% endfor %}
</select>
</label>
<label class="wide">
Eintrag
<select name="item_id">
<option value="">Etwas fuer den Plan waehlen</option>
{% for item in selectable_items %}
<option value="{{ item.id }}">{{ item.name }} · {{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</option>
{% endfor %}
</select>
</label>
<label class="wide">
Notiz
<input type="text" name="note" placeholder="Optional, z. B. zuerst einkaufen">
</label>
<button type="submit">In den Plan legen</button>
</form> </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>Für diese Woche</h2>
</div>
<div class="hint-list">
{% for hint in week_hints %}
<p class="hint-chip">{{ hint }}</p>
{% endfor %}
</div>
</article>
{% endif %}
</section> </section>
<section class="planner-grid"> {% if upcoming_entries %}
{% for daypart in dayparts %} <section class="panel">
<div class="planner-row"> <div class="panel-head">
<div class="planner-label">{{ daypart.name }}</div> <h2>Kommt später zum Einkauf dazu</h2>
{% for day in days %} <small>{{ household_settings.shopping_prep_days }} Tag{% if household_settings.shopping_prep_days != 1 %}e{% endif %} Vorlauf</small>
<div class="planner-cell"> </div>
<div class="planner-date">{{ day.strftime('%a %d.%m.') }}</div> <div class="chip-row">
{% set slot_entries = entries.get((day.isoformat(), daypart.id), []) %} {% for entry in upcoming_entries %}
{% if slot_entries %} <span class="chip">{{ entry.item_name }} · ab {{ entry.activation_label }}</span>
<div class="planner-entry-stack"> {% endfor %}
{% for entry in slot_entries %} </div>
<article class="planner-entry"> </section>
<strong>{{ entry.item_name }}</strong> {% endif %}
<small>{{ item_kind_labels[entry.item_kind] }}</small>
{% if entry.note %} <section class="week-overview-grid week-board" data-csrf-token="{{ csrf_token_value }}">
<p>{{ entry.note }}</p> {% for card in week_cards %}
<article class="week-card">
<div class="week-card-head">
<div>
<p class="eyebrow">{{ weekday_name(card.date) }}</p>
<h2>{{ card.date.strftime('%d.%m.%Y') }}</h2>
</div>
{% if card.date == today %}
<span class="status-pill status-home">heute</span>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, week=week_start.isoformat()) }}"> </div>
{{ csrf_input() }}
<button class="ghost-button" type="submit">Entfernen</button> {% if card.filled_dayparts %}
</form> <p class="week-card-count">{{ card.planned_count }} Einträge</p>
<div class="chip-row">
{% for slot in card.filled_dayparts %}
<span class="chip">{{ slot.name }} · {{ slot.count }}</span>
{% endfor %}
</div>
<p class="muted">{{ card.preview_items | join(', ') }}</p>
{% else %}
<p class="empty-state">Noch offen. Du kannst den Tag ganz leicht nach und nach füllen.</p>
{% endif %}
<div class="week-slot-stack">
{% for slot in card.slots %}
<div class="week-slot drop-slot" data-target-date="{{ card.date.isoformat() }}" data-target-daypart-id="{{ slot.daypart.id }}">
<div class="week-slot-head">
<strong>{{ slot.daypart.name }}</strong>
<span>{{ slot.entries|length }}</span>
</div>
{% if slot.entries %}
<div class="week-entry-stack">
{% for entry in slot.entries %}
<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>
<small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
</article> </article>
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p class="empty-slot">frei</p> <p class="week-slot-empty">Hierher ziehen</p>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="week-card-actions">
<a class="button" href="{{ url_for('main.planner_day', date=card.date.isoformat()) }}">Tagesplan öffnen</a>
</div>
</article>
{% endfor %} {% endfor %}
</section> </section>
{% endblock %} {% endblock %}
+125
View File
@@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block title %}Optionen | Nouri{% endblock %}
{% block content %}
<section class="page-intro">
<div>
<p class="eyebrow">Optionen</p>
<h1>Ruhige Einstellungen für Alltag, Einkauf und Erinnerungen</h1>
<p class="lead">Hier lässt sich festlegen, wann Einkäufe vorbereitet werden, welche Hinweise hilfreich sind und ob Nouri sich wie eine App auf dem Home-Bildschirm verhalten soll.</p>
</div>
</section>
<section class="two-column">
<article class="panel">
<div class="panel-head">
<h2>Einkaufstag</h2>
</div>
<form method="post" class="stack-form">
{{ csrf_input() }}
<input type="hidden" name="form_name" value="household">
<label>
Wochentag für den Großeinkauf
<select name="shopping_weekday">
{% for value, label in weekday_options %}
<option value="{{ value }}" {% if household_settings.shopping_weekday == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
So viele Tage vorher vorbereiten
<input type="number" min="0" max="7" name="shopping_prep_days" value="{{ household_settings.shopping_prep_days }}">
</label>
<label>
Erinnerung ungefähr um
<input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}">
</label>
<button type="submit">Speichern</button>
</form>
</article>
<article class="panel">
<div class="panel-head">
<h2>Home-Bildschirm & Push</h2>
</div>
<div class="stack-sections">
<div class="pwa-card">
<strong>Als Web-App nutzen</strong>
<p class="muted">Auf dem iPhone kannst du Nouri über Teilen → Zum Home-Bildschirm hinzufügen. Danach wirkt die App deutlich app-näher.</p>
</div>
<div class="pwa-card">
<strong>Push-Mitteilungen</strong>
{% if push_ready %}
<p class="muted">Push ist vorbereitet. Du kannst es auf diesem Gerät freigeben und später testweise prüfen.</p>
<div class="row-actions">
<button class="secondary" type="button" data-push-enable>Push erlauben</button>
<button class="ghost-button" type="button" data-push-disable>Push beenden</button>
</div>
<form method="post">
{{ csrf_input() }}
<input type="hidden" name="form_name" value="push_test">
<button class="ghost-button" type="submit">Test-Mitteilung senden</button>
</form>
<small class="helper-text">{{ push_subscription_count }} aktives Gerät{% if push_subscription_count != 1 %}e{% endif %}</small>
{% else %}
<p class="muted">Push wird sichtbar, sobald VAPID-Schlüssel für die App gesetzt sind.</p>
{% endif %}
</div>
</div>
</article>
</section>
<section class="panel">
<div class="panel-head">
<h2>Erinnerungen und Hinweise</h2>
</div>
<form method="post" class="stack-form">
{{ csrf_input() }}
<input type="hidden" name="form_name" value="reminders">
<div class="settings-grid">
<fieldset>
<legend>Grundsätzlich</legend>
<label class="inline-check"><input type="checkbox" name="reminders_enabled" value="1" {% if user_settings.reminders_enabled %}checked{% endif %}><span>Erinnerungen insgesamt nutzen</span></label>
<label class="inline-check"><input type="checkbox" name="push_enabled" value="1" {% if user_settings.push_enabled %}checked{% endif %}><span>Push-Mitteilungen erlauben</span></label>
<label>
Hinweise zeigen als
<select name="notification_channel">
{% for value, label in notification_channel_options %}
<option value="{{ value }}" {% if user_settings.notification_channel == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
</fieldset>
<fieldset>
<legend>Einkauf</legend>
<label class="inline-check"><input type="checkbox" name="remind_before_shopping" value="1" {% if user_settings.remind_before_shopping %}checked{% endif %}><span>Am Tag vor dem Einkauf erinnern</span></label>
<label class="inline-check"><input type="checkbox" name="remind_on_shopping_day" value="1" {% if user_settings.remind_on_shopping_day %}checked{% endif %}><span>Am Einkaufstag erinnern</span></label>
<label class="inline-check"><input type="checkbox" name="show_missing_for_upcoming_week" value="1" {% if user_settings.show_missing_for_upcoming_week %}checked{% endif %}><span>Fehlende Dinge für die kommende Woche zeigen</span></label>
<label class="inline-check"><input type="checkbox" name="show_planned_not_shopped" value="1" {% if user_settings.show_planned_not_shopped %}checked{% endif %}><span>Geplante, aber noch nicht eingekaufte Dinge zeigen</span></label>
</fieldset>
<fieldset>
<legend>Planung</legend>
<label class="inline-check"><input type="checkbox" name="remind_tomorrow_if_sparse" value="1" {% if user_settings.remind_tomorrow_if_sparse %}checked{% endif %}><span>Für morgen erinnern, wenn noch wenig geplant ist</span></label>
<label class="inline-check"><input type="checkbox" name="remind_week_if_sparse" value="1" {% if user_settings.remind_week_if_sparse %}checked{% endif %}><span>Für die Woche erinnern, wenn noch wenig eingeplant ist</span></label>
<label class="inline-check"><input type="checkbox" name="suggest_home_for_today" value="1" {% if user_settings.suggest_home_for_today %}checked{% endif %}><span>Passende Dinge aus Zuhause vorschlagen</span></label>
<label class="inline-check"><input type="checkbox" name="show_meal_balancing" value="1" {% if user_settings.show_meal_balancing %}checked{% endif %}><span>Zum Abrunden von Mahlzeiten kleine Vorschläge zeigen</span></label>
</fieldset>
<fieldset>
<legend>Alltag</legend>
<label class="inline-check"><input type="checkbox" name="remind_small_snack" value="1" {% if user_settings.remind_small_snack %}checked{% endif %}><span>An kleine Zwischenmahlzeiten erinnern</span></label>
<label class="inline-check"><input type="checkbox" name="remind_nuts" value="1" {% if user_settings.remind_nuts %}checked{% endif %}><span>Heute schon an Nüsse gedacht?</span></label>
<label class="inline-check"><input type="checkbox" name="suggest_templates" value="1" {% if user_settings.suggest_templates %}checked{% endif %}><span>Häufig genutzte Tages- und Wochenvorlagen vorschlagen</span></label>
<label class="inline-check"><input type="checkbox" name="suggest_patterns" value="1" {% if user_settings.suggest_patterns %}checked{% endif %}><span>Wiederkehrende Muster vorschlagen</span></label>
</fieldset>
</div>
<div class="form-actions">
<button type="submit">Speichern</button>
<a class="ghost-button" href="{{ url_for('auth.profile') }}">Zum Profil</a>
</div>
</form>
</section>
{% endblock %}
+54 -6
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">Hier erscheint, was für den nächsten Einkauf wirklich relevant ist. Spätere Bedarfe bleiben erstmal ruhig vorgemerkt.</p>
</div> </div>
</section> </section>
@@ -13,9 +13,12 @@
<form method="post" class="inline-form"> <form method="post" class="inline-form">
{{ csrf_input() }} {{ csrf_input() }}
<select name="item_id"> <select name="item_id">
<option value="">Bestehenden Eintrag hinzufuegen</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>
@@ -23,22 +26,41 @@
</section> </section>
{% if entries %} {% if entries %}
<section class="panel compact-form-panel">
<div class="panel-head">
<h2>Für den nächsten Einkauf</h2>
<span>{{ entries|length }} Einträge</span>
</div>
</section>
<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 roomy-row">
<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 %} · hinzugefuegt 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>
{% if entry.can_edit %}
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}"> <form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}">
{{ csrf_input() }} {{ csrf_input() }}
<button class="ghost-button" type="submit">Entfernen</button> <button class="ghost-button" type="submit">Entfernen</button>
</form> </form>
{% endif %}
</div> </div>
</article> </article>
{% endfor %} {% endfor %}
@@ -46,7 +68,33 @@
{% 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>Eintraege aus Lebensmitteln, Mahlzeitenideen oder dem Archiv lassen sich jederzeit wieder hinzufuegen.</p> <p>Einträge aus Lebensmitteln, Vorlagen oder kleinen Paketen lassen sich jederzeit wieder hinzufügen.</p>
</section>
{% endif %}
{% if upcoming_entries %}
<section class="panel">
<div class="panel-head">
<h2>Später gebraucht</h2>
<small>Einkaufstag: {{ shopping_weekday_label }}</small>
</div>
<div class="stack-list">
{% for entry in upcoming_entries %}
<article class="list-row stacked-mobile roomy-row">
<div>
<strong>{{ entry.item_name }}</strong>
<p class="muted">Wird ab {{ entry.activation_label }} in die Einkaufsliste übernommen</p>
<div class="chip-row">
<span class="chip">{{ entry.for_label }}</span>
<span class="chip">{{ entry.needed_for_label }}</span>
{% if entry.needed_daypart_name %}
<span class="chip status-soft">{{ entry.needed_daypart_name }}</span>
{% endif %}
</div>
</div>
</article>
{% endfor %}
</div>
</section> </section>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+2
View File
@@ -1 +1,3 @@
Flask==3.1.1 Flask==3.1.1
gunicorn==23.0.0
pywebpush==2.3.0
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
from py_vapid import Vapid01, b64urlencode
from cryptography.hazmat.primitives import serialization
def main() -> None:
vapid = Vapid01()
vapid.generate_keys()
public_key = b64urlencode(
vapid.public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
)
private_value = vapid.private_key.private_numbers().private_value
private_key = b64urlencode(private_value.to_bytes(32, "big"))
print(f"NOURI_VAPID_PUBLIC_KEY={public_key}")
print(f"NOURI_VAPID_PRIVATE_KEY={private_key}")
print("NOURI_VAPID_SUBJECT=mailto:mail@hnz.io")
if __name__ == "__main__":
main()
Executable
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
set -eu
export NOURI_DATA_DIR="${NOURI_DATA_DIR:-/app/data}"
mkdir -p "${NOURI_DATA_DIR}"
mkdir -p "${NOURI_DATA_DIR}/uploads"
exec gunicorn \
--bind 0.0.0.0:8000 \
--workers 2 \
--threads 4 \
--timeout 60 \
wsgi:app