Compare commits
22 Commits
36bde02c54
..
V1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c87d653d6 | |||
| 1490fc8f1d | |||
| 40bab48806 | |||
| ed14dd4aef | |||
| 35e6a7b56e | |||
| 325101da99 | |||
| b0d1cee5f5 | |||
| a26d519cf2 | |||
| 555fddab80 | |||
| 9ff7a6d57c | |||
| cf5157c496 | |||
| dffbe26423 | |||
| 732e7918af | |||
| 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
|
||||
@@ -9,3 +9,6 @@ __pycache__/
|
||||
|
||||
data/
|
||||
instance/
|
||||
.cloudron-push.env
|
||||
.env.local
|
||||
.env.push.local
|
||||
|
||||
@@ -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": "1.1.1",
|
||||
"upstreamVersion": "1.1.1",
|
||||
"healthCheckPath": "/",
|
||||
"httpPort": 8000,
|
||||
"manifestVersion": 2,
|
||||
"addons": {
|
||||
"localstorage": {
|
||||
"sqlite": {
|
||||
"paths": [
|
||||
"/app/data/nouri.sqlite3"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app/code
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PORT=8000
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
sqlite3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd --gid 1000 cloudron \
|
||||
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/cloudron cloudron
|
||||
|
||||
COPY requirements.txt /app/code/
|
||||
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||
|
||||
COPY . /app/code
|
||||
|
||||
# Lokale Daten für den ersten Start sichern und danach /app/code/data auf /app/data zeigen lassen
|
||||
RUN if [ -d /app/code/data ]; then mv /app/code/data /app/bootstrap-data; else mkdir -p /app/bootstrap-data; fi \
|
||||
&& ln -s /app/data /app/code/data \
|
||||
&& chmod +x /app/code/start.sh \
|
||||
&& chown -R cloudron:cloudron /app/code /app/bootstrap-data \
|
||||
&& chown -h cloudron:cloudron /app/code/data
|
||||
|
||||
USER cloudron
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["/app/code/start.sh"]
|
||||
@@ -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/
|
||||
@@ -0,0 +1,41 @@
|
||||
# Push-Setup für Nouri
|
||||
|
||||
## 1. VAPID-Schlüssel erzeugen
|
||||
|
||||
```bash
|
||||
. .venv/bin/activate
|
||||
python scripts/generate_vapid_keys.py
|
||||
```
|
||||
|
||||
Das Script gibt drei Zeilen aus:
|
||||
|
||||
- `NOURI_VAPID_PUBLIC_KEY`
|
||||
- `NOURI_VAPID_PRIVATE_KEY`
|
||||
- `NOURI_VAPID_SUBJECT`
|
||||
|
||||
## 2. In Cloudron eintragen
|
||||
|
||||
In der bestehenden Nouri-App unter `Settings` → `Environment Variables` diese drei Werte anlegen:
|
||||
|
||||
```text
|
||||
NOURI_VAPID_PUBLIC_KEY=...
|
||||
NOURI_VAPID_PRIVATE_KEY=...
|
||||
NOURI_VAPID_SUBJECT=mailto:mail@hnz.io
|
||||
```
|
||||
|
||||
Danach die App neu starten.
|
||||
|
||||
## 3. Auf dem iPhone aktivieren
|
||||
|
||||
1. `nouri.heinz.media` in Safari öffnen
|
||||
2. `Teilen` → `Zum Home-Bildschirm`
|
||||
3. die installierte Web-App öffnen
|
||||
4. in Nouri `Optionen` öffnen
|
||||
5. `Push erlauben` tippen
|
||||
6. danach optional `Test-Mitteilung senden`
|
||||
|
||||
## 4. Bereits vorbereitete lokale Datei
|
||||
|
||||
Wenn lokal bereits eine Datei `.cloudron-push.env` liegt, kannst du deren Werte direkt nach Cloudron übernehmen.
|
||||
|
||||
Die Datei ist absichtlich in `.gitignore`, damit keine geheimen Schlüssel committed werden.
|
||||
@@ -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.
|
||||
|
||||
## Merkmale in Version 0.2
|
||||
## Merkmale in Version 1.0.0
|
||||
|
||||
- Lebensmittel und Mahlzeitenideen anlegen
|
||||
- Fotos lokal hochladen
|
||||
@@ -12,7 +12,18 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein
|
||||
- Tagesplan mit schnellen Vorschlägen je Tageszeit
|
||||
- Wochenansicht für die nächsten 7 Tage
|
||||
- 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
|
||||
- Einkaufsrhythmus mit geplantem Einkaufstag und später aktivierten Bedarfen
|
||||
- PWA-Grundlage mit Web App Manifest, Service Worker und optionalem Web Push
|
||||
|
||||
## Lokal starten
|
||||
|
||||
@@ -32,13 +43,105 @@ Die App legt Daten standardmäßig unter `./data` ab.
|
||||
Wichtige Umgebungsvariablen:
|
||||
|
||||
- `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, lokal standardmäßig `./data`, auf Cloudron `/app/data`
|
||||
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
|
||||
- `NOURI_SECURE_COOKIES`: bei HTTPS in Produktion auf `1` setzen
|
||||
- `NOURI_TIMEZONE`: lokale Zeitzone, z. B. `Europe/Berlin`
|
||||
- `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 und Datenhaltung
|
||||
|
||||
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 SQLite-Schema pragmatisch direkt weiter. Vorhandene Daten bleiben dabei erhalten und werden weiterverwendet.
|
||||
|
||||
## Cloudron-Hinweis
|
||||
Wichtig für die Trennung zwischen lokal und Produktion:
|
||||
|
||||
Für Cloudron später `NOURI_DATA_DIR=/app/data` setzen, damit Datenbank und Uploads persistent liegen.
|
||||
- lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data`
|
||||
- auf Cloudron nutzt Nouri `/app/data`
|
||||
- `data/` ist nicht für Git oder das Paket gedacht
|
||||
- produktive Daten unter `/app/data` bleiben bei Updates erhalten
|
||||
|
||||
## Cloudron
|
||||
|
||||
Nouri ist so vorbereitet, dass Code und persistente Daten sauber getrennt bleiben:
|
||||
|
||||
- Code liegt im Container unter `/app/code`
|
||||
- persistente Daten liegen unter `/app/data`
|
||||
- Datenbank und Uploads werden nicht aus dem lokalen `./data` nach Produktion übernommen
|
||||
- Updates ersetzen den Code, aber nicht die produktiven Inhalte in `/app/data`
|
||||
|
||||
### Neu installieren
|
||||
|
||||
1. Paket oder Image bauen und nach Cloudron hochladen.
|
||||
2. Die App einmal per Cloudron installieren.
|
||||
3. Nach dem ersten Start Nouri öffnen.
|
||||
4. Den ersten Haushalt-Zugang unter `/auth/setup` anlegen.
|
||||
5. Danach Push, Erinnerungen und Einkaufstag in den Optionen einrichten.
|
||||
|
||||
### Bestehende Installation aktualisieren
|
||||
|
||||
Wenn Nouri bereits installiert ist, bitte **kein neues `cloudron install`** ausführen.
|
||||
Stattdessen die bestehende App aktualisieren, zum Beispiel mit:
|
||||
|
||||
```bash
|
||||
cloudron update --no-backup --app <deine-app> --server <dein-server> --token <dein-token>
|
||||
```
|
||||
|
||||
Dabei gilt:
|
||||
|
||||
- produktive Daten unter `/app/data` bleiben erhalten
|
||||
- lokale Testdaten aus `./data` werden nicht mit hochgeladen
|
||||
- die bestehende Installation läuft mit demselben persistenten Datenordner weiter
|
||||
|
||||
### Wichtige Cloudron-Variablen
|
||||
|
||||
Für eine saubere produktive Installation sind diese Werte sinnvoll:
|
||||
|
||||
- `NOURI_DATA_DIR=/app/data`
|
||||
- `NOURI_SECURE_COOKIES=1`
|
||||
- `NOURI_TIMEZONE=Europe/Berlin`
|
||||
- `NOURI_SECRET_KEY=<eigenes-secret>`
|
||||
- `NOURI_VAPID_PUBLIC_KEY=<public-key>`
|
||||
- `NOURI_VAPID_PRIVATE_KEY=<private-key>`
|
||||
- `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io`
|
||||
|
||||
## Push einrichten
|
||||
|
||||
### 1. VAPID-Schlüssel erzeugen
|
||||
|
||||
Die Schritte dafür stehen kompakt in [PUSH_SETUP.md](PUSH_SETUP.md).
|
||||
|
||||
### 2. VAPID-Werte in Cloudron setzen
|
||||
|
||||
Zum Beispiel so:
|
||||
|
||||
```bash
|
||||
cloudron env set --app <deine-app> \
|
||||
NOURI_VAPID_PUBLIC_KEY='...' \
|
||||
NOURI_VAPID_PRIVATE_KEY='...' \
|
||||
NOURI_VAPID_SUBJECT='mailto:mail@hnz.io'
|
||||
```
|
||||
|
||||
Danach die App neu starten:
|
||||
|
||||
```bash
|
||||
cloudron restart --app <deine-app>
|
||||
```
|
||||
|
||||
### 3. Push auf dem Gerät aktivieren
|
||||
|
||||
1. Nouri im Browser oder auf dem iPhone öffnen.
|
||||
2. In `Optionen` auf **Push erlauben** tippen.
|
||||
3. Optional eine Test-Mitteilung senden.
|
||||
4. Auf dem iPhone Nouri am besten zusätzlich zum Home-Bildschirm hinzufügen.
|
||||
|
||||
Wichtig:
|
||||
|
||||
- Push funktioniert nur auf Geräten, die sich einmal aktiv registriert haben.
|
||||
- Ohne VAPID-Werte bleibt Push bewusst deaktiviert.
|
||||
- Die Browser- oder iPhone-Freigabe allein reicht nicht: Hinweise müssen zusätzlich in Nouri eingeschaltet sein.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Nouri 0.5.1
|
||||
|
||||
## Highlights
|
||||
|
||||
- Smartphone-Navigation unten neu als echte Erweiterung umgesetzt
|
||||
- obere Nouri-Leiste auf kleinen Geräten nicht mehr sticky, sondern sauber fest positioniert
|
||||
- PWA-Cache für frische Layout- und Einstellungsänderungen bereinigt
|
||||
- Cloudron-Version auf `0.5.1` angehoben
|
||||
|
||||
## Neu in 0.5.1
|
||||
|
||||
### Mobile Navigation
|
||||
|
||||
- `Mehr` ist auf Smartphones kein schwebendes Overlay mehr.
|
||||
- Die zusätzlichen Punkte klappen jetzt direkt aus der unteren Navigation heraus auf.
|
||||
- Die Zusatzpunkte nutzen dieselbe kompakte Größe wie die unteren Menüpunkte.
|
||||
- Der untere Navigationsbereich wird dabei nicht weichgezeichnet.
|
||||
|
||||
### Mobile Header
|
||||
|
||||
- Die obere Nouri-Leiste scrollt auf kleinen Geräten nicht mehr mit dem Inhalt.
|
||||
- Die bisherige `sticky`-Logik für den Header wurde entfernt, damit es keine widersprüchlichen Zustände mehr gibt.
|
||||
|
||||
### PWA
|
||||
|
||||
- Der Service Worker verwendet einen aktualisierten Cache-Namen.
|
||||
- Navigationsseiten werden frischer geladen, damit Änderungen an Einstellungen und Layout nicht an altem Cache hängen bleiben.
|
||||
|
||||
## Cloudron
|
||||
|
||||
- `CloudronManifest.json` wurde auf `0.5.1` angehoben.
|
||||
- Damit lässt sich das Update sauber als neue App-Version ausrollen.
|
||||
@@ -0,0 +1,57 @@
|
||||
# Nouri 0.6.0
|
||||
|
||||
Nouri 0.6.0 bringt die App näher an einen stabilen 1.0-Stand. Der Schwerpunkt liegt auf Reife, Alltagstauglichkeit und einem ruhigeren, verlässlicheren Gesamteindruck statt auf großen neuen Kernfunktionen.
|
||||
|
||||
## Highlights
|
||||
|
||||
### Design und Brand
|
||||
|
||||
- Neues Nouri-App-Icon für Header, Favicon und PWA
|
||||
- Überarbeitete PWA-Icons inklusive zusätzlicher Größen und maskierbarer Variante
|
||||
- Ruhigeres visuelles Finish bei Karten, Fokuszuständen, Setup und leeren Bereichen
|
||||
- Mobile Header-Logik bereinigt, damit der obere Bereich auf Smartphones nicht fest mitscrollt
|
||||
- Desktop-Navigation wieder klarer als durchlaufende Leiste direkt neben dem Logo
|
||||
|
||||
### Bilder und Performance
|
||||
|
||||
- Bild-Uploads werden jetzt in mehrere sinnvolle Größen abgeleitet
|
||||
- Listen und Formulare nutzen responsive Bildauslieferung statt immer die volle Originalgröße
|
||||
- Statische Assets bekommen sauberes Cache-Busting über Versions-URLs
|
||||
- Uploads und statische Marken-Assets werden vorsichtig browserfreundlich gecacht
|
||||
|
||||
### PWA und iPhone-Nutzung
|
||||
|
||||
- Überarbeitetes Web App Manifest
|
||||
- Verbesserter Service Worker für eine stabilere App-Shell
|
||||
- Kleine Offline-Seite für kurze Verbindungsabbrüche
|
||||
- Bessere Einbindung für Homescreen-Nutzung auf iPhones
|
||||
|
||||
### Backup und Stabilität
|
||||
|
||||
- Admin-Bereich in den Optionen für Backup-Export und Restore
|
||||
- Backup als ZIP mit App-Daten und Uploads
|
||||
- Restore mit klarer Bestätigung statt versehentlicher Überschreibung
|
||||
- Freundlichere Behandlung von zu großen Bild-Uploads
|
||||
- Robusterer Umgang mit vorhandenen älteren Datenstrukturen
|
||||
- Startet lokal jetzt auch ohne Pillow-Build auf Python 3.14, statt schon beim Import zu scheitern
|
||||
|
||||
### Planung und Vorschläge
|
||||
|
||||
- Automatische Mahlzeitenvorschläge robuster aufgebaut
|
||||
- Bausteinlogik für Protein, Kohlenhydrate und Gemüse verbessert
|
||||
- Bestehende Kombinationen werden beim Übernehmen besser wiederverwendet
|
||||
- Suche beim Zusammenstellen von Mahlzeiten zeigt nur die drei passendsten Treffer
|
||||
- Kategorie-Richtung von „Brot & Getreide“ in Richtung „Kohlenhydrate“ weitergeführt
|
||||
|
||||
## Technische Änderungen
|
||||
|
||||
- Neue Hilfsmodule für Bildverarbeitung und Backup/Restore
|
||||
- App-Metadaten-Tabelle für robustere Schema-Verwaltung vorbereitet
|
||||
- Cloudron-Version auf 0.6.0 angehoben
|
||||
- Pillow für Bildverarbeitung ergänzt und auf Python 3.14 lokal als optionale Abhängigkeit abgefedert
|
||||
|
||||
## Hinweise zum Update
|
||||
|
||||
- Nach dem Update kann ein harter Reload im Browser sinnvoll sein, damit neue CSS-, JS- und PWA-Dateien sicher geladen werden.
|
||||
- Vor einem Restore empfiehlt sich immer zuerst ein frischer Backup-Export aus der laufenden App.
|
||||
- Vorhandene Kategorien und ältere Daten werden beim Start weiter normalisiert, statt hart ersetzt zu werden.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Nouri 1.0.0
|
||||
|
||||
Nouri 1.0.0 fasst die ruhige Alltagsplanung, gemeinsame Nutzung im Haushalt und die ersten echten Push-Erinnerungen in einen stabileren Release-Stand zusammen. Der Fokus dieser Version liegt auf Verlässlichkeit, klareren Vorschlägen und einem saubereren produktiven Betrieb auf Cloudron.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Vorschläge lassen sich jetzt gezielter über drei Modi steuern:
|
||||
- `Eher ausgewogen`
|
||||
- `Fitness`
|
||||
- `Proteinbetont`
|
||||
- Lebensmittel können zusätzlich als `Eher leicht`, `Neutral` oder `Eher gehaltvoll` markiert werden.
|
||||
- Push-Erinnerungen für fehlendes Frühstück, Mittagessen und Abendessen sind vorbereitet und in den Optionen pro Nutzer schaltbar.
|
||||
- Push öffnet direkt den passenden Tagesplan und kann eine schon vorbereitete Mahlzeitenidee mitbringen.
|
||||
- Saaten sind in der Kategorien-Logik jetzt separat unter `Passt eher zu` auswählbar.
|
||||
|
||||
## Planung und Vorschläge
|
||||
|
||||
- Nouri bevorzugt bei Vorschlägen weiterhin zuerst vorhandene Mahlzeitenideen.
|
||||
- Wenn noch keine passende Mahlzeitenidee da ist, werden Kombinationen aus den zuhause vorhandenen Lebensmitteln vorgeschlagen.
|
||||
- Für `Fitness` werden proteinbetonte und eher leichte Kombinationen bevorzugt.
|
||||
- Für `Proteinbetont` werden proteinreiche Kombinationen priorisiert, ohne zusätzlich auf „leicht“ zu ziehen.
|
||||
- Im Tagesplan können vorbereitete Vorschläge direkter übernommen werden.
|
||||
|
||||
## Erinnerungen und Push
|
||||
|
||||
- Neue Push-Schalter in den Optionen:
|
||||
- Frühstück um `08:00`
|
||||
- Mittagessen um `12:00`
|
||||
- Abendessen um `18:00`
|
||||
- Die Push-Nachricht enthält nach Möglichkeit direkt einen passenden Vorschlag.
|
||||
- Für den Versand wurde ein kleiner Reminder-Worker ergänzt.
|
||||
- Push bleibt weiterhin optional und funktioniert nur auf Geräten, die sich aktiv registriert haben.
|
||||
|
||||
## Sicherheit und Stabilität
|
||||
|
||||
- Passwortprüfung wurde angezogen.
|
||||
- Sichere Cookie-Konfiguration für HTTPS ist vorbereitet.
|
||||
- Zusätzliche Sicherheitsheader werden gesetzt.
|
||||
- Die Schema-Upgrades wurden weitergezogen, damit neue Einstellungen und Reminder-Daten sauber in bestehende Installationen einlaufen.
|
||||
|
||||
## Cloudron und Betrieb
|
||||
|
||||
- README wurde für Cloudron klarer dokumentiert:
|
||||
- Neuinstallation
|
||||
- Update statt Neuinstallation
|
||||
- persistente Daten unter `/app/data`
|
||||
- wichtige Umgebungsvariablen
|
||||
- VAPID- und Push-Einrichtung
|
||||
- Cloudron-Paketversion und App-Version stehen jetzt auf `1.0.0`.
|
||||
- Der Service Worker nutzt einen neuen Cache-Namen für den Release-Stand `1.0.0`.
|
||||
|
||||
## Upgrade-Hinweis
|
||||
|
||||
- Bestehende SQLite-Daten bleiben erhalten.
|
||||
- Neue Felder und Tabellen werden beim Start ergänzt.
|
||||
- Nach dem Update lohnt sich ein kurzer Blick in `Optionen`, damit Vorschlagsstil, Push und Erinnerungen einmal bewusst gesetzt sind.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Nouri 1.1.1
|
||||
|
||||
Nouri 1.1.1 ist ein kleiner Feinschliff-Release. Der Schwerpunkt liegt auf saubereren Bezeichnungen in der Oberfläche und einer einheitlichen Versionsanhebung für App und Cloudron-Paket.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Beschriftungen im Plan werden wieder korrekt großgeschrieben, zum Beispiel `Mahlzeitideen`
|
||||
- App-Version und Cloudron-Version stehen jetzt auf `1.1.1`
|
||||
- Versions-Fallback in der App wurde an den neuen Stand angepasst
|
||||
|
||||
## Neu in 1.1.1
|
||||
|
||||
### Oberfläche
|
||||
|
||||
- Die automatisch kleingeschriebene Anzeige im Tagesplan wurde korrigiert.
|
||||
- Begriffe wie `Mahlzeitideen` erscheinen wieder so, wie sie in der App gedacht sind.
|
||||
|
||||
### Versionierung
|
||||
|
||||
- `CloudronManifest.json` wurde auf `1.1.1` angehoben.
|
||||
- Der interne App-Fallback in `nouri/__init__.py` wurde ebenfalls auf `1.1.1` gesetzt.
|
||||
- Die Schema-Version in `nouri/db.py` folgt jetzt ebenfalls `1.1.1`.
|
||||
|
||||
## Cloudron
|
||||
|
||||
- Das Update kann sauber als neue Version ausgerollt werden.
|
||||
- Footer, Release-Link und Versionsanzeige greifen damit wieder auf einen konsistenten Stand zu.
|
||||
@@ -1,20 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, send_from_directory
|
||||
from flask import Flask, flash, g, redirect, request, send_from_directory, url_for
|
||||
|
||||
from . import db
|
||||
from .admin import admin_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,
|
||||
ENERGY_DENSITY_LABELS,
|
||||
ENERGY_DENSITY_OPTIONS,
|
||||
ITEM_KIND_LABELS,
|
||||
ITEM_KIND_SINGULAR_LABELS,
|
||||
NOTIFICATION_CHANNEL_OPTIONS,
|
||||
ROLE_LABELS,
|
||||
SUGGESTION_STYLE_LABELS,
|
||||
SUGGESTION_STYLE_OPTIONS,
|
||||
VISIBILITY_DESCRIPTIONS,
|
||||
VISIBILITY_LABELS,
|
||||
WEEKDAY_OPTIONS,
|
||||
)
|
||||
from .images import ensure_upload_structure, image_sizes, image_srcset, image_url
|
||||
from .main import main_bp
|
||||
|
||||
|
||||
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||
DEFAULT_RELEASE_URL = "https://git.hnz.io/hnzio/nouri-App/releases"
|
||||
|
||||
|
||||
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 load_app_version(root_dir: Path) -> str:
|
||||
env_version = os.environ.get("NOURI_APP_VERSION", "").strip()
|
||||
if env_version:
|
||||
return env_version
|
||||
|
||||
manifest_path = root_dir / "CloudronManifest.json"
|
||||
if manifest_path.exists():
|
||||
try:
|
||||
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
manifest_data = {}
|
||||
manifest_version = str(
|
||||
manifest_data.get("upstreamVersion")
|
||||
or manifest_data.get("version")
|
||||
or ""
|
||||
).strip()
|
||||
if manifest_version:
|
||||
return manifest_version
|
||||
return "1.1.1"
|
||||
|
||||
|
||||
def load_release_url() -> str:
|
||||
return os.environ.get("NOURI_RELEASE_URL", DEFAULT_RELEASE_URL).strip() or DEFAULT_RELEASE_URL
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
@@ -22,40 +86,128 @@ def create_app() -> Flask:
|
||||
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
|
||||
upload_dir = data_dir / "uploads"
|
||||
db_path = data_dir / "nouri.sqlite3"
|
||||
app_version = load_app_version(root_dir)
|
||||
release_url = load_release_url()
|
||||
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
ensure_upload_structure(upload_dir)
|
||||
|
||||
app = Flask(__name__, instance_relative_config=False)
|
||||
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),
|
||||
DATA_DIR=str(data_dir),
|
||||
UPLOAD_FOLDER=str(upload_dir),
|
||||
MAX_CONTENT_LENGTH=int(os.environ.get("NOURI_MAX_UPLOAD_MB", "5")) * 1024 * 1024,
|
||||
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1",
|
||||
APP_VERSION=app_version,
|
||||
RELEASE_URL=release_url,
|
||||
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
|
||||
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_db_if_needed(app)
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals() -> dict[str, object]:
|
||||
def asset_url(filename: str) -> str:
|
||||
file_path = root_dir / "nouri" / "static" / filename
|
||||
version = int(file_path.stat().st_mtime) if file_path.exists() else app.config["APP_VERSION"]
|
||||
return url_for("static", filename=filename, v=version)
|
||||
|
||||
return {
|
||||
"item_kind_labels": ITEM_KIND_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,
|
||||
"energy_density_options": ENERGY_DENSITY_OPTIONS,
|
||||
"energy_density_labels": ENERGY_DENSITY_LABELS,
|
||||
"suggestion_style_options": SUGGESTION_STYLE_OPTIONS,
|
||||
"suggestion_style_labels": SUGGESTION_STYLE_LABELS,
|
||||
"visibility_labels": VISIBILITY_LABELS,
|
||||
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
|
||||
"role_labels": ROLE_LABELS,
|
||||
"weekday_options": WEEKDAY_OPTIONS,
|
||||
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
|
||||
"today": date.today(),
|
||||
"app_version": app.config["APP_VERSION"],
|
||||
"app_release_url": app.config["RELEASE_URL"],
|
||||
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
|
||||
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
|
||||
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
||||
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
|
||||
"is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin",
|
||||
"asset_url": asset_url,
|
||||
"image_url": lambda filename, variant="md": image_url(
|
||||
filename,
|
||||
url_for,
|
||||
variant,
|
||||
upload_folder=app.config["UPLOAD_FOLDER"],
|
||||
),
|
||||
"image_srcset": lambda filename: image_srcset(
|
||||
filename,
|
||||
url_for,
|
||||
upload_folder=app.config["UPLOAD_FOLDER"],
|
||||
),
|
||||
"image_sizes": image_sizes,
|
||||
}
|
||||
|
||||
@app.get("/uploads/<path:filename>")
|
||||
def uploaded_file(filename: str):
|
||||
return send_from_directory(app.config["UPLOAD_FOLDER"], filename)
|
||||
response = send_from_directory(app.config["UPLOAD_FOLDER"], filename, max_age=60 * 60 * 24 * 30)
|
||||
response.headers["Cache-Control"] = "public, max-age=2592000, immutable"
|
||||
return response
|
||||
|
||||
@app.get("/app.webmanifest")
|
||||
def webmanifest():
|
||||
response = send_from_directory(
|
||||
root_dir / "nouri" / "static" / "pwa",
|
||||
"app.webmanifest",
|
||||
mimetype="application/manifest+json",
|
||||
max_age=60 * 30,
|
||||
)
|
||||
response.headers["Cache-Control"] = "public, max-age=1800"
|
||||
return response
|
||||
|
||||
@app.get("/service-worker.js")
|
||||
def service_worker():
|
||||
response = send_from_directory(
|
||||
root_dir / "nouri" / "static" / "pwa",
|
||||
"service-worker.js",
|
||||
mimetype="application/javascript",
|
||||
max_age=0,
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
@app.after_request
|
||||
def apply_cache_policy(response):
|
||||
if response.direct_passthrough:
|
||||
return response
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
if content_type.startswith("text/html"):
|
||||
response.headers.setdefault("Cache-Control", "no-store, max-age=0")
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
|
||||
return response
|
||||
|
||||
@app.errorhandler(413)
|
||||
def upload_too_large(_error):
|
||||
flash("Das hochgeladene Bild ist etwas zu groß. Eine kleinere Datei passt hier besser.", "error")
|
||||
return redirect(request.referrer or url_for("main.dashboard"))
|
||||
|
||||
return app
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
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"))
|
||||
|
||||
|
||||
@admin_bp.post("/categories/<int:category_id>/delete")
|
||||
@admin_required
|
||||
def category_delete(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"))
|
||||
|
||||
if category["name"] in DEFAULT_CATEGORIES:
|
||||
flash("Standardkategorien bleiben erhalten. Du kannst sie bei Bedarf pausieren.", "info")
|
||||
return redirect(url_for("admin.category_settings"))
|
||||
|
||||
get_db().execute(
|
||||
"DELETE FROM household_categories WHERE id = ? AND household_id = ?",
|
||||
(category_id, g.user["household_id"]),
|
||||
)
|
||||
get_db().commit()
|
||||
flash("Die Kategorie wurde entfernt.", "success")
|
||||
return redirect(url_for("admin.category_settings"))
|
||||
@@ -16,7 +16,8 @@ from flask import (
|
||||
from markupsafe import Markup
|
||||
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")
|
||||
@@ -32,6 +33,19 @@ def login_required(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:
|
||||
token = session.get("_csrf_token")
|
||||
if not token:
|
||||
@@ -39,6 +53,38 @@ def ensure_csrf_token() -> str:
|
||||
return token
|
||||
|
||||
|
||||
def normalize_login_value(raw: str) -> str:
|
||||
return raw.strip().lower()
|
||||
|
||||
|
||||
def validate_password_strength(password: str) -> str | None:
|
||||
if len(password or "") < 10:
|
||||
return "Bitte ein etwas längeres Passwort wählen."
|
||||
return None
|
||||
|
||||
|
||||
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
|
||||
def inject_csrf_input():
|
||||
return {
|
||||
@@ -56,9 +102,18 @@ def load_logged_in_user():
|
||||
g.user = None
|
||||
else:
|
||||
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,),
|
||||
).fetchone()
|
||||
if g.user is not None and not g.user["is_active"]:
|
||||
session.clear()
|
||||
g.user = None
|
||||
|
||||
endpoint = request.endpoint or ""
|
||||
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}:
|
||||
@@ -78,27 +133,34 @@ def setup():
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
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()
|
||||
password = request.form.get("password", "")
|
||||
password_repeat = request.form.get("password_repeat", "")
|
||||
database = get_db()
|
||||
|
||||
error = None
|
||||
if not username:
|
||||
error = "Bitte einen Benutzernamen eintragen."
|
||||
elif not password:
|
||||
error = validate_identity_fields(database, username, email)
|
||||
if error is None and not password:
|
||||
error = "Bitte ein Passwort vergeben."
|
||||
elif password != password_repeat:
|
||||
elif error is None and password != password_repeat:
|
||||
error = "Die Passwörter stimmen nicht überein."
|
||||
elif error is None:
|
||||
error = validate_password_strength(password)
|
||||
|
||||
if error is None:
|
||||
database = get_db()
|
||||
database.execute(
|
||||
"INSERT INTO households (name) VALUES (?)",
|
||||
(household_name,),
|
||||
)
|
||||
household_id = ensure_default_household(database)
|
||||
database.execute(
|
||||
"""
|
||||
INSERT INTO users (username, display_name, password_hash)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO users (household_id, username, email, display_name, role, password_hash)
|
||||
VALUES (?, ?, ?, ?, 'admin', ?)
|
||||
""",
|
||||
(username, display_name, generate_password_hash(password)),
|
||||
(household_id, username, email, display_name, generate_password_hash(password)),
|
||||
)
|
||||
database.commit()
|
||||
flash("Der erste Haushalt-Zugang ist angelegt. Du kannst dich jetzt anmelden.", "success")
|
||||
@@ -115,22 +177,28 @@ def login():
|
||||
return redirect(url_for("auth.setup"))
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip().lower()
|
||||
identity = normalize_login_value(request.form.get("username", ""))
|
||||
password = request.form.get("password", "")
|
||||
remember_me = request.form.get("remember_me") == "1"
|
||||
database = get_db()
|
||||
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()
|
||||
|
||||
error = None
|
||||
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:
|
||||
session.clear()
|
||||
# Opt-in long-lived session so the shared household device stays low-friction.
|
||||
session.permanent = remember_me
|
||||
session["user_id"] = user["id"]
|
||||
ensure_csrf_token()
|
||||
@@ -141,8 +209,115 @@ def login():
|
||||
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."
|
||||
else:
|
||||
error = validate_password_strength(new_password)
|
||||
|
||||
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")
|
||||
def logout():
|
||||
session.clear()
|
||||
flash("Du bist abgemeldet.", "info")
|
||||
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 password:
|
||||
password_error = validate_password_strength(password)
|
||||
if password_error:
|
||||
return password_error
|
||||
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
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BACKUP_FILENAME_PREFIX = "nouri-backup"
|
||||
RESTORE_CONFIRMATION_TEXT = "WIEDERHERSTELLEN"
|
||||
|
||||
|
||||
def list_backup_tables(database: sqlite3.Connection) -> list[str]:
|
||||
rows = database.execute(
|
||||
"""
|
||||
SELECT name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY name
|
||||
"""
|
||||
).fetchall()
|
||||
return [row["name"] for row in rows]
|
||||
|
||||
|
||||
def export_backup_archive(
|
||||
database: sqlite3.Connection,
|
||||
upload_folder: str | Path,
|
||||
app_version: str,
|
||||
) -> tuple[str, str]:
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
backup_name = f"{BACKUP_FILENAME_PREFIX}-{timestamp}.zip"
|
||||
temp_handle = tempfile.NamedTemporaryFile(prefix="nouri-backup-", suffix=".zip", delete=False)
|
||||
temp_handle.close()
|
||||
archive_path = temp_handle.name
|
||||
|
||||
tables = list_backup_tables(database)
|
||||
payload = {
|
||||
"meta": {
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"app_version": app_version,
|
||||
"format_version": 1,
|
||||
},
|
||||
"tables": {},
|
||||
}
|
||||
|
||||
for table_name in tables:
|
||||
rows = database.execute(f"SELECT * FROM {table_name}").fetchall()
|
||||
payload["tables"][table_name] = [dict(row) for row in rows]
|
||||
|
||||
uploads_root = Path(upload_folder)
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("backup.json", json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
if uploads_root.exists():
|
||||
for file_path in uploads_root.rglob("*"):
|
||||
if file_path.is_file():
|
||||
relative_path = file_path.relative_to(uploads_root)
|
||||
archive.write(file_path, f"uploads/{relative_path.as_posix()}")
|
||||
|
||||
return archive_path, backup_name
|
||||
|
||||
|
||||
def _extract_uploads_to_temp(archive: zipfile.ZipFile) -> Path:
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="nouri-restore-uploads-"))
|
||||
for member in archive.infolist():
|
||||
if not member.filename.startswith("uploads/") or member.is_dir():
|
||||
continue
|
||||
relative_target = member.filename.removeprefix("uploads/").lstrip("/")
|
||||
if not relative_target:
|
||||
continue
|
||||
target_path = temp_dir / relative_target
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with archive.open(member, "r") as source, target_path.open("wb") as destination:
|
||||
shutil.copyfileobj(source, destination)
|
||||
return temp_dir
|
||||
|
||||
|
||||
def _replace_uploads(temp_dir: Path, upload_folder: str | Path) -> None:
|
||||
upload_root = Path(upload_folder)
|
||||
previous_root = upload_root.with_name(f"{upload_root.name}-previous")
|
||||
if previous_root.exists():
|
||||
shutil.rmtree(previous_root)
|
||||
if upload_root.exists():
|
||||
upload_root.rename(previous_root)
|
||||
upload_root.mkdir(parents=True, exist_ok=True)
|
||||
for file_path in temp_dir.rglob("*"):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
relative_path = file_path.relative_to(temp_dir)
|
||||
target_path = upload_root / relative_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(file_path, target_path)
|
||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||
shutil.rmtree(previous_root, ignore_errors=True)
|
||||
|
||||
|
||||
def restore_backup_archive(
|
||||
database: sqlite3.Connection,
|
||||
upload_folder: str | Path,
|
||||
backup_file,
|
||||
) -> dict:
|
||||
backup_bytes = backup_file.read()
|
||||
if not backup_bytes:
|
||||
raise ValueError("Bitte ein gültiges Backup auswählen.")
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(backup_bytes)) as archive:
|
||||
try:
|
||||
backup_payload = json.loads(archive.read("backup.json").decode("utf-8"))
|
||||
except KeyError as exc:
|
||||
raise ValueError("Im Backup fehlt die Datei backup.json.") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError("Das Backup konnte nicht gelesen werden.") from exc
|
||||
|
||||
tables = backup_payload.get("tables")
|
||||
if not isinstance(tables, dict):
|
||||
raise ValueError("Das Backup enthält keine gültigen Tabellen-Daten.")
|
||||
|
||||
current_tables = list_backup_tables(database)
|
||||
restore_tables = [table for table in current_tables if table in tables]
|
||||
|
||||
upload_temp_dir = _extract_uploads_to_temp(archive)
|
||||
|
||||
try:
|
||||
database.execute("PRAGMA foreign_keys = OFF")
|
||||
try:
|
||||
for table_name in reversed(restore_tables):
|
||||
database.execute(f"DELETE FROM {table_name}")
|
||||
database.execute("DELETE FROM sqlite_sequence")
|
||||
|
||||
for table_name in restore_tables:
|
||||
rows = tables.get(table_name, [])
|
||||
if not rows:
|
||||
continue
|
||||
columns = list(rows[0].keys())
|
||||
placeholders = ", ".join(["?"] * len(columns))
|
||||
column_list = ", ".join(columns)
|
||||
for row in rows:
|
||||
values = [row.get(column) for column in columns]
|
||||
database.execute(
|
||||
f"INSERT INTO {table_name} ({column_list}) VALUES ({placeholders})",
|
||||
values,
|
||||
)
|
||||
finally:
|
||||
database.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
_replace_uploads(upload_temp_dir, upload_folder)
|
||||
except Exception:
|
||||
shutil.rmtree(upload_temp_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
return backup_payload.get("meta", {})
|
||||
@@ -7,8 +7,8 @@ DAYPARTS = [
|
||||
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
|
||||
]
|
||||
|
||||
CATEGORIES = [
|
||||
"Brot & Getreide",
|
||||
DEFAULT_CATEGORIES = [
|
||||
"Kohlenhydrate",
|
||||
"Milchprodukt",
|
||||
"Obst",
|
||||
"Gemüse",
|
||||
@@ -20,6 +20,84 @@ CATEGORIES = [
|
||||
"Kleines Essen",
|
||||
]
|
||||
|
||||
DEFAULT_CATEGORY_BUILDERS = {
|
||||
"Kohlenhydrate": "carb",
|
||||
"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",
|
||||
"seeds": "Saaten",
|
||||
"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.",
|
||||
"seeds": "Passt eher zu Saaten.",
|
||||
"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()]
|
||||
|
||||
ENERGY_DENSITY_OPTIONS = [
|
||||
("low", "Eher leicht"),
|
||||
("neutral", "Neutral"),
|
||||
("high", "Eher gehaltvoll"),
|
||||
]
|
||||
|
||||
ENERGY_DENSITY_LABELS = {
|
||||
"low": "Eher leicht",
|
||||
"neutral": "Neutral",
|
||||
"high": "Eher gehaltvoll",
|
||||
}
|
||||
|
||||
SUGGESTION_STYLE_OPTIONS = [
|
||||
("balanced", "Eher ausgewogen"),
|
||||
("fitness", "Fitness"),
|
||||
("protein", "Proteinbetont"),
|
||||
]
|
||||
|
||||
SUGGESTION_STYLE_LABELS = {
|
||||
"balanced": "Eher ausgewogen",
|
||||
"fitness": "Fitness",
|
||||
"protein": "Proteinbetont",
|
||||
}
|
||||
|
||||
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 = {
|
||||
"food": "Lebensmittel",
|
||||
"meal": "Mahlzeitenideen",
|
||||
@@ -35,3 +113,41 @@ AVAILABILITY_LABELS = {
|
||||
"home": "Zuhause",
|
||||
"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,9 @@ from flask import Flask, current_app, g
|
||||
from flask.cli import with_appcontext
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from .constants import DAYPARTS
|
||||
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
|
||||
|
||||
CURRENT_SCHEMA_VERSION = "1.1.1"
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
@@ -16,9 +18,11 @@ def get_db() -> sqlite3.Connection:
|
||||
g.db = sqlite3.connect(
|
||||
current_app.config["DATABASE_PATH"],
|
||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
||||
timeout=30,
|
||||
)
|
||||
g.db.row_factory = sqlite3.Row
|
||||
g.db.execute("PRAGMA foreign_keys = ON")
|
||||
g.db.execute("PRAGMA busy_timeout = 30000")
|
||||
return g.db
|
||||
|
||||
|
||||
@@ -28,9 +32,457 @@ def close_db(_error=None) -> None:
|
||||
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 ensure_meta_table(database: sqlite3.Connection) -> None:
|
||||
database.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def get_meta(database: sqlite3.Connection, key: str) -> str | None:
|
||||
row = database.execute("SELECT value FROM app_meta WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else None
|
||||
|
||||
|
||||
def set_meta(database: sqlite3.Connection, key: str, value: str) -> None:
|
||||
database.execute(
|
||||
"""
|
||||
INSERT INTO app_meta (key, value, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(key, value),
|
||||
)
|
||||
|
||||
|
||||
def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||
ensure_meta_table(database)
|
||||
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'")
|
||||
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
||||
|
||||
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',
|
||||
suggestion_style TEXT NOT NULL DEFAULT 'balanced',
|
||||
energy_preference TEXT NOT NULL DEFAULT 'neutral',
|
||||
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,
|
||||
push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
|
||||
push_missing_lunch INTEGER NOT NULL DEFAULT 0,
|
||||
push_missing_dinner INTEGER NOT NULL DEFAULT 0,
|
||||
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 reminder_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
event_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, event_key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
database.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS hidden_generated_suggestions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
suggestion_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, suggestion_key),
|
||||
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'")
|
||||
|
||||
if table_exists(database, "user_settings"):
|
||||
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
||||
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
||||
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
|
||||
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
|
||||
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
|
||||
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):
|
||||
legacy = database.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM household_categories
|
||||
WHERE household_id = ? AND name = 'Brot & Getreide'
|
||||
LIMIT 1
|
||||
""",
|
||||
(household_id,),
|
||||
).fetchone()
|
||||
updated = database.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM household_categories
|
||||
WHERE household_id = ? AND name = 'Kohlenhydrate'
|
||||
LIMIT 1
|
||||
""",
|
||||
(household_id,),
|
||||
).fetchone()
|
||||
if legacy and not updated:
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE household_categories
|
||||
SET name = 'Kohlenhydrate', builder_key = 'carb'
|
||||
WHERE id = ?
|
||||
""",
|
||||
(legacy["id"],),
|
||||
)
|
||||
database.execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET category = 'Kohlenhydrate'
|
||||
WHERE household_id = ? AND category = 'Brot & Getreide'
|
||||
""",
|
||||
(household_id,),
|
||||
)
|
||||
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:
|
||||
ensure_meta_table(database)
|
||||
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 = 5 WHERE shopping_weekday IS NULL")
|
||||
database.execute("UPDATE households SET shopping_prep_days = 1 WHERE shopping_prep_days IS NULL")
|
||||
database.execute(
|
||||
"UPDATE households SET shopping_reminder_time = '18:00' WHERE shopping_reminder_time IS NULL OR shopping_reminder_time = ''"
|
||||
)
|
||||
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, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
||||
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
|
||||
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
|
||||
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
||||
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
||||
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
|
||||
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
|
||||
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
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("UPDATE items SET energy_density = 'neutral' WHERE energy_density IS NULL OR energy_density = ''")
|
||||
database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''")
|
||||
database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''")
|
||||
database.execute("UPDATE user_settings SET push_missing_breakfast = 0 WHERE push_missing_breakfast IS NULL")
|
||||
database.execute("UPDATE user_settings SET push_missing_lunch = 0 WHERE push_missing_lunch IS NULL")
|
||||
database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner IS NULL")
|
||||
|
||||
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)
|
||||
"""
|
||||
)
|
||||
database.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_hidden_generated_suggestions_user
|
||||
ON hidden_generated_suggestions (user_id)
|
||||
"""
|
||||
)
|
||||
set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION)
|
||||
|
||||
|
||||
def apply_schema(database: sqlite3.Connection) -> None:
|
||||
bootstrap_legacy_schema(database)
|
||||
schema_path = Path(__file__).with_name("schema.sql")
|
||||
database.executescript(schema_path.read_text(encoding="utf-8"))
|
||||
ensure_schema_upgrades(database)
|
||||
sync_dayparts(database)
|
||||
|
||||
|
||||
@@ -69,6 +521,18 @@ def user_count() -> int:
|
||||
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")
|
||||
@with_appcontext
|
||||
def init_db_command() -> None:
|
||||
@@ -80,15 +544,25 @@ def init_db_command() -> None:
|
||||
@click.argument("username")
|
||||
@click.argument("password")
|
||||
@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
|
||||
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()
|
||||
household_id = ensure_default_household(database)
|
||||
database.execute(
|
||||
"""
|
||||
INSERT INTO users (username, display_name, password_hash)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO users (household_id, username, email, display_name, role, password_hash)
|
||||
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()
|
||||
click.echo(f"User '{username}' created.")
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
PILLOW_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover - local fallback when Pillow is unavailable
|
||||
Image = None
|
||||
ImageOps = None
|
||||
UnidentifiedImageError = OSError
|
||||
PILLOW_AVAILABLE = False
|
||||
|
||||
|
||||
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
||||
IMAGE_VARIANTS = {
|
||||
"sm": {"width": 320, "quality": 76},
|
||||
"md": {"width": 720, "quality": 82},
|
||||
"lg": {"width": 1280, "quality": 86},
|
||||
}
|
||||
DEFAULT_RENDERED_FORMAT = "webp"
|
||||
ORIGINAL_MAX_WIDTH = 1600
|
||||
|
||||
|
||||
def allowed_image_file(filename: str) -> bool:
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
def ensure_upload_structure(upload_folder: str | Path) -> None:
|
||||
upload_root = Path(upload_folder)
|
||||
upload_root.mkdir(parents=True, exist_ok=True)
|
||||
(upload_root / "variants").mkdir(parents=True, exist_ok=True)
|
||||
for variant_name in IMAGE_VARIANTS:
|
||||
(upload_root / "variants" / variant_name).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def build_variant_filename(filename: str, variant_name: str) -> str:
|
||||
source = Path(filename)
|
||||
return f"{source.stem}__{variant_name}.webp"
|
||||
|
||||
|
||||
def build_variant_relative_path(filename: str, variant_name: str) -> str:
|
||||
return f"variants/{variant_name}/{build_variant_filename(filename, variant_name)}"
|
||||
|
||||
|
||||
def remove_photo_assets(upload_folder: str | Path, filename: str | None) -> None:
|
||||
if not filename:
|
||||
return
|
||||
upload_root = Path(upload_folder)
|
||||
original_path = upload_root / filename
|
||||
if original_path.exists():
|
||||
original_path.unlink()
|
||||
for variant_name in IMAGE_VARIANTS:
|
||||
variant_path = upload_root / build_variant_relative_path(filename, variant_name)
|
||||
if variant_path.exists():
|
||||
variant_path.unlink()
|
||||
|
||||
|
||||
def _open_image(upload: FileStorage) -> Image.Image:
|
||||
if not PILLOW_AVAILABLE or Image is None or ImageOps is None:
|
||||
raise OSError("Pillow ist nicht verfügbar.")
|
||||
upload.stream.seek(0)
|
||||
image = Image.open(upload.stream)
|
||||
image.load()
|
||||
return ImageOps.exif_transpose(image)
|
||||
|
||||
|
||||
def _prepare_image(image: Image.Image) -> Image.Image:
|
||||
if not PILLOW_AVAILABLE:
|
||||
return image
|
||||
if image.mode not in {"RGB", "RGBA"}:
|
||||
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||
return image
|
||||
|
||||
|
||||
def _resize_copy(image: Image.Image, width: int) -> Image.Image:
|
||||
resized = image.copy()
|
||||
resized.thumbnail((width, width * 3), Image.Resampling.LANCZOS)
|
||||
return resized
|
||||
|
||||
|
||||
def _save_image(image: Image.Image, destination: Path, quality: int) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(
|
||||
destination,
|
||||
format=DEFAULT_RENDERED_FORMAT.upper(),
|
||||
quality=quality,
|
||||
method=6,
|
||||
)
|
||||
|
||||
|
||||
def save_photo_with_variants(
|
||||
upload: FileStorage | None,
|
||||
upload_folder: str | Path,
|
||||
current_filename: str | None = None,
|
||||
) -> str | None:
|
||||
if not upload or not upload.filename:
|
||||
return current_filename
|
||||
|
||||
if not allowed_image_file(upload.filename):
|
||||
raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.")
|
||||
|
||||
ensure_upload_structure(upload_folder)
|
||||
|
||||
original_name = secure_filename(upload.filename)
|
||||
extension = original_name.rsplit(".", 1)[1].lower()
|
||||
|
||||
try:
|
||||
image = _prepare_image(_open_image(upload))
|
||||
filename = f"{uuid.uuid4().hex}.webp"
|
||||
original_path = Path(upload_folder) / filename
|
||||
optimized = _resize_copy(image, ORIGINAL_MAX_WIDTH)
|
||||
_save_image(optimized, original_path, quality=88)
|
||||
for variant_name, config in IMAGE_VARIANTS.items():
|
||||
variant_image = _resize_copy(image, int(config["width"]))
|
||||
variant_path = Path(upload_folder) / build_variant_relative_path(filename, variant_name)
|
||||
_save_image(variant_image, variant_path, quality=int(config["quality"]))
|
||||
except (UnidentifiedImageError, OSError, ValueError):
|
||||
filename = f"{uuid.uuid4().hex}.{extension}"
|
||||
original_path = Path(upload_folder) / filename
|
||||
upload.stream.seek(0)
|
||||
upload.save(original_path)
|
||||
|
||||
if current_filename and current_filename != filename:
|
||||
remove_photo_assets(upload_folder, current_filename)
|
||||
|
||||
return filename
|
||||
|
||||
|
||||
def image_url(filename: str | None, url_builder, variant: str = "md", upload_folder: str | Path | None = None) -> str | None:
|
||||
if not filename:
|
||||
return None
|
||||
if variant in IMAGE_VARIANTS:
|
||||
if upload_folder is not None:
|
||||
variant_path = Path(upload_folder) / build_variant_relative_path(filename, variant)
|
||||
if variant_path.exists():
|
||||
return url_builder("uploaded_file", filename=build_variant_relative_path(filename, variant))
|
||||
else:
|
||||
return url_builder("uploaded_file", filename=build_variant_relative_path(filename, variant))
|
||||
return url_builder("uploaded_file", filename=filename)
|
||||
|
||||
|
||||
def image_srcset(filename: str | None, url_builder, upload_folder: str | Path | None = None) -> str:
|
||||
if not filename:
|
||||
return ""
|
||||
parts = []
|
||||
for variant_name, config in IMAGE_VARIANTS.items():
|
||||
variant_url = image_url(filename, url_builder, variant_name, upload_folder=upload_folder)
|
||||
if variant_url and (not upload_folder or variant_url != url_builder("uploaded_file", filename=filename)):
|
||||
parts.append(f"{variant_url} {config['width']}w")
|
||||
original_width = max(config["width"] for config in IMAGE_VARIANTS.values()) + 320
|
||||
parts.append(f"{url_builder('uploaded_file', filename=filename)} {original_width}w")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def image_sizes(card: str = "grid") -> str:
|
||||
if card == "detail":
|
||||
return "(max-width: 720px) 100vw, 720px"
|
||||
return "(max-width: 720px) 42vw, (max-width: 1080px) 28vw, 180px"
|
||||
|
||||
|
||||
def upload_file_size_ok(upload: FileStorage | None, max_bytes: int) -> bool:
|
||||
if not upload or not upload.filename:
|
||||
return True
|
||||
stream = upload.stream
|
||||
current_position = stream.tell()
|
||||
stream.seek(0, os.SEEK_END)
|
||||
size = stream.tell()
|
||||
stream.seek(current_position)
|
||||
return size <= max_bytes
|
||||
@@ -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
|
||||
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
from urllib.parse import quote
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from flask import current_app, g
|
||||
|
||||
from .db import get_db
|
||||
from .main import build_home_recipe_suggestions, get_user_settings
|
||||
from .push import send_push_message
|
||||
|
||||
|
||||
MEAL_PUSH_RULES = [
|
||||
{"slug": "breakfast", "setting": "push_missing_breakfast", "hour": 8, "minute": 0, "end_hour": 12, "label": "Frühstück"},
|
||||
{"slug": "lunch", "setting": "push_missing_lunch", "hour": 12, "minute": 0, "end_hour": 18, "label": "Mittagessen"},
|
||||
{"slug": "dinner", "setting": "push_missing_dinner", "hour": 18, "minute": 0, "end_hour": 24, "label": "Abendessen"},
|
||||
]
|
||||
|
||||
|
||||
def current_local_time() -> datetime:
|
||||
timezone_name = current_app.config.get("TIMEZONE", "Europe/Berlin")
|
||||
try:
|
||||
timezone = ZoneInfo(timezone_name)
|
||||
except Exception:
|
||||
timezone = ZoneInfo("Europe/Berlin")
|
||||
return datetime.now(timezone)
|
||||
|
||||
|
||||
def push_delivery_channel_enabled(settings: dict) -> bool:
|
||||
return (
|
||||
settings.get("reminders_enabled")
|
||||
and settings.get("push_enabled")
|
||||
and settings.get("notification_channel") in {"push", "both"}
|
||||
)
|
||||
|
||||
|
||||
def fetch_push_ready_users() -> list:
|
||||
return get_db().execute(
|
||||
"""
|
||||
SELECT users.*
|
||||
FROM users
|
||||
JOIN user_settings ON user_settings.user_id = users.id
|
||||
WHERE users.is_active = 1
|
||||
AND user_settings.reminders_enabled = 1
|
||||
AND user_settings.push_enabled = 1
|
||||
AND user_settings.notification_channel IN ('push', 'both')
|
||||
ORDER BY users.id
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def fetch_daypart_map() -> dict[str, dict]:
|
||||
return {
|
||||
row["slug"]: {"id": int(row["id"]), "name": row["name"]}
|
||||
for row in get_db().execute("SELECT id, slug, name FROM dayparts").fetchall()
|
||||
}
|
||||
|
||||
|
||||
def plan_exists_for_daypart(user, *, planned_date: date, daypart_id: int) -> bool:
|
||||
row = get_db().execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM plan_entries
|
||||
WHERE household_id = ?
|
||||
AND plan_date = ?
|
||||
AND daypart_id = ?
|
||||
AND (visibility = 'shared' OR owner_user_id = ?)
|
||||
""",
|
||||
(int(user["household_id"]), planned_date.isoformat(), daypart_id, int(user["id"])),
|
||||
).fetchone()
|
||||
return bool(int(row["count"] or 0))
|
||||
|
||||
|
||||
def reminder_event_exists(user_id: int, event_key: str) -> bool:
|
||||
row = get_db().execute(
|
||||
"SELECT 1 FROM reminder_events WHERE user_id = ? AND event_key = ? LIMIT 1",
|
||||
(user_id, event_key),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def mark_reminder_event(user_id: int, event_key: str) -> None:
|
||||
get_db().execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO reminder_events (user_id, event_key)
|
||||
VALUES (?, ?)
|
||||
""",
|
||||
(user_id, event_key),
|
||||
)
|
||||
get_db().commit()
|
||||
|
||||
|
||||
def due_for_rule(now: datetime, *, hour: int, minute: int, end_hour: int) -> bool:
|
||||
current_minutes = (now.hour * 60) + now.minute
|
||||
target_minutes = (hour * 60) + minute
|
||||
end_minutes = end_hour * 60
|
||||
return target_minutes <= current_minutes < end_minutes
|
||||
|
||||
|
||||
def build_push_target_url(*, planned_date: date, daypart_id: int, suggestion: dict | None) -> str:
|
||||
base = f"/planner/day?date={planned_date.isoformat()}&daypart_id={daypart_id}"
|
||||
if not suggestion:
|
||||
return f"{base}#daypart-{daypart_id}"
|
||||
if suggestion.get("existing_item_id"):
|
||||
return f"{base}&item_id={int(suggestion['existing_item_id'])}#daypart-{daypart_id}"
|
||||
component_ids = ",".join(str(component_id) for component_id in suggestion.get("component_ids", []))
|
||||
if suggestion.get("title") and component_ids:
|
||||
meal_name = quote(str(suggestion["title"]))
|
||||
return f"{base}&meal_name={meal_name}&component_ids={component_ids}#daypart-{daypart_id}"
|
||||
return f"{base}#daypart-{daypart_id}"
|
||||
|
||||
|
||||
def build_push_message(label: str, suggestion: dict | None) -> tuple[str, str]:
|
||||
title = f"Nouri · {label}"
|
||||
if suggestion and suggestion.get("title"):
|
||||
return title, f"Für {label.lower()} ist noch nichts geplant. Möglich wäre gerade: {suggestion['title']}."
|
||||
return title, f"Für {label.lower()} ist noch nichts geplant."
|
||||
|
||||
|
||||
def best_suggestion_for_user(user, daypart_id: int) -> dict | None:
|
||||
previous_user = getattr(g, "user", None)
|
||||
g.user = user
|
||||
try:
|
||||
suggestions = build_home_recipe_suggestions(daypart_id, limit=1)
|
||||
finally:
|
||||
g.user = previous_user
|
||||
return suggestions[0] if suggestions else None
|
||||
|
||||
|
||||
def send_due_meal_pushes(now: datetime | None = None) -> int:
|
||||
now = now or current_local_time()
|
||||
planned_date = now.date()
|
||||
sent_count = 0
|
||||
dayparts = fetch_daypart_map()
|
||||
|
||||
for user in fetch_push_ready_users():
|
||||
g.user = user
|
||||
settings = get_user_settings()
|
||||
if not push_delivery_channel_enabled(settings):
|
||||
continue
|
||||
|
||||
subscriptions = get_db().execute(
|
||||
"""
|
||||
SELECT endpoint, p256dh, auth
|
||||
FROM push_subscriptions
|
||||
WHERE user_id = ? AND is_active = 1
|
||||
ORDER BY updated_at DESC
|
||||
""",
|
||||
(int(user["id"]),),
|
||||
).fetchall()
|
||||
if not subscriptions:
|
||||
continue
|
||||
|
||||
for rule in MEAL_PUSH_RULES:
|
||||
if not settings.get(rule["setting"]):
|
||||
continue
|
||||
if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"], end_hour=rule["end_hour"]):
|
||||
continue
|
||||
|
||||
daypart = dayparts.get(rule["slug"])
|
||||
if not daypart:
|
||||
continue
|
||||
if plan_exists_for_daypart(user, planned_date=planned_date, daypart_id=daypart["id"]):
|
||||
continue
|
||||
|
||||
event_key = f"meal-push:{planned_date.isoformat()}:{rule['slug']}"
|
||||
if reminder_event_exists(int(user["id"]), event_key):
|
||||
continue
|
||||
|
||||
suggestion = best_suggestion_for_user(user, daypart["id"])
|
||||
title, body = build_push_message(rule["label"], suggestion)
|
||||
url = build_push_target_url(planned_date=planned_date, daypart_id=daypart["id"], suggestion=suggestion)
|
||||
|
||||
delivered = False
|
||||
for subscription in subscriptions:
|
||||
ok, _error = send_push_message(
|
||||
{
|
||||
"endpoint": subscription["endpoint"],
|
||||
"keys": {"p256dh": subscription["p256dh"], "auth": subscription["auth"]},
|
||||
},
|
||||
title=title,
|
||||
body=body,
|
||||
url=url,
|
||||
)
|
||||
delivered = delivered or ok
|
||||
|
||||
if delivered:
|
||||
mark_reminder_event(int(user["id"]), event_key)
|
||||
sent_count += 1
|
||||
|
||||
return sent_count
|
||||
|
||||
|
||||
def reminder_worker_loop(sleep_seconds: int = 60) -> None:
|
||||
while True:
|
||||
try:
|
||||
send_due_meal_pushes()
|
||||
except Exception as exc: # pragma: no cover - background worker fallback
|
||||
current_app.logger.warning("Reminder worker skipped one cycle: %s", exc)
|
||||
time.sleep(sleep_seconds)
|
||||
@@ -1,11 +1,106 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
household_id INTEGER,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'member',
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
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',
|
||||
suggestion_style TEXT NOT NULL DEFAULT 'balanced',
|
||||
energy_preference TEXT NOT NULL DEFAULT 'neutral',
|
||||
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,
|
||||
push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
|
||||
push_missing_lunch INTEGER NOT NULL DEFAULT 0,
|
||||
push_missing_dinner INTEGER NOT NULL DEFAULT 0,
|
||||
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 reminder_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
event_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, event_key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS hidden_generated_suggestions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
suggestion_key TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (user_id, suggestion_key),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dayparts (
|
||||
@@ -17,9 +112,14 @@ CREATE TABLE IF NOT EXISTS dayparts (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
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')),
|
||||
name TEXT NOT NULL,
|
||||
category TEXT,
|
||||
energy_density TEXT NOT NULL DEFAULT 'neutral',
|
||||
note TEXT,
|
||||
photo_filename TEXT,
|
||||
availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')),
|
||||
@@ -27,6 +127,9 @@ CREATE TABLE IF NOT EXISTS items (
|
||||
updated_by INTEGER,
|
||||
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,
|
||||
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 (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
@@ -49,42 +152,172 @@ CREATE TABLE IF NOT EXISTS meal_components (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shopping_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
household_id INTEGER,
|
||||
owner_user_id INTEGER,
|
||||
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||
item_id INTEGER NOT NULL,
|
||||
added_by INTEGER,
|
||||
checked_by INTEGER,
|
||||
needed_for_date TEXT,
|
||||
needed_for_daypart_id INTEGER,
|
||||
is_checked INTEGER NOT NULL DEFAULT 0,
|
||||
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
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 (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
|
||||
ON shopping_entries (item_id)
|
||||
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 (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
household_id INTEGER,
|
||||
owner_user_id INTEGER,
|
||||
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||
plan_date TEXT NOT NULL,
|
||||
daypart_id INTEGER NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
note TEXT,
|
||||
created_by INTEGER,
|
||||
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 (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS day_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
household_id INTEGER NOT NULL,
|
||||
owner_user_id INTEGER,
|
||||
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS day_template_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
day_template_id INTEGER NOT NULL,
|
||||
daypart_id INTEGER NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
FOREIGN KEY (day_template_id) REFERENCES day_templates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS week_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
household_id INTEGER NOT NULL,
|
||||
owner_user_id INTEGER,
|
||||
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS week_template_days (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
week_template_id INTEGER NOT NULL,
|
||||
weekday_index INTEGER NOT NULL,
|
||||
day_template_id INTEGER NOT NULL,
|
||||
UNIQUE (week_template_id, weekday_index),
|
||||
FOREIGN KEY (week_template_id) REFERENCES week_templates(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (day_template_id) REFERENCES day_templates(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_sets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
household_id INTEGER NOT NULL,
|
||||
owner_user_id INTEGER,
|
||||
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS item_set_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
item_set_id INTEGER NOT NULL,
|
||||
item_id INTEGER NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||
UNIQUE (item_set_id, item_id),
|
||||
FOREIGN KEY (item_set_id) REFERENCES item_sets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_items_kind_name
|
||||
ON items (kind, name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_items_availability_name
|
||||
ON items (availability_state, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
||||
ON items (household_id, visibility, availability_state);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_items_target_user
|
||||
ON items (target_user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item
|
||||
ON item_dayparts (daypart_id, item_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plan_entries_plan_date_daypart
|
||||
ON plan_entries (plan_date, daypart_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility
|
||||
ON plan_entries (household_id, visibility, plan_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility
|
||||
ON shopping_entries (household_id, visibility, is_checked);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation
|
||||
ON shopping_needs (household_id, activation_date, is_activated);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_day_templates_household_visibility
|
||||
ON day_templates (household_id, visibility, name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_week_templates_household_visibility
|
||||
ON week_templates (household_id, visibility, name);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_item_sets_household_visibility
|
||||
ON item_sets (household_id, visibility, name);
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffd7be"/>
|
||||
<stop offset="100%" stop-color="#e39a63"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#b5dfc8"/>
|
||||
<stop offset="100%" stop-color="#72a98b"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="18" fill="url(#bg)"/>
|
||||
<path d="M16 34c0-3 2.4-5.4 5.4-5.4h21.2c3 0 5.4 2.4 5.4 5.4 0 9.2-7.4 16.6-16.6 16.6h-.8C23.4 50.6 16 43.2 16 34z" fill="#fff9f4"/>
|
||||
<path d="M21 25c2.4-6.7 7.2-10.6 11-10.6S40.6 18.3 43 25" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="4"/>
|
||||
<path d="M40 12c5 .4 9 5 9 10.1 0 .5 0 1-.1 1.5-.8-.7-1.8-1.2-2.9-1.5-2.7-.8-4.3-3.3-4.2-6.1 0-1.3-.6-2.6-1.8-4z" fill="url(#leaf)"/>
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="3" width="58" height="58" rx="18" fill="#E3A06B"/>
|
||||
<path d="M16 36H48C46.9 44.2 40.0727 50 31.8 50H32.2C23.9273 50 17.1 44.2 16 36Z" fill="#FFF5EC"/>
|
||||
<path d="M31.9997 19C36.9702 19 40.9997 23.0781 40.9997 28.1081V29.3848C40.9997 32.7262 39.6098 35.9158 37.1535 38.1417L29.9949 44.6282H24.5605L32.4863 37.4468C34.0025 36.0726 34.8571 34.1103 34.8571 32.0653V28.1081C34.8571 26.551 33.5671 25.2162 31.9997 25.2162C30.4324 25.2162 29.1424 26.551 29.1424 28.1081V29.3848H23C23 23.0781 27.0295 19 31.9997 19Z" fill="#8C533B"/>
|
||||
<rect x="24" y="39" width="16" height="6" rx="3" fill="#8C533B"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 914 B After Width: | Height: | Size: 715 B |
@@ -1,21 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title">
|
||||
<title>Nouri</title>
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffd7be"/>
|
||||
<stop offset="55%" stop-color="#f5b17a"/>
|
||||
<stop offset="100%" stop-color="#d58c57"/>
|
||||
<linearGradient id="nouriBg" x1="88" y1="72" x2="420" y2="440" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F6C394"/>
|
||||
<stop offset="1" stop-color="#DE9862"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#b5dfc8"/>
|
||||
<stop offset="100%" stop-color="#70aa87"/>
|
||||
<linearGradient id="nouriBowl" x1="161" y1="176" x2="349" y2="339" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFF8F0"/>
|
||||
<stop offset="1" stop-color="#FDE7D5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="256" height="256" rx="64" fill="url(#bg)"/>
|
||||
<circle cx="128" cy="128" r="96" fill="rgba(255,255,255,0.16)"/>
|
||||
<path d="M68 132c0-9.9 8.1-18 18-18h84c9.9 0 18 8.1 18 18 0 31.8-25.8 57.6-57.6 57.6h-4.8C93.8 189.6 68 163.8 68 132z" fill="#fff9f4"/>
|
||||
<path d="M84 105c7-21.3 24-34 44-34s37 12.7 44 34" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="14"/>
|
||||
<path d="M156 55c15 1 27 14.7 27 30.6 0 1.6-.1 3.1-.3 4.6-1.9-2.4-4.5-4.2-7.5-5.1-8-2.5-13.1-10.2-12.7-18.5.1-4.1-1.8-8-5.2-11.6z" fill="url(#leaf)"/>
|
||||
<path d="M129 143h41" stroke="#f0a46c" stroke-linecap="round" stroke-width="10"/>
|
||||
<path d="M92 96l-10 62" stroke="#fff9f4" stroke-linecap="round" stroke-width="10"/>
|
||||
<rect x="24" y="24" width="464" height="464" rx="122" fill="url(#nouriBg)"/>
|
||||
<rect x="48" y="48" width="416" height="416" rx="104" fill="white" fill-opacity="0.12"/>
|
||||
<path d="M152 232C152 175.667 197.667 130 254 130H258C315.438 130 362 176.562 362 234V242C362 299.438 315.438 346 258 346H254C197.667 346 152 300.333 152 244V232Z" fill="url(#nouriBowl)"/>
|
||||
<path d="M175 244C175 201.474 209.474 167 252 167H258C300.526 167 335 201.474 335 244V244C335 286.526 300.526 321 258 321H252C209.474 321 175 286.526 175 244V244Z" fill="#EFC39F" fill-opacity="0.22"/>
|
||||
<path d="M164 287H348C344.6 332.419 307.445 368 261.2 368H250.8C204.555 368 167.4 332.419 164 287Z" fill="#F7B37D"/>
|
||||
<path d="M198 287H314C311.53 314.638 288.347 336 260.2 336H251.8C223.653 336 200.47 314.638 198 287Z" fill="#A86244" fill-opacity="0.2"/>
|
||||
<path d="M255.999 171C271.513 171 284.246 183.574 284.246 199.27V205.573C284.246 216.441 279.591 226.794 271.487 233.994L246.435 256.256C241.017 261.07 237.906 268.003 237.906 275.283V295.246H212.906V275.283C212.906 260.722 219.129 246.854 229.965 237.227L255.016 214.966C257.705 212.578 259.246 209.146 259.246 205.573V199.27C259.246 197.28 257.651 195.6 255.999 195.6C254.347 195.6 252.752 197.28 252.752 199.27V205.091H227.752V199.27C227.752 183.574 240.485 171 255.999 171Z" fill="#8C533B"/>
|
||||
<rect x="226" y="280" width="63" height="25" rx="12.5" fill="#8C533B"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 805 B |
|
After Width: | Height: | Size: 7.9 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;
|
||||
|
||||
board.querySelectorAll(".draggable-plan-entry").forEach((entry) => {
|
||||
if (entry.getAttribute("draggable") !== "true") return;
|
||||
entry.addEventListener("dragstart", () => {
|
||||
draggedEntry = entry;
|
||||
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 storageKey = "nouri-theme";
|
||||
const toggle = () => document.querySelector("[data-theme-toggle]");
|
||||
const toggles = () => Array.from(document.querySelectorAll("[data-theme-toggle]"));
|
||||
|
||||
const applyTheme = (theme) => {
|
||||
const resolved = theme || localStorage.getItem(storageKey) || "auto";
|
||||
@@ -11,22 +11,20 @@
|
||||
: resolved;
|
||||
root.dataset.theme = finalTheme;
|
||||
|
||||
const button = toggle();
|
||||
if (button) {
|
||||
toggles().forEach((button) => {
|
||||
button.textContent = finalTheme === "dark" ? "Hell" : "Dunkel";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
applyTheme();
|
||||
const button = toggle();
|
||||
if (!button) return;
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
const current = root.dataset.theme === "dark" ? "dark" : "light";
|
||||
const next = current === "dark" ? "light" : "dark";
|
||||
localStorage.setItem(storageKey, next);
|
||||
applyTheme(next);
|
||||
toggles().forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const current = root.dataset.theme === "dark" ? "dark" : "light";
|
||||
const next = current === "dark" ? "light" : "dark";
|
||||
localStorage.setItem(storageKey, next);
|
||||
applyTheme(next);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
(() => {
|
||||
const initMobileSheet = () => {
|
||||
const sheet = document.querySelector("[data-mobile-sheet]");
|
||||
const navStack = document.querySelector("[data-mobile-nav-stack]");
|
||||
const openButtons = document.querySelectorAll("[data-mobile-sheet-open]");
|
||||
if (!sheet || !navStack || !openButtons.length) return;
|
||||
|
||||
const closeSheet = () => {
|
||||
sheet.hidden = true;
|
||||
navStack.classList.remove("is-open");
|
||||
openButtons.forEach((button) => button.classList.remove("is-open"));
|
||||
};
|
||||
|
||||
const openSheet = () => {
|
||||
sheet.hidden = false;
|
||||
navStack.classList.add("is-open");
|
||||
openButtons.forEach((button) => button.classList.add("is-open"));
|
||||
};
|
||||
|
||||
const toggleSheet = () => {
|
||||
if (sheet.hidden) {
|
||||
openSheet();
|
||||
} else {
|
||||
closeSheet();
|
||||
}
|
||||
};
|
||||
|
||||
openButtons.forEach((button) => {
|
||||
button.addEventListener("click", toggleSheet);
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
closeSheet();
|
||||
}
|
||||
});
|
||||
|
||||
sheet.querySelectorAll("a").forEach((link) => {
|
||||
link.addEventListener("click", closeSheet);
|
||||
});
|
||||
sheet.querySelectorAll("button[data-theme-toggle]").forEach((button) => {
|
||||
button.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 filterGroups = Array.from(container.querySelectorAll("[data-filter-group]"));
|
||||
const resultLimit = Number.parseInt(input.getAttribute("data-filter-limit") || "", 10);
|
||||
const hasLimit = Number.isFinite(resultLimit) && resultLimit > 0;
|
||||
|
||||
const scoreItem = (label, term) => {
|
||||
if (label === term) return 0;
|
||||
if (label.startsWith(term)) return 1;
|
||||
if (label.split(/\s+/).some((part) => part.startsWith(term))) return 2;
|
||||
if (label.includes(term)) return 3;
|
||||
return 99;
|
||||
};
|
||||
|
||||
const syncGroups = () => {
|
||||
filterGroups.forEach((group) => {
|
||||
const visibleChildren = Array.from(group.querySelectorAll("[data-filter-label]")).some((item) => !item.hidden);
|
||||
const card = group.closest(".component-group, .template-list-card, .panel, .planner-subsection");
|
||||
if (card) {
|
||||
card.hidden = !visibleChildren;
|
||||
} else {
|
||||
group.hidden = !visibleChildren;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const applyFilter = () => {
|
||||
const term = input.value.trim().toLowerCase();
|
||||
if (!term) {
|
||||
items.forEach((item, index) => {
|
||||
item.hidden = hasLimit ? index >= resultLimit : false;
|
||||
});
|
||||
syncGroups();
|
||||
return;
|
||||
}
|
||||
|
||||
const rankedMatches = items
|
||||
.map((item, index) => {
|
||||
const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase();
|
||||
const score = scoreItem(haystack, term);
|
||||
return { item, index, score, matches: score < 99 };
|
||||
})
|
||||
.filter((entry) => entry.matches)
|
||||
.sort((left, right) => left.score - right.score || left.index - right.index);
|
||||
|
||||
const allowedItems = new Set(
|
||||
(hasLimit ? rankedMatches.slice(0, resultLimit) : rankedMatches).map((entry) => entry.item)
|
||||
);
|
||||
|
||||
items.forEach((item) => {
|
||||
item.hidden = !allowedItems.has(item);
|
||||
});
|
||||
syncGroups();
|
||||
};
|
||||
|
||||
input.addEventListener("input", applyFilter);
|
||||
applyFilter();
|
||||
});
|
||||
};
|
||||
|
||||
const initIosPullToRefresh = () => {
|
||||
const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent)
|
||||
|| (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
if (!isAppleTouchDevice) return;
|
||||
|
||||
let startY = 0;
|
||||
let maxPull = 0;
|
||||
let tracking = false;
|
||||
|
||||
window.addEventListener("touchstart", (event) => {
|
||||
if (window.scrollY > 0) {
|
||||
tracking = false;
|
||||
return;
|
||||
}
|
||||
startY = event.touches[0].clientY;
|
||||
maxPull = 0;
|
||||
tracking = true;
|
||||
}, { passive: true });
|
||||
|
||||
window.addEventListener("touchmove", (event) => {
|
||||
if (!tracking) return;
|
||||
const currentY = event.touches[0].clientY;
|
||||
maxPull = Math.max(maxPull, currentY - startY);
|
||||
}, { passive: true });
|
||||
|
||||
window.addEventListener("touchend", () => {
|
||||
if (tracking && maxPull > 96 && window.scrollY <= 2) {
|
||||
window.location.reload();
|
||||
}
|
||||
tracking = false;
|
||||
maxPull = 0;
|
||||
}, { passive: true });
|
||||
|
||||
document.addEventListener("gesturestart", (event) => {
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener("touchmove", (event) => {
|
||||
if (event.touches.length > 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initMobileSheet();
|
||||
initFilterInputs();
|
||||
initIosPullToRefresh();
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "Nouri",
|
||||
"short_name": "Nouri",
|
||||
"description": "einfach essen planen",
|
||||
"lang": "de",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone", "minimal-ui"],
|
||||
"background_color": "#fff6ef",
|
||||
"theme_color": "#de9862",
|
||||
"categories": ["food", "lifestyle", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/static/brand/pwa-180.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/brand/pwa-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/brand/pwa-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/brand/pwa-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Nouri offline</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #fff6ef;
|
||||
--surface: rgba(255, 255, 255, 0.92);
|
||||
--line: rgba(126, 104, 85, 0.14);
|
||||
--text: #352d2b;
|
||||
--muted: #7d7069;
|
||||
--accent: #de9862;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(255, 205, 174, 0.42), transparent 24rem),
|
||||
radial-gradient(circle at 90% 8%, rgba(190, 226, 203, 0.34), transparent 24rem),
|
||||
linear-gradient(180deg, var(--bg), #fdf0e6);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(28rem, 100%);
|
||||
padding: 1.4rem;
|
||||
border-radius: 24px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 22px 48px rgba(125, 92, 68, 0.12);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.45rem;
|
||||
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.35rem 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
padding: 0.82rem 1.1rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<h1>Nouri ist gerade kurz offline</h1>
|
||||
<p>Die App bleibt da und versucht es gleich wieder. Sobald die Verbindung zurück ist, kannst du normal weitermachen.</p>
|
||||
<p class="muted">Ein Teil der Oberfläche ist schon lokal verfügbar. Für aktuelle Haushaltsdaten braucht Nouri aber wieder eine Verbindung.</p>
|
||||
<a href="/">Erneut versuchen</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,105 @@
|
||||
const CACHE_NAME = "nouri-v1-0-0";
|
||||
const OFFLINE_URL = "/static/pwa/offline.html";
|
||||
const STATIC_ASSETS = [
|
||||
"/static/css/styles.css",
|
||||
"/static/js/theme.js",
|
||||
"/static/js/ui.js",
|
||||
"/static/js/planner.js",
|
||||
"/static/js/pwa.js",
|
||||
"/static/brand/pwa-180.png",
|
||||
"/static/brand/pwa-192.png",
|
||||
"/static/brand/pwa-512.png",
|
||||
"/static/brand/pwa-maskable-512.png",
|
||||
"/static/brand/pwa-badge.png",
|
||||
"/static/brand/favicon.svg",
|
||||
"/app.webmanifest",
|
||||
OFFLINE_URL,
|
||||
];
|
||||
|
||||
const cacheFirst = async (request) => {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
const response = await fetch(request);
|
||||
if (response && response.ok && response.type === "basic") {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (event.request.method !== "GET") return;
|
||||
|
||||
const requestUrl = new URL(event.request.url);
|
||||
const isSameOrigin = requestUrl.origin === self.location.origin;
|
||||
const isStaticAsset = isSameOrigin && (
|
||||
requestUrl.pathname.startsWith("/static/")
|
||||
|| requestUrl.pathname === "/app.webmanifest"
|
||||
|| requestUrl.pathname === "/service-worker.js"
|
||||
);
|
||||
const isUpload = isSameOrigin && requestUrl.pathname.startsWith("/uploads/");
|
||||
|
||||
if (event.request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
const copy = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
return (await caches.match(event.request)) || caches.match(OFFLINE_URL);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isStaticAsset || isUpload) {
|
||||
event.respondWith(cacheFirst(event.request));
|
||||
}
|
||||
});
|
||||
|
||||
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,79 @@
|
||||
{% 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>
|
||||
{% if category.name not in default_categories %}
|
||||
<form method="post" action="{{ url_for('admin.category_delete', category_id=category.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Löschen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</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>
|
||||
<p class="eyebrow">Archiv</p>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -23,6 +23,14 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Sichtbarkeit
|
||||
<select name="visibility">
|
||||
{% for value, label in visibility_options %}
|
||||
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<div class="filter-actions">
|
||||
<button type="submit">Filtern</button>
|
||||
<a class="ghost-button" href="{{ url_for('main.archive_view') }}">Zurücksetzen</a>
|
||||
@@ -36,13 +44,23 @@
|
||||
<article class="item-card">
|
||||
<div class="item-media">
|
||||
{% if item.photo_filename %}
|
||||
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}">
|
||||
<img
|
||||
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||
sizes="{{ image_sizes('grid') }}"
|
||||
alt="{{ item.name }}"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-body">
|
||||
<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>
|
||||
{% if item.dayparts %}
|
||||
<div class="chip-row">
|
||||
@@ -64,10 +82,12 @@
|
||||
<button type="submit">Wieder einkaufen</button>
|
||||
</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>
|
||||
<form method="post" action="{{ url_for('main.item_restore', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Zur aktiven Liste</button>
|
||||
</form>
|
||||
{% if item.can_edit %}
|
||||
<form method="post" action="{{ url_for('main.item_restore', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Zur aktiven Liste</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<div class="auth-card">
|
||||
<p class="eyebrow">Willkommen zurück</p>
|
||||
<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">
|
||||
{{ csrf_input() }}
|
||||
<label>
|
||||
Benutzername
|
||||
Benutzername oder E-Mail
|
||||
<input type="text" name="username" autocomplete="username" required>
|
||||
</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,37 @@
|
||||
<div class="auth-card">
|
||||
<p class="eyebrow">Erster Start</p>
|
||||
<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. Der erste Einstieg bleibt bewusst klein: Zugang anlegen, Einkaufstag festlegen, erste Lebensmittel sammeln und bei Bedarf später eine Tagesvorlage merken.</p>
|
||||
|
||||
<div class="setup-intro-grid">
|
||||
<div class="setup-tip">
|
||||
<strong>1. Ruhig starten</strong>
|
||||
<p class="muted">Ein erster Haushalt und ein Admin-Zugang reichen für den Anfang völlig.</p>
|
||||
</div>
|
||||
<div class="setup-tip">
|
||||
<strong>2. Alltag festhalten</strong>
|
||||
<p class="muted">Später könnt ihr Lebensmittel, Mahlzeitenideen und Planungen gemeinsam nutzen.</p>
|
||||
</div>
|
||||
<div class="setup-tip">
|
||||
<strong>3. Als App nutzen</strong>
|
||||
<p class="muted">Auf dem iPhone lässt sich Nouri später über Safari zum Home-Bildschirm hinzufügen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" class="stack-form">
|
||||
{{ csrf_input() }}
|
||||
<label>
|
||||
Haushaltsname
|
||||
<input type="text" name="household_name" autocomplete="organization" placeholder="z. B. Zuhause">
|
||||
</label>
|
||||
<label>
|
||||
Benutzername
|
||||
<input type="text" name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label>
|
||||
E-Mail
|
||||
<input type="email" name="email" autocomplete="email" placeholder="Optional">
|
||||
</label>
|
||||
<label>
|
||||
Anzeigename
|
||||
<input type="text" name="display_name" autocomplete="name" placeholder="z. B. Heinz">
|
||||
|
||||
@@ -2,44 +2,76 @@
|
||||
<html lang="de" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>{% block title %}Nouri{% endblock %}</title>
|
||||
<meta name="theme-color" content="#de9862">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<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="application-name" content="Nouri">
|
||||
<meta name="csrf-token" content="{{ csrf_token_value }}">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='brand/favicon.svg') }}">
|
||||
<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/planner.js') }}"></script>
|
||||
<meta name="nouri-push-public-key" content="{{ push_public_key }}">
|
||||
<link rel="icon" type="image/svg+xml" href="{{ asset_url('brand/favicon.svg') }}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ asset_url('brand/pwa-180.png') }}">
|
||||
<link rel="manifest" href="{{ url_for('webmanifest') }}">
|
||||
<link rel="stylesheet" href="{{ asset_url('css/styles.css') }}">
|
||||
<script defer src="{{ asset_url('js/theme.js') }}"></script>
|
||||
<script defer src="{{ asset_url('js/planner.js') }}"></script>
|
||||
<script defer src="{{ asset_url('js/ui.js') }}"></script>
|
||||
<script defer src="{{ asset_url('js/pwa.js') }}"></script>
|
||||
</head>
|
||||
<body>
|
||||
<body class="{% if g.user %}has-mobile-nav{% endif %}">
|
||||
<div class="page-shell">
|
||||
<header class="site-header">
|
||||
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
||||
<span class="brand-mark">
|
||||
<img src="{{ url_for('static', filename='brand/nouri-icon.svg') }}" alt="">
|
||||
</span>
|
||||
<span>
|
||||
<strong>Nouri</strong>
|
||||
<small>einfach essen planen</small>
|
||||
</span>
|
||||
</a>
|
||||
<div class="desktop-header-main">
|
||||
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
||||
<span class="brand-mark">
|
||||
<img src="{{ asset_url('brand/nouri-icon.svg') }}" alt="">
|
||||
</span>
|
||||
<span class="brand-copy">
|
||||
<strong>Nouri</strong>
|
||||
<small>einfach essen planen</small>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{% if g.user %}
|
||||
<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.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='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a>
|
||||
<a href="{{ url_for('main.template_library') }}" class="{{ 'active' if (request.endpoint or '').startswith('main.day_template') or (request.endpoint or '').startswith('main.week_template') or (request.endpoint or '').startswith('main.item_set') or request.endpoint == 'main.template_library' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></span></a>
|
||||
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if g.user %}
|
||||
<nav class="site-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.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.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.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>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<button class="theme-toggle" type="button" data-theme-toggle>Modus</button>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Abmelden</button>
|
||||
</form>
|
||||
<div class="desktop-header-sub">
|
||||
<div class="header-actions desktop-actions">
|
||||
<button class="theme-toggle ghost-button" type="button" data-theme-toggle>Hell</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') }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Abmelden</button>
|
||||
</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 %}
|
||||
</header>
|
||||
|
||||
@@ -56,6 +88,68 @@
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="footer-copy">
|
||||
<a href="{{ app_release_url }}" target="_blank" rel="noreferrer">Version {{ app_version }}</a>
|
||||
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
|
||||
</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-nav-stack" data-mobile-nav-stack>
|
||||
<nav class="mobile-nav-extension" data-mobile-sheet hidden aria-label="Mehr Navigation">
|
||||
<a class="mobile-extra-link" href="{{ url_for('main.item_list', kind='food') }}"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('main.item_list', kind='meal') }}"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('main.home_view') }}"><span class="ui-icon icon-house"></span><span>Zuhause</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('main.archive_view') }}"><span class="ui-icon icon-archive"></span><span>Archiv</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('main.template_library') }}"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('main.settings_view') }}"><span class="ui-icon icon-sliders"></span><span>Optionen</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
|
||||
{% if g.user.role == 'admin' %}
|
||||
<a class="mobile-extra-link" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
|
||||
<a class="mobile-extra-link" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></a>
|
||||
{% endif %}
|
||||
<button class="mobile-extra-link mobile-extra-button" type="button" data-theme-toggle>
|
||||
<span class="ui-icon icon-mobile-screen-button"></span>
|
||||
<span>Modus</span>
|
||||
</button>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}" class="mobile-extra-form">
|
||||
{{ csrf_input() }}
|
||||
<button class="mobile-extra-link mobile-extra-button" type="submit">
|
||||
<span class="ui-icon icon-ellipsis"></span>
|
||||
<span>Abmelden</span>
|
||||
</button>
|
||||
</form>
|
||||
</nav>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,15 +4,32 @@
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Heute</p>
|
||||
<h1>Ein ruhiger Blick auf das, was gerade hilft</h1>
|
||||
<p class="lead">Du siehst auf einen Blick, was zuhause da ist, was schon eingeplant wurde und wo du schnell weitermachen kannst.</p>
|
||||
<h1>Ein ruhiger Blick auf euren Alltag</h1>
|
||||
<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 class="hero-actions">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{% if setup_checklist %}
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Gut anfangen</h2>
|
||||
</div>
|
||||
<div class="more-link-grid">
|
||||
{% for step in setup_checklist %}
|
||||
<a class="more-link-card" href="{{ step.url }}">
|
||||
<strong>{{ step.title }}</strong>
|
||||
<small>{{ step.text }}</small>
|
||||
<span class="chip">{{ step.label }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="stats-grid">
|
||||
<article class="stat-card">
|
||||
<span>Zuhause</span>
|
||||
@@ -31,6 +48,19 @@
|
||||
</article>
|
||||
</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">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
@@ -40,10 +70,15 @@
|
||||
{% if today_entries %}
|
||||
<ul class="simple-list">
|
||||
{% for entry in today_entries %}
|
||||
<li>
|
||||
<li class="stacked-mobile">
|
||||
<div>
|
||||
<strong>{{ entry.daypart_name }}</strong>
|
||||
<span>{{ entry.item_name }}</span>
|
||||
<div class="chip-row">
|
||||
<span class="chip">{{ entry.visibility_label }}</span>
|
||||
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||
<span class="chip">{{ entry.for_label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if entry.availability_state == 'home' %}
|
||||
<span class="status-pill status-home">zuhause</span>
|
||||
@@ -52,7 +87,7 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% 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 %}
|
||||
</article>
|
||||
|
||||
@@ -67,7 +102,8 @@
|
||||
<article class="mini-card">
|
||||
<div class="mini-card-body">
|
||||
<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 %}
|
||||
<div class="chip-row">
|
||||
{% for daypart in item.dayparts %}
|
||||
@@ -85,24 +121,114 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Nächste Tage</h2>
|
||||
<a href="{{ url_for('main.planner') }}">Wochenansicht öffnen</a>
|
||||
</div>
|
||||
<div class="week-mini-grid">
|
||||
{% for card in week_cards %}
|
||||
<a class="week-mini-card" href="{{ url_for('main.planner_day', date=card.date.isoformat()) }}">
|
||||
<strong>{{ weekday_short_name(card.date) }} {{ card.date.strftime('%d.%m.') }}</strong>
|
||||
{% if card.filled_dayparts %}
|
||||
<span>{{ card.planned_count }} Einträge</span>
|
||||
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
|
||||
{% else %}
|
||||
<span>Noch frei</span>
|
||||
<small>sanfter Einstieg für den Tag</small>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<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>
|
||||
{% if suggestion.needs_shopping and suggestion.missing_components %}
|
||||
<div class="chip-row">
|
||||
<span class="chip status-idea">Es fehlt noch: {{ suggestion.missing_components|join(', ') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="template-list-card-actions">
|
||||
{% if suggestion.existing_item_id %}
|
||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=suggestion.existing_item_id, daypart_id=suggestion.daypart_id or 1) }}">Im Tagesplan öffnen</a>
|
||||
{% else %}
|
||||
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
|
||||
<form method="post" action="{{ url_for('main.suggestion_hide') }}">
|
||||
{{ csrf_input() }}
|
||||
{% for component_id in suggestion.component_ids %}
|
||||
<input type="hidden" name="component_ids" value="{{ component_id }}">
|
||||
{% endfor %}
|
||||
<button class="ghost-button" type="submit">Dauerhaft ausblenden</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">Sobald ein paar Dinge unter Zuhause liegen, zeigt Nouri hier kleine Kombinationsideen.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Vorlagen für später</h2>
|
||||
<a href="{{ url_for('main.template_library') }}">Alles ansehen</a>
|
||||
</div>
|
||||
{% if day_templates or week_templates %}
|
||||
<div class="stack-sections">
|
||||
{% for template in day_templates %}
|
||||
<a class="mini-card" href="{{ url_for('main.day_template_edit', template_id=template.id) }}">
|
||||
<strong>{{ template.name }}</strong>
|
||||
<small>Tagesvorlage · {{ template.visibility_label }}</small>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% for template in week_templates %}
|
||||
<a class="mini-card" href="{{ url_for('main.week_template_edit', template_id=template.id) }}">
|
||||
<strong>{{ template.name }}</strong>
|
||||
<small>Wochenvorlage · {{ template.visibility_label }}</small>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">Vorlagen helfen später beim Wiederverwenden. Du kannst sie direkt aus einem Tag oder einer Woche heraus anlegen.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="two-column">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Nächster Einkauf</h2>
|
||||
<a href="{{ url_for('main.shopping_list') }}">Zur Einkaufsliste</a>
|
||||
</div>
|
||||
{% if upcoming_entries %}
|
||||
<div class="stack-sections">
|
||||
{% for entry in upcoming_entries %}
|
||||
<article class="template-list-card">
|
||||
<div>
|
||||
<strong>{{ entry.item_name }}</strong>
|
||||
<small>Wird ab {{ entry.activation_label }} sichtbar · {{ entry.needed_for_label }}</small>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">Gerade ist nichts für spätere Einkäufe vorgemerkt.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Nächste Tage</h2>
|
||||
<a href="{{ url_for('main.planner') }}">Wochenansicht öffnen</a>
|
||||
</div>
|
||||
<div class="week-mini-grid">
|
||||
{% for card in week_cards %}
|
||||
<a class="week-mini-card" href="{{ url_for('main.planner_day', date=card.date.isoformat()) }}">
|
||||
<strong>{{ weekday_short_name(card.date) }} {{ card.date.strftime('%d.%m.') }}</strong>
|
||||
{% if card.filled_dayparts %}
|
||||
<span>{{ card.planned_count }} Einträge</span>
|
||||
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
|
||||
{% else %}
|
||||
<span>Noch frei</span>
|
||||
<small>ruhiger Einstieg für den Tag</small>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Zuhause</p>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -15,6 +15,14 @@
|
||||
Suche
|
||||
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
|
||||
</label>
|
||||
<label>
|
||||
Sichtbarkeit
|
||||
<select name="visibility">
|
||||
{% for value, label in visibility_options %}
|
||||
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Tageszeit
|
||||
<select name="daypart_id">
|
||||
@@ -31,6 +39,44 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if recipe_suggestions %}
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Passt gut dazu</h2>
|
||||
<a href="{{ url_for('main.item_create', kind='meal') }}">Neue Mahlzeit</a>
|
||||
</div>
|
||||
<div class="stack-sections">
|
||||
{% for suggestion in recipe_suggestions %}
|
||||
<article class="template-list-card">
|
||||
<div>
|
||||
<strong>{{ suggestion.title }}</strong>
|
||||
<small>{{ suggestion.reason }}</small>
|
||||
{% if suggestion.needs_shopping and suggestion.missing_components %}
|
||||
<div class="chip-row">
|
||||
<span class="chip status-idea">Es fehlt noch: {{ suggestion.missing_components|join(', ') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="template-list-card-actions">
|
||||
{% if suggestion.existing_item_id %}
|
||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=suggestion.existing_item_id, daypart_id=suggestion.daypart_id or 1) }}">Im Tagesplan öffnen</a>
|
||||
{% else %}
|
||||
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
|
||||
<form method="post" action="{{ url_for('main.suggestion_hide') }}">
|
||||
{{ csrf_input() }}
|
||||
{% for component_id in suggestion.component_ids %}
|
||||
<input type="hidden" name="component_ids" value="{{ component_id }}">
|
||||
{% endfor %}
|
||||
<button class="ghost-button" type="submit">Dauerhaft ausblenden</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if sections %}
|
||||
<section class="stack-sections">
|
||||
{% for section in sections if section["items"] %}
|
||||
@@ -44,13 +90,23 @@
|
||||
<article class="item-card compact">
|
||||
<div class="item-media">
|
||||
{% if item.photo_filename %}
|
||||
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}">
|
||||
<img
|
||||
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||
sizes="{{ image_sizes('grid') }}"
|
||||
alt="{{ item.name }}"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-body">
|
||||
<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>
|
||||
{% if item.components %}
|
||||
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
||||
@@ -58,10 +114,12 @@
|
||||
</div>
|
||||
<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>
|
||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Verbraucht / nicht mehr da</button>
|
||||
</form>
|
||||
{% if item.can_edit %}
|
||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Verbraucht / nicht mehr da</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button type="submit">Erneut einkaufen</button>
|
||||
|
||||
@@ -5,8 +5,15 @@
|
||||
<div>
|
||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||
<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>
|
||||
{% 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 class="panel form-panel">
|
||||
@@ -17,14 +24,45 @@
|
||||
<input type="text" name="name" value="{{ form_data.name }}" required>
|
||||
</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>
|
||||
Kategorie
|
||||
<input type="text" name="category" list="category-list" value="{{ form_data.category }}" placeholder="z. B. Obst, Warmes, Snack">
|
||||
<datalist id="category-list">
|
||||
<select name="category">
|
||||
<option value="">Ohne Kategorie</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category }}"></option>
|
||||
<option value="{{ category }}" {% if form_data.category == category %}selected{% endif %}>{{ category }}</option>
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Energiedichte
|
||||
<select name="energy_density">
|
||||
{% for value, label in energy_density_options %}
|
||||
<option value="{{ value }}" {% if form_data.energy_density == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="helper-text">Hilft Nouri dabei, passende Ideen etwas ruhiger und persönlicher zu sortieren.</small>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
@@ -39,7 +77,12 @@
|
||||
|
||||
{% if item and item.photo_filename %}
|
||||
<div class="inline-photo">
|
||||
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}">
|
||||
<img
|
||||
src="{{ image_url(item.photo_filename, 'lg') }}"
|
||||
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||
sizes="{{ image_sizes('detail') }}"
|
||||
alt="{{ item.name }}"
|
||||
loading="lazy">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -58,20 +101,36 @@
|
||||
{% if kind == 'meal' %}
|
||||
<fieldset>
|
||||
<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"
|
||||
data-filter-limit="3"
|
||||
>
|
||||
</label>
|
||||
<button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button>
|
||||
</div>
|
||||
<p class="helper-text">Während der Suche zeigt Nouri nur die drei passendsten Lebensmittel, damit die Auswahl ruhig bleibt.</p>
|
||||
{% if food_groups %}
|
||||
<div class="stack-sections">
|
||||
<div class="stack-sections" id="meal-components-list">
|
||||
{% for group in food_groups %}
|
||||
<div class="component-group">
|
||||
<div class="panel-head">
|
||||
<h3>{{ group["title"] }}</h3>
|
||||
<span>{{ group["items"]|length }} Einträge</span>
|
||||
</div>
|
||||
<div class="checkbox-grid">
|
||||
<div class="checkbox-grid filterable-checkbox-group" data-filter-group>
|
||||
{% 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 %}>
|
||||
<span>{{ food.name }}</span>
|
||||
<span>{{ food.name }} · {{ food.visibility_label }} · {{ food.for_label }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -93,7 +152,20 @@
|
||||
</label>
|
||||
<label>
|
||||
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>
|
||||
Energiedichte
|
||||
<select name="quick_food_energy_density">
|
||||
{% for value, label in energy_density_options %}
|
||||
<option value="{{ value }}" {% if form_data.quick_food_energy_density == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="wide">
|
||||
Notiz
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||
<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>
|
||||
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
|
||||
</section>
|
||||
@@ -24,6 +24,14 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Sichtbarkeit
|
||||
<select name="visibility">
|
||||
{% for value, label in visibility_options %}
|
||||
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Tageszeit
|
||||
<select name="daypart_id">
|
||||
@@ -46,7 +54,12 @@
|
||||
<article class="item-card">
|
||||
<div class="item-media">
|
||||
{% if item.photo_filename %}
|
||||
<img src="{{ url_for('uploaded_file', filename=item.photo_filename) }}" alt="{{ item.name }}">
|
||||
<img
|
||||
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||
sizes="{{ image_sizes('grid') }}"
|
||||
alt="{{ item.name }}"
|
||||
loading="lazy">
|
||||
{% else %}
|
||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||
{% endif %}
|
||||
@@ -56,10 +69,14 @@
|
||||
<h2>{{ item.name }}</h2>
|
||||
<span class="status-pill status-{{ item.availability_state }}">{{ availability_labels[item.availability_state] }}</span>
|
||||
</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">
|
||||
{% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %}
|
||||
·
|
||||
{{ item_kind_labels[item.kind] }}
|
||||
· {{ item_kind_labels[item.kind] }}
|
||||
</p>
|
||||
{% if item.dayparts %}
|
||||
<div class="chip-row">
|
||||
@@ -76,19 +93,21 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
||||
{% if item.can_edit %}
|
||||
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
||||
{% endif %}
|
||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button type="submit">Auf Einkaufsliste</button>
|
||||
</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) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="secondary" type="submit">Als Zuhause markieren</button>
|
||||
</form>
|
||||
{% 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) }}">
|
||||
{{ csrf_input() }}
|
||||
<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>
|
||||
</section>
|
||||
|
||||
<section class="two-column">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Tagesvorlagen</h2>
|
||||
<a href="{{ url_for('main.day_template_create', source_date=selected_date.isoformat()) }}">Als Vorlage speichern</a>
|
||||
</div>
|
||||
{% if day_templates %}
|
||||
<div class="stack-sections">
|
||||
{% for template in day_templates %}
|
||||
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
|
||||
{{ csrf_input() }}
|
||||
<input type="hidden" name="target_date" value="{{ selected_date.isoformat() }}">
|
||||
<div class="template-card">
|
||||
<strong>{{ template.name }}</strong>
|
||||
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
|
||||
</div>
|
||||
<button type="submit">Vorlage anwenden</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">Wenn du einen Tag öfter wiederverwenden möchtest, kannst du ihn hier als Tagesvorlage speichern.</p>
|
||||
{% endif %}
|
||||
</article>
|
||||
|
||||
{% if day_hints %}
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Heute im Blick</h2>
|
||||
</div>
|
||||
<div class="hint-list">
|
||||
{% for hint in day_hints %}
|
||||
<p class="hint-chip">{{ hint }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="planner-day-stack">
|
||||
{% for section in sections %}
|
||||
<details class="day-tile" id="daypart-{{ section.daypart.id }}" {% if section.is_open %}open{% endif %}>
|
||||
@@ -33,44 +72,141 @@
|
||||
</summary>
|
||||
|
||||
<div class="day-tile-body">
|
||||
{% if section.quick_items %}
|
||||
<div class="quick-add-row">
|
||||
{% for item in section.quick_items %}
|
||||
{% if section.selected_quick_action %}
|
||||
<div class="suggestion-card">
|
||||
<strong>{{ section.selected_quick_action.title }}</strong>
|
||||
<p class="muted">{{ section.selected_quick_action.subtitle }}</p>
|
||||
{% if section.selected_quick_action.type == 'existing' %}
|
||||
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
|
||||
{{ csrf_input() }}
|
||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||
<input type="hidden" name="item_id" value="{{ section.selected_quick_action.item_id }}">
|
||||
<input type="hidden" name="visibility" value="{{ section.selected_quick_action.visibility }}">
|
||||
<button class="secondary" type="submit">Jetzt nur noch speichern</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<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="{{ section.selected_quick_action.title }}">
|
||||
<input type="hidden" name="visibility" value="{{ section.selected_quick_action.visibility }}">
|
||||
{% for component_id in section.selected_quick_action.component_ids %}
|
||||
<input type="hidden" name="component_ids" value="{{ component_id }}">
|
||||
{% endfor %}
|
||||
<button class="secondary" type="submit">Jetzt nur noch speichern</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if section.balance_suggestion %}
|
||||
<div class="suggestion-card">
|
||||
<strong>{{ section.balance_suggestion.text }}</strong>
|
||||
{% if section.balance_suggestion["items"] %}
|
||||
<div class="quick-add-row compact-quick-row">
|
||||
{% for item in section.balance_suggestion["items"] %}
|
||||
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
|
||||
{{ csrf_input() }}
|
||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||
<button class="quick-add-button compact-button" type="submit">
|
||||
<span>{{ item.name }}</span>
|
||||
<small>zuhause vorhanden</small>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if section.meal_candidates %}
|
||||
<div class="planner-subsection">
|
||||
<h3>Mahlzeitenideen</h3>
|
||||
<div class="quick-add-row compact-quick-row">
|
||||
{% for item in section.meal_candidates %}
|
||||
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
|
||||
{{ csrf_input() }}
|
||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||
<button class="quick-add-button compact-button" type="submit">
|
||||
<span>{{ item.name }}</span>
|
||||
{% if item.availability_state == 'home' %}<small>zuhause vorhanden</small>{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if section.recipe_suggestions %}
|
||||
<div class="planner-subsection">
|
||||
<h3>Passt gut dazu</h3>
|
||||
<div class="quick-add-row compact-quick-row">
|
||||
{% for suggestion in section.recipe_suggestions %}
|
||||
{% if suggestion.existing_item_id %}
|
||||
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
|
||||
{{ csrf_input() }}
|
||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||
<input type="hidden" name="item_id" value="{{ suggestion.existing_item_id }}">
|
||||
<input type="hidden" name="visibility" value="{{ suggestion.visibility or 'shared' }}">
|
||||
<button class="quick-add-button compact-button" type="submit">
|
||||
<span>{{ suggestion.title }}</span>
|
||||
<small>{{ suggestion.reason }}</small>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
{% 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 }}" data-filter-limit="3">
|
||||
</label>
|
||||
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
|
||||
{% for item in section.search_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 }}">
|
||||
<button class="quick-add-button" type="submit">
|
||||
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||
<button class="picker-row" type="submit">
|
||||
<span>{{ item.name }}</span>
|
||||
<small>{{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</small>
|
||||
<small>
|
||||
{{ item_kind_labels[item.kind] }}
|
||||
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
||||
</small>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" class="planner-entry-form">
|
||||
{{ csrf_input() }}
|
||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||
<label class="wide">
|
||||
Eintrag hinzufügen
|
||||
<select name="item_id">
|
||||
<option value="">Etwas für {{ section.daypart.name }} wählen</option>
|
||||
{% for item in section.candidates %}
|
||||
<option value="{{ item.id }}" {% if section.selected_item_id == item.id %}selected{% endif %}>
|
||||
{{ item.name }} · {{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}{% if item.dayparts and section.daypart.name not in item.dayparts %} · auch flexibel{% endif %}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{% if section.entries %}
|
||||
<div class="planner-entry-list">
|
||||
@@ -80,17 +216,46 @@
|
||||
<div>
|
||||
<strong>{{ entry.item_name }}</strong>
|
||||
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
|
||||
<div class="chip-row">
|
||||
<span class="chip">{{ entry.visibility_label }}</span>
|
||||
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||
<span class="chip">{{ entry.for_label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, date=selected_date.isoformat()) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Entfernen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if entry.can_edit %}
|
||||
<div class="row-actions">
|
||||
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, date=selected_date.isoformat()) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Entfernen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if entry.note %}
|
||||
<p>{{ entry.note }}</p>
|
||||
{% endif %}
|
||||
{% if entry.can_edit %}
|
||||
<details class="planner-entry-edit">
|
||||
<summary class="ghost-button">Anpassen</summary>
|
||||
<form method="post" action="{{ url_for('main.planner_update', entry_id=entry.id) }}" class="planner-entry-inline-form">
|
||||
{{ csrf_input() }}
|
||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||
<label>
|
||||
Für wen?
|
||||
<select name="visibility">
|
||||
{% for value, label in visibility_options %}
|
||||
<option value="{{ value }}" {% if entry.visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label class="wide">
|
||||
Notiz
|
||||
<input type="text" name="note" value="{{ entry.note or '' }}" placeholder="Optional">
|
||||
</label>
|
||||
<button type="submit">Speichern</button>
|
||||
</form>
|
||||
</details>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<p class="eyebrow">Wochenansicht</p>
|
||||
<h1>Ein sanfter Blick auf die nächsten sieben Tage</h1>
|
||||
<p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben. Wenn etwas noch nicht zuhause ist, landet es dabei automatisch auf der Einkaufsliste.</p>
|
||||
<h1>Ein ruhiger Blick auf die nächsten sieben Tage</h1>
|
||||
<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 class="week-nav">
|
||||
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
|
||||
@@ -14,6 +14,59 @@
|
||||
</div>
|
||||
</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 }}">
|
||||
{% for card in week_cards %}
|
||||
<article class="week-card">
|
||||
@@ -49,9 +102,9 @@
|
||||
{% if slot.entries %}
|
||||
<div class="week-entry-stack">
|
||||
{% 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>
|
||||
<small>{{ item_kind_labels[entry.item_kind] }}</small>
|
||||
<small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
{% 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, Sicherung und iPhone-Nutzung</h1>
|
||||
<p class="lead">Hier lässt sich festlegen, wann Einkäufe vorbereitet werden, welche Hinweise hilfreich sind und wie Nouri sich auf dem Home-Bildschirm oder beim Backup 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>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Für den Homescreen</h2>
|
||||
</div>
|
||||
<div class="stack-sections">
|
||||
<div class="pwa-card">
|
||||
<strong>Auf dem iPhone installieren</strong>
|
||||
<p class="muted">Öffne Nouri in Safari, tippe auf Teilen und dann auf <em>Zum Home-Bildschirm</em>. Danach startet Nouri deutlich app-näher und ruhiger.</p>
|
||||
</div>
|
||||
<div class="pwa-card">
|
||||
<strong>Offline etwas stabiler</strong>
|
||||
<p class="muted">Die wichtigsten Oberflächen und Brand-Dateien bleiben lokal greifbar. Wenn das Netz kurz weg ist, wirkt die App dadurch stabiler und klarer.</p>
|
||||
</div>
|
||||
<div class="pwa-card">
|
||||
<strong>Push-Mitteilungen</strong>
|
||||
{% if push_ready %}
|
||||
<p class="muted">Push ist vorbereitet. Wenn du möchtest, kannst du es auf diesem Gerät freigeben, testen und später für Frühstück, Mittagessen oder Abendessen nutzen.</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 diese App gesetzt sind.</p>
|
||||
{% if push_public_key_value %}
|
||||
<small class="helper-text">Öffentlicher Schlüssel erkannt, privater Schlüssel fehlt noch.</small>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
<label>
|
||||
Vorschläge eher
|
||||
<select name="suggestion_style">
|
||||
{% for value, label in suggestion_style_options %}
|
||||
<option value="{{ value }}" {% if user_settings.suggestion_style == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="helper-text">
|
||||
Ausgewogen bleibt offen. Fitness denkt stärker in proteinbetont und eher leicht. Proteinbetont priorisiert Eiweiß, ohne extra leicht zu werden.
|
||||
</small>
|
||||
</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="push_missing_breakfast" value="1" {% if user_settings.push_missing_breakfast %}checked{% endif %}><span>Um 8:00 erinnern, wenn noch kein Frühstück geplant ist</span></label>
|
||||
<label class="inline-check"><input type="checkbox" name="push_missing_lunch" value="1" {% if user_settings.push_missing_lunch %}checked{% endif %}><span>Um 12:00 erinnern, wenn noch kein Mittagessen geplant ist</span></label>
|
||||
<label class="inline-check"><input type="checkbox" name="push_missing_dinner" value="1" {% if user_settings.push_missing_dinner %}checked{% endif %}><span>Um 18:00 erinnern, wenn noch kein Abendessen geplant 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>
|
||||
<small class="helper-text">Für Push nimmt Nouri zuerst vorhandene Mahlzeitenideen. Wenn nichts passt, kommt eine Kombination aus dem, was zuhause da ist.</small>
|
||||
</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>
|
||||
|
||||
{% if is_admin() %}
|
||||
<section class="two-column">
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Backup exportieren</h2>
|
||||
</div>
|
||||
<div class="stack-sections">
|
||||
<div class="pwa-card">
|
||||
<strong>Komplettes App-Backup</strong>
|
||||
<p class="muted">Das ZIP enthält Nutzer, Einstellungen, Lebensmittel, Mahlzeiten, Vorlagen, Planungen, Einkaufsdaten und hochgeladene Bilder.</p>
|
||||
<a class="button" href="{{ url_for('main.backup_export') }}">Backup herunterladen</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Backup wiederherstellen</h2>
|
||||
</div>
|
||||
<form method="post" action="{{ url_for('main.backup_restore') }}" class="stack-form" enctype="multipart/form-data">
|
||||
{{ csrf_input() }}
|
||||
<div class="restore-warning">
|
||||
<strong>Nur bewusst verwenden</strong>
|
||||
<p class="muted">Die Wiederherstellung ersetzt den aktuellen Datenstand dieses Haushalts. Vorher am besten selbst noch ein frisches Backup herunterladen.</p>
|
||||
</div>
|
||||
<label>
|
||||
Backup-Datei
|
||||
<input type="file" name="backup_file" accept=".zip" required>
|
||||
</label>
|
||||
<label>
|
||||
Zur Bestätigung bitte {{ restore_confirmation_text }} eintragen
|
||||
<input type="text" name="restore_confirmation" placeholder="{{ restore_confirmation_text }}" required>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit">Backup wiederherstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -5,7 +5,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Einkaufsliste</p>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
<select name="item_id">
|
||||
<option value="">Bestehenden Eintrag hinzufügen</option>
|
||||
{% 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 %}
|
||||
</select>
|
||||
<button type="submit">Auf Liste setzen</button>
|
||||
@@ -23,22 +26,41 @@
|
||||
</section>
|
||||
|
||||
{% 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">
|
||||
{% for entry in entries %}
|
||||
<article class="list-row">
|
||||
<article class="list-row stacked-mobile roomy-row">
|
||||
<div>
|
||||
<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 class="row-actions">
|
||||
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button type="submit">Eingekauft</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Entfernen</button>
|
||||
</form>
|
||||
{% if entry.can_edit %}
|
||||
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Entfernen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
@@ -46,7 +68,33 @@
|
||||
{% else %}
|
||||
<section class="panel empty-panel">
|
||||
<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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
Flask==3.1.1
|
||||
gunicorn==23.0.0
|
||||
pywebpush==2.3.0
|
||||
Pillow==11.2.1; python_version < "3.14"
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BRAND_DIR = ROOT / "nouri" / "static" / "brand"
|
||||
|
||||
BG_TOP = "#F6C394"
|
||||
BG_BOTTOM = "#DE9862"
|
||||
BOWL = "#FFF7EF"
|
||||
STROKE = "#8C533B"
|
||||
|
||||
|
||||
def rounded_gradient_icon(size: int, *, maskable: bool = False) -> Image.Image:
|
||||
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
radius = int(size * (0.28 if maskable else 0.24))
|
||||
|
||||
for y in range(size):
|
||||
blend = y / max(size - 1, 1)
|
||||
r1, g1, b1 = ImageColorTuple(BG_TOP)
|
||||
r2, g2, b2 = ImageColorTuple(BG_BOTTOM)
|
||||
color = (
|
||||
int(r1 + (r2 - r1) * blend),
|
||||
int(g1 + (g2 - g1) * blend),
|
||||
int(b1 + (b2 - b1) * blend),
|
||||
255,
|
||||
)
|
||||
draw.line((0, y, size, y), fill=color)
|
||||
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255)
|
||||
image.putalpha(mask)
|
||||
|
||||
inset = int(size * (0.11 if maskable else 0.13))
|
||||
inner = [inset, inset, size - inset, size - inset]
|
||||
draw.rounded_rectangle(inner, radius=int(radius * 0.84), outline=(255, 255, 255, 54), width=max(2, size // 80))
|
||||
|
||||
bowl_top = int(size * 0.24)
|
||||
bowl_left = int(size * 0.27)
|
||||
bowl_right = int(size * 0.73)
|
||||
bowl_bottom = int(size * 0.68)
|
||||
draw.rounded_rectangle(
|
||||
(bowl_left, bowl_top, bowl_right, bowl_bottom),
|
||||
radius=int(size * 0.16),
|
||||
fill=BOWL,
|
||||
)
|
||||
draw.rounded_rectangle(
|
||||
(int(size * 0.31), int(size * 0.31), int(size * 0.69), int(size * 0.60)),
|
||||
radius=int(size * 0.12),
|
||||
fill=(239, 195, 159, 64),
|
||||
)
|
||||
|
||||
bowl_curve_top = int(size * 0.56)
|
||||
draw.pieslice(
|
||||
(int(size * 0.24), bowl_curve_top, int(size * 0.76), int(size * 0.84)),
|
||||
start=0,
|
||||
end=180,
|
||||
fill=(247, 179, 125, 255),
|
||||
)
|
||||
|
||||
line_width = max(4, size // 26)
|
||||
steam = [
|
||||
(0.50, 0.31),
|
||||
(0.56, 0.31),
|
||||
(0.56, 0.40),
|
||||
(0.45, 0.50),
|
||||
(0.45, 0.58),
|
||||
]
|
||||
draw.line([(int(size * x), int(size * y)) for x, y in steam], fill=STROKE, width=line_width, joint="curve")
|
||||
draw.line(
|
||||
[(int(size * 0.45), int(size * 0.58)), (int(size * 0.56), int(size * 0.58))],
|
||||
fill=STROKE,
|
||||
width=line_width,
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
def ImageColorTuple(hex_color: str) -> tuple[int, int, int]:
|
||||
hex_color = hex_color.lstrip("#")
|
||||
return tuple(int(hex_color[index:index + 2], 16) for index in (0, 2, 4))
|
||||
|
||||
|
||||
def badge_icon(size: int) -> Image.Image:
|
||||
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=size // 3, fill=BG_BOTTOM)
|
||||
draw.ellipse((size * 0.18, size * 0.18, size * 0.82, size * 0.82), fill=BOWL)
|
||||
draw.rectangle((size * 0.34, size * 0.52, size * 0.66, size * 0.63), fill=STROKE)
|
||||
return image
|
||||
|
||||
|
||||
def save_assets() -> None:
|
||||
BRAND_DIR.mkdir(parents=True, exist_ok=True)
|
||||
rounded_gradient_icon(180).save(BRAND_DIR / "pwa-180.png")
|
||||
rounded_gradient_icon(192).save(BRAND_DIR / "pwa-192.png")
|
||||
rounded_gradient_icon(512).save(BRAND_DIR / "pwa-512.png")
|
||||
rounded_gradient_icon(512, maskable=True).save(BRAND_DIR / "pwa-maskable-512.png")
|
||||
badge_icon(96).save(BRAND_DIR / "pwa-badge.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
save_assets()
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from py_vapid import Vapid01, b64urlencode
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
|
||||
|
||||
def main() -> None:
|
||||
vapid = Vapid01()
|
||||
vapid.generate_keys()
|
||||
|
||||
public_key = b64urlencode(
|
||||
vapid.public_key.public_bytes(
|
||||
encoding=serialization.Encoding.X962,
|
||||
format=serialization.PublicFormat.UncompressedPoint,
|
||||
)
|
||||
)
|
||||
private_value = vapid.private_key.private_numbers().private_value
|
||||
private_key = b64urlencode(private_value.to_bytes(32, "big"))
|
||||
|
||||
print(f"NOURI_VAPID_PUBLIC_KEY={public_key}")
|
||||
print(f"NOURI_VAPID_PRIVATE_KEY={private_key}")
|
||||
print("NOURI_VAPID_SUBJECT=mailto:mail@hnz.io")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from nouri import create_app
|
||||
from nouri.reminders import reminder_worker_loop, send_due_meal_pushes
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run Nouri meal reminder pushes.")
|
||||
parser.add_argument("--once", action="store_true", help="Run one reminder cycle and exit.")
|
||||
parser.add_argument("--sleep", type=int, default=60, help="Seconds between reminder cycles.")
|
||||
args = parser.parse_args()
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
if args.once:
|
||||
send_due_meal_pushes()
|
||||
return
|
||||
reminder_worker_loop(sleep_seconds=max(15, args.sleep))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
set -eu
|
||||
|
||||
mkdir -p /app/data/uploads
|
||||
|
||||
# Vorhandene lokale SQLite-Datei beim allerersten Start übernehmen
|
||||
if [ ! -f /app/data/nouri.sqlite3 ] && [ -f /app/bootstrap-data/nouri.sqlite3 ]; then
|
||||
cp /app/bootstrap-data/nouri.sqlite3 /app/data/nouri.sqlite3
|
||||
fi
|
||||
|
||||
# Vorhandene Uploads beim allerersten Start übernehmen
|
||||
if [ -d /app/bootstrap-data/uploads ] && [ -z "$(ls -A /app/data/uploads 2>/dev/null || true)" ]; then
|
||||
cp -a /app/bootstrap-data/uploads/. /app/data/uploads/
|
||||
fi
|
||||
|
||||
if [ "${NOURI_RUN_REMINDER_WORKER:-1}" = "1" ]; then
|
||||
(
|
||||
sleep "${NOURI_REMINDER_START_DELAY:-25}"
|
||||
python /app/code/scripts/reminder_worker.py --sleep 60
|
||||
) &
|
||||
fi
|
||||
|
||||
exec gunicorn --bind 0.0.0.0:8000 --workers 1 --threads 4 wsgi:app
|
||||