Compare commits
9 Commits
36bde02c54
..
V0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 96ab52e1ba | |||
| d8b56e6b67 | |||
| b68ed62887 | |||
| a4e7292930 | |||
| 9c164dc2e7 | |||
| 0231b28935 | |||
| a8b7eb09df | |||
| 31287813c8 | |||
| 24ebb26ffd |
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
data
|
||||||
|
instance
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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 useradd -r -m -d /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"]
|
||||||
@@ -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/
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten.
|
Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten.
|
||||||
|
|
||||||
## Merkmale in Version 0.2
|
## Merkmale in Version 0.5
|
||||||
|
|
||||||
- Lebensmittel und Mahlzeitenideen anlegen
|
- Lebensmittel und Mahlzeitenideen anlegen
|
||||||
- Fotos lokal hochladen
|
- Fotos lokal hochladen
|
||||||
@@ -12,7 +12,22 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein
|
|||||||
- Tagesplan mit schnellen Vorschlägen je Tageszeit
|
- Tagesplan mit schnellen Vorschlägen je Tageszeit
|
||||||
- Wochenansicht für die nächsten 7 Tage
|
- Wochenansicht für die nächsten 7 Tage
|
||||||
- einfache Suche und Filter für Lebensmittel und Mahlzeitenideen
|
- einfache Suche und Filter für Lebensmittel und Mahlzeitenideen
|
||||||
- einfache Benutzeranmeldung für einen Haushalt
|
- mehrere Haushaltsnutzer mit Rollen
|
||||||
|
- gemeinsame und persönliche Inhalte
|
||||||
|
- Profilseite und Passwortänderung
|
||||||
|
- kleine Admin-Verwaltung für Nutzer
|
||||||
|
- kompaktere mobile Navigation mit Bottom-Bar
|
||||||
|
- Tagesvorlagen und Wochenvorlagen
|
||||||
|
- kleine Pakete für wiederkehrende Einkaufs- oder Planungsbausteine
|
||||||
|
- 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
|
||||||
|
|
||||||
@@ -34,11 +49,27 @@ Wichtige Umgebungsvariablen:
|
|||||||
- `NOURI_SECRET_KEY`: Session-Secret für Produktion
|
- `NOURI_SECRET_KEY`: Session-Secret für Produktion
|
||||||
- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron
|
- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron
|
||||||
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
|
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
|
||||||
|
- `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.1 auf 0.2
|
## Migration von 0.4 auf 0.5
|
||||||
|
|
||||||
Beim Start führt Nouri das Schema erneut mit `CREATE ... IF NOT EXISTS` aus und gleicht die festen Tageszeiten ab. Vorhandene Daten bleiben erhalten; neue Indizes und aktualisierte Tageszeit-Namen werden automatisch ergänzt.
|
Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite weiter: 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
|
||||||
|
|
||||||
Für Cloudron später `NOURI_DATA_DIR=/app/data` setzen, damit Datenbank und Uploads persistent liegen.
|
Für Cloudron ist die App jetzt so vorbereitet, dass Datenbank und Uploads unter `/app/data` liegen. Das Startskript setzt `NOURI_DATA_DIR=/app/data`, legt die SQLite-Datei dort an und startet die App per `gunicorn`.
|
||||||
|
|
||||||
|
Lokale Testdaten und produktive Cloudron-Daten bleiben bewusst getrennt:
|
||||||
|
|
||||||
|
- lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data`
|
||||||
|
- auf Cloudron nutzt Nouri `/app/data`
|
||||||
|
- `data/` ist in `.gitignore` und `.dockerignore` ausgeschlossen und wird weder eingecheckt noch ins Image kopiert
|
||||||
|
- `/app/data` ist auf Cloudron persistent und bleibt bei App-Updates erhalten
|
||||||
|
|
||||||
|
Wenn die App auf Cloudron bereits installiert ist, bitte **kein neues `cloudron install`** ausführen. Stattdessen die bestehende App aktualisieren, also ein neues Image bzw. Paket bauen und dann die vorhandene Installation updaten.
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Nouri ist in diesem Repository für private, nicht-kommerzielle Nutzung freigegeben. Details stehen in [LICENSE.md](LICENSE.md).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -5,11 +5,25 @@ import secrets
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, send_from_directory
|
from flask import Flask, g, send_from_directory
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
|
from .admin import admin_bp
|
||||||
from .auth import auth_bp
|
from .auth import auth_bp
|
||||||
from .constants import CATEGORIES, DAYPARTS, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS
|
from .constants import (
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +31,24 @@ WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Sam
|
|||||||
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||||
|
|
||||||
|
|
||||||
|
def load_secret_key(data_dir: Path) -> str:
|
||||||
|
env_secret = os.environ.get("NOURI_SECRET_KEY")
|
||||||
|
if env_secret:
|
||||||
|
return env_secret
|
||||||
|
|
||||||
|
secret_path = data_dir / ".secret_key"
|
||||||
|
if secret_path.exists():
|
||||||
|
return secret_path.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
secret_value = secrets.token_hex(24)
|
||||||
|
try:
|
||||||
|
with secret_path.open("x", encoding="utf-8") as handle:
|
||||||
|
handle.write(secret_value)
|
||||||
|
except FileExistsError:
|
||||||
|
return secret_path.read_text(encoding="utf-8").strip()
|
||||||
|
return secret_value
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
root_dir = Path(__file__).resolve().parent.parent
|
root_dir = Path(__file__).resolve().parent.parent
|
||||||
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
|
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
|
||||||
@@ -28,18 +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),
|
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
|
||||||
@@ -47,15 +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(),
|
"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_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
||||||
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
|
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
|
||||||
|
"is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/uploads/<path:filename>")
|
@app.get("/uploads/<path:filename>")
|
||||||
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
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -16,7 +16,8 @@ from flask import (
|
|||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
from .db import get_db, user_count
|
from .constants import ROLE_LABELS
|
||||||
|
from .db import active_admin_count, ensure_default_household, get_db, user_count
|
||||||
|
|
||||||
|
|
||||||
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
@@ -32,6 +33,19 @@ def login_required(view):
|
|||||||
return wrapped_view
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(view):
|
||||||
|
@functools.wraps(view)
|
||||||
|
def wrapped_view(**kwargs):
|
||||||
|
if g.user is None:
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
if g.user["role"] != "admin":
|
||||||
|
flash("Dieser Bereich ist für Admins gedacht.", "error")
|
||||||
|
return redirect(url_for("main.dashboard"))
|
||||||
|
return view(**kwargs)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
def ensure_csrf_token() -> str:
|
def ensure_csrf_token() -> str:
|
||||||
token = session.get("_csrf_token")
|
token = session.get("_csrf_token")
|
||||||
if not token:
|
if not token:
|
||||||
@@ -39,6 +53,32 @@ def ensure_csrf_token() -> str:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_login_value(raw: str) -> str:
|
||||||
|
return raw.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_identity_fields(database, username: str, email: str | None, current_user_id: int | None = None) -> str | None:
|
||||||
|
if not username:
|
||||||
|
return "Bitte einen Benutzernamen eintragen."
|
||||||
|
|
||||||
|
existing_user = database.execute(
|
||||||
|
"SELECT id FROM users WHERE LOWER(username) = LOWER(?)",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
if existing_user and int(existing_user["id"]) != current_user_id:
|
||||||
|
return "Dieser Benutzername ist bereits vergeben."
|
||||||
|
|
||||||
|
if email:
|
||||||
|
existing_email = database.execute(
|
||||||
|
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
if existing_email and int(existing_email["id"]) != current_user_id:
|
||||||
|
return "Diese E-Mail-Adresse wird bereits verwendet."
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.app_context_processor
|
@auth_bp.app_context_processor
|
||||||
def inject_csrf_input():
|
def inject_csrf_input():
|
||||||
return {
|
return {
|
||||||
@@ -56,9 +96,18 @@ def load_logged_in_user():
|
|||||||
g.user = None
|
g.user = None
|
||||||
else:
|
else:
|
||||||
g.user = get_db().execute(
|
g.user = get_db().execute(
|
||||||
"SELECT * FROM users WHERE id = ?",
|
"""
|
||||||
|
SELECT users.*,
|
||||||
|
households.name AS household_name
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN households ON households.id = users.household_id
|
||||||
|
WHERE users.id = ?
|
||||||
|
""",
|
||||||
(user_id,),
|
(user_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
if g.user is not None and not g.user["is_active"]:
|
||||||
|
session.clear()
|
||||||
|
g.user = None
|
||||||
|
|
||||||
endpoint = request.endpoint or ""
|
endpoint = request.endpoint or ""
|
||||||
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}:
|
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}:
|
||||||
@@ -78,27 +127,32 @@ def setup():
|
|||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip().lower()
|
household_name = request.form.get("household_name", "").strip() or "Unser Haushalt"
|
||||||
|
username = normalize_login_value(request.form.get("username", ""))
|
||||||
|
email = normalize_login_value(request.form.get("email", "")) or None
|
||||||
display_name = request.form.get("display_name", "").strip()
|
display_name = request.form.get("display_name", "").strip()
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
password_repeat = request.form.get("password_repeat", "")
|
password_repeat = request.form.get("password_repeat", "")
|
||||||
|
database = get_db()
|
||||||
|
|
||||||
error = None
|
error = validate_identity_fields(database, username, email)
|
||||||
if not username:
|
if error is None and not password:
|
||||||
error = "Bitte einen Benutzernamen eintragen."
|
|
||||||
elif not password:
|
|
||||||
error = "Bitte ein Passwort vergeben."
|
error = "Bitte ein Passwort vergeben."
|
||||||
elif password != password_repeat:
|
elif error is None and password != password_repeat:
|
||||||
error = "Die Passwörter stimmen nicht überein."
|
error = "Die Passwörter stimmen nicht überein."
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
database = get_db()
|
database.execute(
|
||||||
|
"INSERT INTO households (name) VALUES (?)",
|
||||||
|
(household_name,),
|
||||||
|
)
|
||||||
|
household_id = ensure_default_household(database)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users (username, display_name, password_hash)
|
INSERT INTO users (household_id, username, email, display_name, role, password_hash)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?, ?, 'admin', ?)
|
||||||
""",
|
""",
|
||||||
(username, display_name, generate_password_hash(password)),
|
(household_id, username, email, display_name, generate_password_hash(password)),
|
||||||
)
|
)
|
||||||
database.commit()
|
database.commit()
|
||||||
flash("Der erste Haushalt-Zugang ist angelegt. Du kannst dich jetzt anmelden.", "success")
|
flash("Der erste Haushalt-Zugang ist angelegt. Du kannst dich jetzt anmelden.", "success")
|
||||||
@@ -115,22 +169,28 @@ def login():
|
|||||||
return redirect(url_for("auth.setup"))
|
return redirect(url_for("auth.setup"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip().lower()
|
identity = normalize_login_value(request.form.get("username", ""))
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
remember_me = request.form.get("remember_me") == "1"
|
remember_me = request.form.get("remember_me") == "1"
|
||||||
database = get_db()
|
database = get_db()
|
||||||
user = database.execute(
|
user = database.execute(
|
||||||
"SELECT * FROM users WHERE username = ?",
|
"""
|
||||||
(username,),
|
SELECT users.*, households.name AS household_name
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN households ON households.id = users.household_id
|
||||||
|
WHERE LOWER(users.username) = LOWER(?) OR LOWER(COALESCE(users.email, '')) = LOWER(?)
|
||||||
|
""",
|
||||||
|
(identity, identity),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
if user is None or not check_password_hash(user["password_hash"], password):
|
if user is None or not check_password_hash(user["password_hash"], password):
|
||||||
error = "Benutzername oder Passwort passen nicht zusammen."
|
error = "Login oder Passwort passen nicht zusammen."
|
||||||
|
elif not user["is_active"]:
|
||||||
|
error = "Dieser Zugang ist derzeit nicht aktiv."
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
session.clear()
|
session.clear()
|
||||||
# Opt-in long-lived session so the shared household device stays low-friction.
|
|
||||||
session.permanent = remember_me
|
session.permanent = remember_me
|
||||||
session["user_id"] = user["id"]
|
session["user_id"] = user["id"]
|
||||||
ensure_csrf_token()
|
ensure_csrf_token()
|
||||||
@@ -141,8 +201,109 @@ def login():
|
|||||||
return render_template("auth/login.html")
|
return render_template("auth/login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/profile", methods=("GET", "POST"))
|
||||||
|
@login_required
|
||||||
|
def profile():
|
||||||
|
database = get_db()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
username = normalize_login_value(request.form.get("username", ""))
|
||||||
|
email = normalize_login_value(request.form.get("email", "")) or None
|
||||||
|
display_name = request.form.get("display_name", "").strip()
|
||||||
|
|
||||||
|
error = validate_identity_fields(database, username, email, current_user_id=g.user["id"])
|
||||||
|
if error is None:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET username = ?, email = ?, display_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(username, email, display_name, g.user["id"]),
|
||||||
|
)
|
||||||
|
database.commit()
|
||||||
|
flash("Dein Profil wurde aktualisiert.", "success")
|
||||||
|
return redirect(url_for("auth.profile"))
|
||||||
|
|
||||||
|
flash(error, "error")
|
||||||
|
|
||||||
|
return render_template("auth/profile.html", role_labels=ROLE_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.post("/password")
|
||||||
|
@login_required
|
||||||
|
def change_password():
|
||||||
|
current_password = request.form.get("current_password", "")
|
||||||
|
new_password = request.form.get("new_password", "")
|
||||||
|
new_password_repeat = request.form.get("new_password_repeat", "")
|
||||||
|
|
||||||
|
error = None
|
||||||
|
if not check_password_hash(g.user["password_hash"], current_password):
|
||||||
|
error = "Das aktuelle Passwort stimmt nicht."
|
||||||
|
elif not new_password:
|
||||||
|
error = "Bitte ein neues Passwort eintragen."
|
||||||
|
elif new_password != new_password_repeat:
|
||||||
|
error = "Die neuen Passwörter stimmen nicht überein."
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
get_db().execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(generate_password_hash(new_password), g.user["id"]),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
flash("Dein Passwort wurde geändert.", "success")
|
||||||
|
else:
|
||||||
|
flash(error, "error")
|
||||||
|
|
||||||
|
return redirect(url_for("auth.profile"))
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.post("/logout")
|
@auth_bp.post("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
session.clear()
|
session.clear()
|
||||||
flash("Du bist abgemeldet.", "info")
|
flash("Du bist abgemeldet.", "info")
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_admin_user_form(
|
||||||
|
database,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
email: str | None,
|
||||||
|
role: str,
|
||||||
|
is_active: bool,
|
||||||
|
password: str,
|
||||||
|
password_repeat: str,
|
||||||
|
current_user_id: int | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
error = validate_identity_fields(database, username, email, current_user_id=current_user_id)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
if role not in ROLE_LABELS:
|
||||||
|
return "Bitte eine gültige Rolle auswählen."
|
||||||
|
if current_user_id is None and not password:
|
||||||
|
return "Bitte ein Passwort vergeben."
|
||||||
|
if password and password != password_repeat:
|
||||||
|
return "Die Passwörter stimmen nicht überein."
|
||||||
|
if current_user_id == g.user["id"] and not is_active:
|
||||||
|
return "Du kannst deinen eigenen Zugang hier nicht deaktivieren."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def can_remove_last_admin(target_user_id: int, new_role: str, is_active: bool) -> bool:
|
||||||
|
if g.user is None:
|
||||||
|
return False
|
||||||
|
if target_user_id != g.user["id"] and g.user["role"] == "admin":
|
||||||
|
target = get_db().execute("SELECT * FROM users WHERE id = ?", (target_user_id,)).fetchone()
|
||||||
|
if target is None:
|
||||||
|
return False
|
||||||
|
if target["role"] != "admin" or not target["is_active"]:
|
||||||
|
return False
|
||||||
|
if new_role == "admin" and is_active:
|
||||||
|
return False
|
||||||
|
return active_admin_count(g.user["household_id"]) <= 1
|
||||||
|
return False
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ DAYPARTS = [
|
|||||||
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
|
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
|
||||||
]
|
]
|
||||||
|
|
||||||
CATEGORIES = [
|
DEFAULT_CATEGORIES = [
|
||||||
"Brot & Getreide",
|
"Brot & Getreide",
|
||||||
"Milchprodukt",
|
"Milchprodukt",
|
||||||
"Obst",
|
"Obst",
|
||||||
@@ -20,6 +20,57 @@ CATEGORIES = [
|
|||||||
"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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -28,9 +28,334 @@ def close_db(_error=None) -> None:
|
|||||||
database.close()
|
database.close()
|
||||||
|
|
||||||
|
|
||||||
|
def table_columns(database: sqlite3.Connection, table_name: str) -> set[str]:
|
||||||
|
rows = database.execute(f"PRAGMA table_info({table_name})").fetchall()
|
||||||
|
return {row["name"] for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(database: sqlite3.Connection, table_name: str) -> bool:
|
||||||
|
row = database.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
||||||
|
(table_name,),
|
||||||
|
).fetchone()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
def add_column_if_missing(database: sqlite3.Connection, table_name: str, definition: str) -> None:
|
||||||
|
column_name = definition.split()[0]
|
||||||
|
if column_name not in table_columns(database, table_name):
|
||||||
|
database.execute(f"ALTER TABLE {table_name} ADD COLUMN {definition}")
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS households (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
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:
|
def apply_schema(database: sqlite3.Connection) -> None:
|
||||||
|
bootstrap_legacy_schema(database)
|
||||||
schema_path = Path(__file__).with_name("schema.sql")
|
schema_path = Path(__file__).with_name("schema.sql")
|
||||||
database.executescript(schema_path.read_text(encoding="utf-8"))
|
database.executescript(schema_path.read_text(encoding="utf-8"))
|
||||||
|
ensure_schema_upgrades(database)
|
||||||
sync_dayparts(database)
|
sync_dayparts(database)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +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:
|
||||||
@@ -80,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.")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,42 +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
|
CREATE INDEX IF NOT EXISTS idx_items_kind_name
|
||||||
ON items (kind, name);
|
ON items (kind, name);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_availability_name
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
||||||
ON items (availability_state, name);
|
ON items (household_id, visibility, availability_state);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_target_user
|
||||||
|
ON items (target_user_id);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item
|
CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item
|
||||||
ON item_dayparts (daypart_id, item_id);
|
ON item_dayparts (daypart_id, item_id);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_plan_entries_plan_date_daypart
|
CREATE INDEX IF NOT EXISTS idx_plan_entries_plan_date_daypart
|
||||||
ON plan_entries (plan_date, daypart_id);
|
ON plan_entries (plan_date, daypart_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility
|
||||||
|
ON plan_entries (household_id, visibility, plan_date);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility
|
||||||
|
ON shopping_entries (household_id, visibility, is_checked);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_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);
|
||||||
|
|||||||
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -11,6 +11,7 @@
|
|||||||
let draggedEntry = null;
|
let draggedEntry = null;
|
||||||
|
|
||||||
board.querySelectorAll(".draggable-plan-entry").forEach((entry) => {
|
board.querySelectorAll(".draggable-plan-entry").forEach((entry) => {
|
||||||
|
if (entry.getAttribute("draggable") !== "true") return;
|
||||||
entry.addEventListener("dragstart", () => {
|
entry.addEventListener("dragstart", () => {
|
||||||
draggedEntry = entry;
|
draggedEntry = entry;
|
||||||
entry.classList.add("is-dragging");
|
entry.classList.add("is-dragging");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const CACHE_NAME = "nouri-v0-5-0";
|
||||||
|
const APP_SHELL = [
|
||||||
|
"/",
|
||||||
|
"/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(APP_SHELL)).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;
|
||||||
|
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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Archiv</p>
|
<p class="eyebrow">Archiv</p>
|
||||||
<h1>Frühere Ideen bleiben greifbar</h1>
|
<h1>Frühere Ideen bleiben greifbar</h1>
|
||||||
<p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder auf die Einkaufsliste setzen.</p>
|
<p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder einplanen oder einkaufen.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -23,6 +23,14 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Sichtbarkeit
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<div class="filter-actions">
|
<div class="filter-actions">
|
||||||
<button type="submit">Filtern</button>
|
<button type="submit">Filtern</button>
|
||||||
<a class="ghost-button" href="{{ url_for('main.archive_view') }}">Zurücksetzen</a>
|
<a class="ghost-button" href="{{ url_for('main.archive_view') }}">Zurücksetzen</a>
|
||||||
@@ -43,6 +51,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="item-body">
|
<div class="item-body">
|
||||||
<h2>{{ item.name }}</h2>
|
<h2>{{ item.name }}</h2>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
|
</div>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
||||||
{% if item.dayparts %}
|
{% if item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
@@ -64,10 +77,12 @@
|
|||||||
<button type="submit">Wieder einkaufen</button>
|
<button type="submit">Wieder einkaufen</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
||||||
|
{% 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 %}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<p class="eyebrow">Willkommen zurück</p>
|
<p class="eyebrow">Willkommen zurück</p>
|
||||||
<h1>Ruhig wieder einsteigen</h1>
|
<h1>Ruhig wieder einsteigen</h1>
|
||||||
<p class="lead">Nouri hilft beim Erinnern, Sichtbar-Machen und Planen. Ohne Zahlen, ohne Druck.</p>
|
<p class="lead">Nouri bleibt ein kleiner, freundlicher Ort für euren Alltag rund um Essen, Einkauf und Planung.</p>
|
||||||
|
|
||||||
<form method="post" class="stack-form">
|
<form method="post" class="stack-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<label>
|
<label>
|
||||||
Benutzername
|
Benutzername oder E-Mail
|
||||||
<input type="text" name="username" autocomplete="username" required>
|
<input type="text" name="username" autocomplete="username" required>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -5,14 +5,22 @@
|
|||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<p class="eyebrow">Erster Start</p>
|
<p class="eyebrow">Erster Start</p>
|
||||||
<h1>Den ersten Haushalt-Zugang anlegen</h1>
|
<h1>Den ersten Haushalt-Zugang anlegen</h1>
|
||||||
<p class="lead">Danach könnt ihr die App gemeinsam nutzen. Die Daten bleiben lokal in dieser Installation.</p>
|
<p class="lead">Danach könnt ihr Nouri gemeinsam nutzen. Persönliche und gemeinsame Einträge lassen sich später ruhig auseinanderhalten.</p>
|
||||||
|
|
||||||
<form method="post" class="stack-form">
|
<form method="post" class="stack-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
|
<label>
|
||||||
|
Haushaltsname
|
||||||
|
<input type="text" name="household_name" autocomplete="organization" placeholder="z. B. Zuhause">
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Benutzername
|
Benutzername
|
||||||
<input type="text" name="username" autocomplete="username" required>
|
<input type="text" name="username" autocomplete="username" required>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
E-Mail
|
||||||
|
<input type="email" name="email" autocomplete="email" placeholder="Optional">
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Anzeigename
|
Anzeigename
|
||||||
<input type="text" name="display_name" autocomplete="name" placeholder="z. B. Heinz">
|
<input type="text" name="display_name" autocomplete="name" placeholder="z. B. Heinz">
|
||||||
|
|||||||
@@ -4,42 +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="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="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/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">
|
<span class="brand-mark">
|
||||||
<img src="{{ url_for('static', filename='brand/nouri-icon.svg') }}" alt="">
|
<img src="{{ url_for('static', filename='brand/nouri-icon.svg') }}" alt="">
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span class="brand-copy">
|
||||||
<strong>Nouri</strong>
|
<strong>Nouri</strong>
|
||||||
<small>einfach essen planen</small>
|
<small>einfach essen planen</small>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if g.user %}
|
{% if g.user %}
|
||||||
<nav class="site-nav">
|
<nav class="site-nav desktop-nav">
|
||||||
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-sparkles"></span><span>Heute</span></span></a>
|
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-sparkles"></span><span>Heute</span></span></a>
|
||||||
|
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-cart-shopping"></span><span>Einkauf</span></span></a>
|
||||||
|
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar"></span><span>Plan</span></span></a>
|
||||||
|
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar-days"></span><span>Woche</span></span></a>
|
||||||
|
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a>
|
||||||
<a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></a>
|
<a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></a>
|
||||||
<a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a>
|
<a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a>
|
||||||
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-cart-shopping"></span><span>Einkaufsliste</span></span></a>
|
<a href="{{ url_for('main.template_library') }}" class="{{ 'active' if (request.endpoint or '').startswith('main.day_template') or (request.endpoint or '').startswith('main.week_template') or (request.endpoint or '').startswith('main.item_set') or request.endpoint == 'main.template_library' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></span></a>
|
||||||
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a>
|
|
||||||
<a href="{{ url_for('main.planner_day', date=today.isoformat() if today else None) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar"></span><span>Tagesplan</span></span></a>
|
|
||||||
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar-days"></span><span>Woche</span></span></a>
|
|
||||||
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a>
|
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="header-actions">
|
|
||||||
<button class="theme-toggle" type="button" data-theme-toggle>Modus</button>
|
<div class="header-actions desktop-actions">
|
||||||
|
<button class="theme-toggle ghost-button" type="button" data-theme-toggle>Modus</button>
|
||||||
|
<a class="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>
|
||||||
|
|
||||||
@@ -56,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>© 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>
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Heute</p>
|
<p class="eyebrow">Heute</p>
|
||||||
<h1>Ein ruhiger Blick auf das, was gerade hilft</h1>
|
<h1>Ein ruhiger Blick auf euren Alltag</h1>
|
||||||
<p class="lead">Du siehst auf einen Blick, was zuhause da ist, was schon eingeplant wurde und wo du schnell weitermachen kannst.</p>
|
<p class="lead">Du siehst schnell, was zuhause da ist, was schon geplant wurde, welche Vorlagen gut passen und wo heute noch etwas ergänzt werden könnte.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
|
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
|
||||||
<a class="button secondary" href="{{ url_for('main.item_create', kind='meal') }}">Mahlzeitenidee anlegen</a>
|
<a class="button secondary" href="{{ url_for('main.template_library') }}">Vorlagen öffnen</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -31,6 +31,19 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if dashboard_hints %}
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>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">
|
||||||
@@ -40,10 +53,15 @@
|
|||||||
{% if today_entries %}
|
{% if today_entries %}
|
||||||
<ul class="simple-list">
|
<ul class="simple-list">
|
||||||
{% for entry in today_entries %}
|
{% for entry in today_entries %}
|
||||||
<li>
|
<li class="stacked-mobile">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ entry.daypart_name }}</strong>
|
<strong>{{ entry.daypart_name }}</strong>
|
||||||
<span>{{ entry.item_name }}</span>
|
<span>{{ entry.item_name }}</span>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ entry.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||||
|
<span class="chip">{{ entry.for_label }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if entry.availability_state == 'home' %}
|
{% if entry.availability_state == 'home' %}
|
||||||
<span class="status-pill status-home">zuhause</span>
|
<span class="status-pill status-home">zuhause</span>
|
||||||
@@ -52,7 +70,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty-state">Für heute ist noch nichts fest eingeplant. Das ist vollkommen okay.</p>
|
<p class="empty-state">Für heute ist noch nichts fest eingeplant. Ein kleiner Anfang reicht völlig.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -67,7 +85,8 @@
|
|||||||
<article class="mini-card">
|
<article class="mini-card">
|
||||||
<div class="mini-card-body">
|
<div class="mini-card-body">
|
||||||
<strong>{{ item.name }}</strong>
|
<strong>{{ item.name }}</strong>
|
||||||
<small>{{ item_kind_labels[item.kind] }}</small>
|
<small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}</small>
|
||||||
|
<small>{{ item.for_label }}</small>
|
||||||
{% if item.dayparts %}
|
{% if item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
{% for daypart in item.dayparts %}
|
{% for daypart in item.dayparts %}
|
||||||
@@ -85,7 +104,78 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<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">
|
<div class="panel-head">
|
||||||
<h2>Nächste Tage</h2>
|
<h2>Nächste Tage</h2>
|
||||||
<a href="{{ url_for('main.planner') }}">Wochenansicht öffnen</a>
|
<a href="{{ url_for('main.planner') }}">Wochenansicht öffnen</a>
|
||||||
@@ -99,10 +189,11 @@
|
|||||||
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
|
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span>Noch frei</span>
|
<span>Noch frei</span>
|
||||||
<small>sanfter Einstieg für den Tag</small>
|
<small>ruhiger Einstieg für den Tag</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Zuhause</p>
|
<p class="eyebrow">Zuhause</p>
|
||||||
<h1>Was aktuell da ist</h1>
|
<h1>Was aktuell da ist</h1>
|
||||||
<p class="lead">Sichtbar, ruhig und besser nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, wandert es nicht weg, sondern ins Archiv.</p>
|
<p class="lead">Sichtbar, ruhig und nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, bleibt es später im Archiv greifbar.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -15,6 +15,14 @@
|
|||||||
Suche
|
Suche
|
||||||
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
|
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Sichtbarkeit
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Tageszeit
|
Tageszeit
|
||||||
<select name="daypart_id">
|
<select name="daypart_id">
|
||||||
@@ -31,6 +39,26 @@
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</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 %}
|
{% if sections %}
|
||||||
<section class="stack-sections">
|
<section class="stack-sections">
|
||||||
{% for section in sections if section["items"] %}
|
{% for section in sections if section["items"] %}
|
||||||
@@ -51,6 +79,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="item-body">
|
<div class="item-body">
|
||||||
<h3>{{ item.name }}</h3>
|
<h3>{{ item.name }}</h3>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
|
</div>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
||||||
{% if item.components %}
|
{% if item.components %}
|
||||||
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
||||||
@@ -58,10 +91,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
||||||
|
{% 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>
|
||||||
|
|||||||
@@ -5,8 +5,15 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||||
<h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1>
|
<h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1>
|
||||||
<p class="lead">Nur das Nötigste: Name, Bild, Tageszeiten und eine kleine Notiz, wenn sie hilft.</p>
|
<p class="lead">Nur das Nötigste: Name, Sichtbarkeit, für wen etwas gedacht ist, Bild, Tageszeiten und eine kleine Notiz.</p>
|
||||||
</div>
|
</div>
|
||||||
|
{% if item %}
|
||||||
|
<div class="intro-pills">
|
||||||
|
<span class="status-pill">{{ item.visibility_label }}</span>
|
||||||
|
<span class="status-pill status-soft">{{ item.owner_label }}</span>
|
||||||
|
<span class="status-pill">{{ item.for_label }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel form-panel">
|
<section class="panel form-panel">
|
||||||
@@ -17,14 +24,35 @@
|
|||||||
<input type="text" name="name" value="{{ form_data.name }}" required>
|
<input type="text" name="name" value="{{ form_data.name }}" required>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div class="dual-grid">
|
||||||
|
<label>
|
||||||
|
Sichtbarkeit
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Für wen?
|
||||||
|
<select name="target_user_id">
|
||||||
|
{% for option in target_user_options %}
|
||||||
|
<option value="{{ option.value }}" {% if form_data.target_user_raw == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Kategorie
|
Kategorie
|
||||||
<input type="text" name="category" list="category-list" value="{{ form_data.category }}" placeholder="z. B. Obst, Warmes, Snack">
|
<select name="category">
|
||||||
<datalist id="category-list">
|
<option value="">Ohne Kategorie</option>
|
||||||
{% for category in categories %}
|
{% for category in categories %}
|
||||||
<option value="{{ category }}"></option>
|
<option value="{{ category }}" {% if form_data.category == category %}selected{% endif %}>{{ category }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</datalist>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
@@ -58,20 +86,34 @@
|
|||||||
{% if kind == 'meal' %}
|
{% if kind == 'meal' %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Bestandteile der Mahlzeitenidee</legend>
|
<legend>Bestandteile der Mahlzeitenidee</legend>
|
||||||
<p class="muted">Optional: Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus vorhandenen und archivierten Lebensmitteln zusammenklicken.</p>
|
<p class="muted">Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenstellen.</p>
|
||||||
|
<div class="inline-form">
|
||||||
|
<label class="wide">
|
||||||
|
Lebensmittel suchen
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="food_search"
|
||||||
|
value="{{ form_data.food_search }}"
|
||||||
|
placeholder="z. B. Reis, Banane, Joghurt"
|
||||||
|
data-filter-input
|
||||||
|
data-filter-target="#meal-components-list"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button>
|
||||||
|
</div>
|
||||||
{% if food_groups %}
|
{% if food_groups %}
|
||||||
<div class="stack-sections">
|
<div class="stack-sections" id="meal-components-list">
|
||||||
{% for group in food_groups %}
|
{% for group in food_groups %}
|
||||||
<div class="component-group">
|
<div class="component-group">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h3>{{ group["title"] }}</h3>
|
<h3>{{ group["title"] }}</h3>
|
||||||
<span>{{ group["items"]|length }} Einträge</span>
|
<span>{{ group["items"]|length }} Einträge</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox-grid">
|
<div class="checkbox-grid filterable-checkbox-group" data-filter-group>
|
||||||
{% for food in group["items"] %}
|
{% for food in group["items"] %}
|
||||||
<label class="check-option">
|
<label class="check-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }}">
|
||||||
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
|
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
|
||||||
<span>{{ food.name }}</span>
|
<span>{{ food.name }} · {{ food.visibility_label }} · {{ food.for_label }}</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +135,12 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Kategorie
|
Kategorie
|
||||||
<input type="text" name="quick_food_category" value="{{ form_data.quick_food_category }}" list="category-list" placeholder="z. B. Milchprodukt">
|
<select name="quick_food_category">
|
||||||
|
<option value="">Ohne Kategorie</option>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category }}" {% if form_data.quick_food_category == category %}selected{% endif %}>{{ category }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="wide">
|
<label class="wide">
|
||||||
Notiz
|
Notiz
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||||
<h1>{{ item_kind_labels[kind] }}</h1>
|
<h1>{{ item_kind_labels[kind] }}</h1>
|
||||||
<p class="lead">Schnell gepflegte Einträge mit Foto, Tageszeiten und einem ruhigen Status zwischen Merkliste, Zuhause und Archiv.</p>
|
<p class="lead">Gemeinsame und persönliche Ideen bleiben hier ruhig sortiert, mit einem klaren Blick darauf, für wen etwas gedacht ist.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
|
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
|
||||||
</section>
|
</section>
|
||||||
@@ -24,6 +24,14 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Sichtbarkeit
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Tageszeit
|
Tageszeit
|
||||||
<select name="daypart_id">
|
<select name="daypart_id">
|
||||||
@@ -56,10 +64,14 @@
|
|||||||
<h2>{{ item.name }}</h2>
|
<h2>{{ item.name }}</h2>
|
||||||
<span class="status-pill status-{{ item.availability_state }}">{{ availability_labels[item.availability_state] }}</span>
|
<span class="status-pill status-{{ item.availability_state }}">{{ availability_labels[item.availability_state] }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
|
</div>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
{% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %}
|
{% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %}
|
||||||
·
|
· {{ item_kind_labels[item.kind] }}
|
||||||
{{ item_kind_labels[item.kind] }}
|
|
||||||
</p>
|
</p>
|
||||||
{% if item.dayparts %}
|
{% if item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
@@ -76,19 +88,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
|
{% 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>
|
<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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -14,6 +14,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="two-column">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Tagesvorlagen</h2>
|
||||||
|
<a href="{{ url_for('main.day_template_create', source_date=selected_date.isoformat()) }}">Als Vorlage speichern</a>
|
||||||
|
</div>
|
||||||
|
{% if day_templates %}
|
||||||
|
<div class="stack-sections">
|
||||||
|
{% for template in day_templates %}
|
||||||
|
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="target_date" value="{{ selected_date.isoformat() }}">
|
||||||
|
<div class="template-card">
|
||||||
|
<strong>{{ template.name }}</strong>
|
||||||
|
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Vorlage anwenden</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">Wenn du einen Tag öfter wiederverwenden möchtest, kannst du ihn hier als Tagesvorlage speichern.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% if day_hints %}
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>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">
|
<section class="planner-day-stack">
|
||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
<details class="day-tile" id="daypart-{{ section.daypart.id }}" {% if section.is_open %}open{% endif %}>
|
<details class="day-tile" id="daypart-{{ section.daypart.id }}" {% if section.is_open %}open{% endif %}>
|
||||||
@@ -33,44 +72,95 @@
|
|||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="day-tile-body">
|
<div class="day-tile-body">
|
||||||
{% if section.quick_items %}
|
{% if section.balance_suggestion %}
|
||||||
<div class="quick-add-row">
|
<div class="suggestion-card">
|
||||||
{% for item in section.quick_items %}
|
<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()) }}">
|
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||||
<button class="quick-add-button" type="submit">
|
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||||
|
<button class="quick-add-button compact-button" type="submit">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<small>{{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</small>
|
<small>zuhause vorhanden</small>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" class="planner-entry-form">
|
{% 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() }}
|
{{ csrf_input() }}
|
||||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||||
<label class="wide">
|
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||||
Eintrag hinzufügen
|
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||||
<select name="item_id">
|
<button class="quick-add-button compact-button" type="submit">
|
||||||
<option value="">Etwas für {{ section.daypart.name }} wählen</option>
|
<span>{{ item.name }}</span>
|
||||||
{% for item in section.candidates %}
|
{% if item.availability_state == 'home' %}<small>zuhause vorhanden</small>{% endif %}
|
||||||
<option value="{{ item.id }}" {% if section.selected_item_id == item.id %}selected{% endif %}>
|
</button>
|
||||||
{{ item.name }} · {{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}{% if item.dayparts and section.daypart.name not in item.dayparts %} · auch flexibel{% endif %}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="wide">
|
|
||||||
Notiz
|
|
||||||
<input type="text" name="note" placeholder="Optional, wenn eine kleine Erinnerung hilft">
|
|
||||||
</label>
|
|
||||||
<button type="submit">Eintragen</button>
|
|
||||||
</form>
|
</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 %}
|
{% if section.entries %}
|
||||||
<div class="planner-entry-list">
|
<div class="planner-entry-list">
|
||||||
@@ -80,13 +170,20 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>{{ entry.item_name }}</strong>
|
<strong>{{ entry.item_name }}</strong>
|
||||||
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
|
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ entry.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||||
|
<span class="chip">{{ entry.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if entry.can_edit %}
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, date=selected_date.isoformat()) }}">
|
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, date=selected_date.isoformat()) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Entfernen</button>
|
<button class="ghost-button" type="submit">Entfernen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if entry.note %}
|
{% if entry.note %}
|
||||||
<p>{{ entry.note }}</p>
|
<p>{{ entry.note }}</p>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Wochenansicht</p>
|
<p class="eyebrow">Wochenansicht</p>
|
||||||
<h1>Ein sanfter Blick auf die nächsten sieben Tage</h1>
|
<h1>Ein ruhiger Blick auf die nächsten sieben Tage</h1>
|
||||||
<p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben. Wenn etwas noch nicht zuhause ist, landet es dabei automatisch auf der Einkaufsliste.</p>
|
<p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und 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>
|
||||||
@@ -14,6 +14,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="two-column">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Wochenvorlagen</h2>
|
||||||
|
<a href="{{ url_for('main.week_template_create', source_week=week_start.isoformat()) }}">Als Vorlage speichern</a>
|
||||||
|
</div>
|
||||||
|
{% if week_templates %}
|
||||||
|
<div class="stack-sections">
|
||||||
|
{% for template in week_templates %}
|
||||||
|
<form method="post" action="{{ url_for('main.week_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="target_week" value="{{ week_start.isoformat() }}">
|
||||||
|
<div class="template-card">
|
||||||
|
<strong>{{ template.name }}</strong>
|
||||||
|
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Vorlage anwenden</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">Wenn eine Woche sich bewährt hat, kannst du sie hier später als Wochenvorlage wiederverwenden.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% if week_hints %}
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>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>
|
||||||
|
|
||||||
|
{% if upcoming_entries %}
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Kommt später zum Einkauf dazu</h2>
|
||||||
|
<small>{{ household_settings.shopping_prep_days }} Tag{% if household_settings.shopping_prep_days != 1 %}e{% endif %} Vorlauf</small>
|
||||||
|
</div>
|
||||||
|
<div class="chip-row">
|
||||||
|
{% for entry in upcoming_entries %}
|
||||||
|
<span class="chip">{{ entry.item_name }} · ab {{ entry.activation_label }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="week-overview-grid week-board" data-csrf-token="{{ csrf_token_value }}">
|
<section class="week-overview-grid week-board" data-csrf-token="{{ csrf_token_value }}">
|
||||||
{% for card in week_cards %}
|
{% for card in week_cards %}
|
||||||
<article class="week-card">
|
<article class="week-card">
|
||||||
@@ -49,9 +102,9 @@
|
|||||||
{% if slot.entries %}
|
{% if slot.entries %}
|
||||||
<div class="week-entry-stack">
|
<div class="week-entry-stack">
|
||||||
{% for entry in slot.entries %}
|
{% for entry in slot.entries %}
|
||||||
<article class="plan-chip draggable-plan-entry" draggable="true" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}">
|
<article class="plan-chip draggable-plan-entry" draggable="{{ 'true' if entry.can_edit else 'false' }}" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}">
|
||||||
<strong>{{ entry.item_name }}</strong>
|
<strong>{{ entry.item_name }}</strong>
|
||||||
<small>{{ item_kind_labels[entry.item_kind] }}</small>
|
<small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -15,7 +15,10 @@
|
|||||||
<select name="item_id">
|
<select name="item_id">
|
||||||
<option value="">Bestehenden Eintrag hinzufügen</option>
|
<option value="">Bestehenden Eintrag hinzufügen</option>
|
||||||
{% for item in addable_items %}
|
{% for item in addable_items %}
|
||||||
<option value="{{ item.id }}">{{ item.name }} · {{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</option>
|
<option value="{{ item.id }}">
|
||||||
|
{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}
|
||||||
|
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
||||||
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button type="submit">Auf Liste setzen</button>
|
<button type="submit">Auf Liste setzen</button>
|
||||||
@@ -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 %} · hinzugefügt von {{ entry.display_name or entry.username }}{% endif %}</p>
|
<p class="muted">{{ item_kind_labels[entry.item_kind] }}</p>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ entry.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||||
|
<span class="chip">{{ entry.for_label }}</span>
|
||||||
|
{% if entry.needed_for_label %}
|
||||||
|
<span class="chip status-home">
|
||||||
|
Für {{ entry.needed_for_label }}
|
||||||
|
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button type="submit">Eingekauft</button>
|
<button type="submit">Eingekauft</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% 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>Einträge aus Lebensmitteln, Mahlzeitenideen oder dem Archiv lassen sich jederzeit wieder hinzufügen.</p>
|
<p>Einträge aus Lebensmitteln, Vorlagen oder kleinen Paketen lassen sich jederzeit wieder hinzufügen.</p>
|
||||||
|
</section>
|
||||||
|
{% 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 %}
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
Flask==3.1.1
|
Flask==3.1.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
pywebpush==2.3.0
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
export NOURI_DATA_DIR="${NOURI_DATA_DIR:-/app/data}"
|
||||||
|
mkdir -p "${NOURI_DATA_DIR}"
|
||||||
|
touch "${NOURI_DATA_DIR}/nouri.sqlite3"
|
||||||
|
mkdir -p "${NOURI_DATA_DIR}/uploads"
|
||||||
|
|
||||||
|
exec gunicorn \
|
||||||
|
--bind 0.0.0.0:8000 \
|
||||||
|
--workers 2 \
|
||||||
|
--threads 4 \
|
||||||
|
--timeout 60 \
|
||||||
|
wsgi:app
|
||||||