Compare commits
29 Commits
V0.5.0
..
c5dea16c53
| Author | SHA1 | Date | |
|---|---|---|---|
| c5dea16c53 | |||
| e057cf0382 | |||
| 5a1c1d5c41 | |||
| f85ec81851 | |||
| bc31430a1e | |||
| 93793a456e | |||
| f17ab27c2e | |||
| a810162221 | |||
| 305440a6b2 | |||
| 6c7c1f01c9 | |||
| 7b751b4d47 | |||
| 03584c4b97 | |||
| 0d03f21a4c | |||
| d0d5bad803 | |||
| 7faa65d6c9 | |||
| 57b56bc797 | |||
| 1c87d653d6 | |||
| 1490fc8f1d | |||
| 40bab48806 | |||
| ed14dd4aef | |||
| 35e6a7b56e | |||
| 325101da99 | |||
| b0d1cee5f5 | |||
| a26d519cf2 | |||
| 555fddab80 | |||
| 9ff7a6d57c | |||
| cf5157c496 | |||
| dffbe26423 | |||
| 732e7918af |
@@ -9,3 +9,6 @@ __pycache__/
|
|||||||
|
|
||||||
data/
|
data/
|
||||||
instance/
|
instance/
|
||||||
|
.cloudron-push.env
|
||||||
|
.env.local
|
||||||
|
.env.push.local
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
"author": "Florian Heinz",
|
"author": "Florian Heinz",
|
||||||
"description": "Private Flask app for meals, shopping and gentle food planning",
|
"description": "Private Flask app for meals, shopping and gentle food planning",
|
||||||
"tagline": "einfach essen planen",
|
"tagline": "einfach essen planen",
|
||||||
"version": "0.5.0",
|
"version": "1.2.2",
|
||||||
"upstreamVersion": "0.5.0",
|
"upstreamVersion": "1.2.2",
|
||||||
"healthCheckPath": "/",
|
"healthCheckPath": "/",
|
||||||
"httpPort": 8000,
|
"httpPort": 8000,
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
sqlite3 \
|
sqlite3 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN useradd -r -m -d /home/cloudron cloudron
|
RUN groupadd --gid 1000 cloudron \
|
||||||
|
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/cloudron cloudron
|
||||||
|
|
||||||
COPY requirements.txt /app/code/
|
COPY requirements.txt /app/code/
|
||||||
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||||
|
|||||||
@@ -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.
|
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.5
|
## Merkmale in Version 1.0.0
|
||||||
|
|
||||||
- Lebensmittel und Mahlzeitenideen anlegen
|
- Lebensmittel und Mahlzeitenideen anlegen
|
||||||
- Fotos lokal hochladen
|
- Fotos lokal hochladen
|
||||||
@@ -22,12 +22,8 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein
|
|||||||
- ruhige Hinweise und Vorschläge aus Zuhause, Archiv und bisherigen Planungen
|
- ruhige Hinweise und Vorschläge aus Zuhause, Archiv und bisherigen Planungen
|
||||||
- globale Kategorien pro Haushalt
|
- globale Kategorien pro Haushalt
|
||||||
- „Für wen?“ direkt an Lebensmitteln und Mahlzeiten
|
- „Für wen?“ direkt an Lebensmitteln und Mahlzeiten
|
||||||
- Mobile-Mehr-Menü als Sheet statt eigener Seite
|
|
||||||
- Einkaufsrhythmus mit geplantem Einkaufstag und später aktivierten Bedarfen
|
- Einkaufsrhythmus mit geplantem Einkaufstag und später aktivierten Bedarfen
|
||||||
- ausgewogene Ergänzungsvorschläge auf Basis ruhiger Bausteine
|
- PWA-Grundlage mit Web App Manifest, Service Worker und optionalem Web Push
|
||||||
- einfache Kombinations- und Rezeptideen aus zuhause vorhandenen Lebensmitteln
|
|
||||||
- Optionen für Erinnerungen, Hinweise und kleine Routinen
|
|
||||||
- PWA-Vorbereitung mit Web App Manifest, Service Worker und optionalem Web Push
|
|
||||||
|
|
||||||
## Lokal starten
|
## Lokal starten
|
||||||
|
|
||||||
@@ -47,28 +43,104 @@ Die App legt Daten standardmäßig unter `./data` ab.
|
|||||||
Wichtige Umgebungsvariablen:
|
Wichtige Umgebungsvariablen:
|
||||||
|
|
||||||
- `NOURI_SECRET_KEY`: Session-Secret für Produktion
|
- `NOURI_SECRET_KEY`: Session-Secret für Produktion
|
||||||
- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron
|
- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, lokal standardmäßig `./data`, auf Cloudron `/app/data`
|
||||||
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
|
- `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_PUBLIC_KEY`: öffentlicher VAPID-Schlüssel für Web Push
|
||||||
- `NOURI_VAPID_PRIVATE_KEY`: privater 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`
|
- `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io`
|
||||||
|
|
||||||
## Migration von 0.4 auf 0.5
|
## Migration und Datenhaltung
|
||||||
|
|
||||||
Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite weiter: Einkaufsrhythmus, vorgemerkte Bedarfe, Nutzer-Einstellungen, Push-Registrierungen und Baustein-Zuordnungen für Kategorien werden ergänzt. Vorhandene 0.4-Daten bleiben erhalten und werden weiterverwendet.
|
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 ist die App jetzt so vorbereitet, dass Datenbank und Uploads unter `/app/data` liegen. Das Startskript setzt `NOURI_DATA_DIR=/app/data`, legt die SQLite-Datei dort an und startet die App per `gunicorn`.
|
|
||||||
|
|
||||||
Lokale Testdaten und produktive Cloudron-Daten bleiben bewusst getrennt:
|
|
||||||
|
|
||||||
- lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data`
|
- lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data`
|
||||||
- auf Cloudron nutzt Nouri `/app/data`
|
- auf Cloudron nutzt Nouri `/app/data`
|
||||||
- `data/` ist in `.gitignore` und `.dockerignore` ausgeschlossen und wird weder eingecheckt noch ins Image kopiert
|
- `data/` ist nicht für Git oder das Paket gedacht
|
||||||
- `/app/data` ist auf Cloudron persistent und bleibt bei App-Updates erhalten
|
- produktive Daten unter `/app/data` bleiben bei Updates erhalten
|
||||||
|
|
||||||
Wenn die App auf Cloudron bereits installiert ist, bitte **kein neues `cloudron install`** ausführen. Stattdessen die bestehende App aktualisieren, also ein neues Image bzw. Paket bauen und dann die vorhandene Installation updaten.
|
## 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
|
## Lizenz
|
||||||
|
|
||||||
|
|||||||
@@ -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,53 @@
|
|||||||
|
# Nouri 1.1.1
|
||||||
|
|
||||||
|
Nouri 1.1.1 bündelt die jüngsten Verbesserungen rund um Mahlzeiten-Vorschläge, Plan-Einträge, Push-Erinnerungen und den letzten Feinschliff bei Bezeichnungen und Versionierung. Der Release macht die App im Alltag direkter nutzbar und runder im Verhalten.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- generierte Mahlzeiten lassen sich pro Nutzer dauerhaft ausblenden
|
||||||
|
- vorhandene Mahlzeiten mit nur 1 bis 2 fehlenden Zutaten werden jetzt ebenfalls vorgeschlagen
|
||||||
|
- einzelne Plan-Einträge können nachträglich für `Für mich` oder `Gemeinsam` angepasst werden
|
||||||
|
- Frühstück-, Mittag- und Abend-Erinnerungen arbeiten zuverlässiger über echte Zeitfenster
|
||||||
|
- Begriffe wie `Mahlzeitideen` werden wieder korrekt großgeschrieben
|
||||||
|
- App- und Cloudron-Version stehen jetzt auf `1.1.1`
|
||||||
|
|
||||||
|
## Neu in 1.1.1
|
||||||
|
|
||||||
|
### Mahlzeiten und Vorschläge
|
||||||
|
|
||||||
|
- Im Bereich `Was zuhause gut zusammenpasst` werden die Aktionsbuttons wieder korrekt dargestellt.
|
||||||
|
- Generierte Mahlzeiten können mit `Dauerhaft ausblenden` pro Nutzer aus den Vorschlägen entfernt werden.
|
||||||
|
- Nouri zeigt jetzt nicht nur vollständige Kombinationen aus Zuhause an.
|
||||||
|
- Auch vorhandene Mahlzeitenideen mit nur 1 oder 2 fehlenden Lebensmitteln werden vorgeschlagen.
|
||||||
|
- Fehlende Dinge werden direkt kenntlich gemacht, zum Beispiel mit `Es fehlt noch: ...`.
|
||||||
|
|
||||||
|
### Plan und Tagesansicht
|
||||||
|
|
||||||
|
- Ein einzelner Planeintrag kann jetzt im Tagesplan direkt angepasst werden.
|
||||||
|
- So lässt sich zum Beispiel ein geplanter Snack nachträglich nur für eine Person setzen, ohne die Grundeinstellungen der Mahlzeit oder des Lebensmittels zu ändern.
|
||||||
|
- Die Anzeige `Für mich`, `Für alle` und persönliche Zuordnungen ist in diesem Zusammenhang klarer geworden.
|
||||||
|
|
||||||
|
### Push und Erinnerungen
|
||||||
|
|
||||||
|
- Die zeitgesteuerten Erinnerungen für fehlendes Frühstück, Mittagessen und Abendessen laufen nicht mehr nur in einem sehr kleinen Zeitfenster.
|
||||||
|
- Stattdessen nutzt Nouri jetzt breitere Zeitfenster:
|
||||||
|
- Frühstück ab `08:00`
|
||||||
|
- Mittagessen ab `12:00`
|
||||||
|
- Abendessen ab `18:00`
|
||||||
|
- Dadurch greifen die normalen Erinnerungen deutlich zuverlässiger, auch wenn der Reminder-Worker nicht exakt in derselben Minute läuft.
|
||||||
|
|
||||||
|
### 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 Versions-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.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Nouri 1.2.0
|
||||||
|
|
||||||
|
Nouri 1.2.0 bündelt die Weiterentwicklung seit 1.1.1 zu einem ruhigeren und alltagstauglicheren Planungs-Release. Der Fokus lag auf weniger Überforderung im Plan, besseren kleinen Erinnerungen und einem sauberen PDF-Export für den Wochenplan.
|
||||||
|
|
||||||
|
## Neu in 1.2.0
|
||||||
|
|
||||||
|
### Snacks ruhiger im Tages- und Wochenplan
|
||||||
|
|
||||||
|
- Hauptmahlzeiten bleiben immer sichtbar.
|
||||||
|
- Snack-Bereiche werden nur bei Bedarf eingeblendet.
|
||||||
|
- Leere Snack-Slots lassen sich wieder ausblenden.
|
||||||
|
- In der Wochenansicht wurden die Snack-Aktionen sprachlich gestrafft:
|
||||||
|
- `Snacks ergänzen`
|
||||||
|
- `Vormittag`
|
||||||
|
- `Nachmittag`
|
||||||
|
- `Abend`
|
||||||
|
|
||||||
|
### Bessere visuelle Betonung im Plan
|
||||||
|
|
||||||
|
- Ausgewählte und eingetragene Mahlzeiten werden im Tagesplan klarer hervorgehoben.
|
||||||
|
- Die Wochenansicht betont gefüllte Slots jetzt ähnlich wie die Tagesansicht.
|
||||||
|
- Snack-Slots fügen sich in der Wochenansicht stimmiger ein und wirken ruhiger.
|
||||||
|
|
||||||
|
### Kleine tägliche Snack-Erinnerung
|
||||||
|
|
||||||
|
- Neue Option in den Einstellungen:
|
||||||
|
- `Am Nachmittag an etwas Kleines erinnern`
|
||||||
|
- Wenn noch kein Snack geplant ist, kann Nouri einmal täglich eine kleine Push-Erinnerung schicken.
|
||||||
|
- Die Push-Nachricht nimmt zuerst passende Mahlzeitenideen und sonst einfache Kombinationen aus dem, was zuhause da ist.
|
||||||
|
|
||||||
|
### Wochenplan als PDF exportieren
|
||||||
|
|
||||||
|
- Die Wochenansicht kann jetzt als PDF exportiert werden.
|
||||||
|
- Der Export ist schlicht und druckfreundlich gehalten.
|
||||||
|
- Es gibt zwei Varianten:
|
||||||
|
- `Meinen Essensplan`
|
||||||
|
- `Unseren Essensplan`
|
||||||
|
- Im gemeinsamen PDF werden persönliche Einträge mit echten Namen gekennzeichnet, zum Beispiel `Für Flo`.
|
||||||
|
- Snack-Zeilen erscheinen nur dann, wenn sie in der Woche tatsächlich genutzt werden.
|
||||||
|
|
||||||
|
### Export-Menü vereinfacht
|
||||||
|
|
||||||
|
- Statt zwei einzelner Export-Buttons gibt es jetzt einen einzigen Button:
|
||||||
|
- `PDF exportieren`
|
||||||
|
- Darunter öffnet sich eine kleine Auswahl für die beiden PDF-Varianten.
|
||||||
|
|
||||||
|
## Technische Änderungen
|
||||||
|
|
||||||
|
- `fpdf2` wurde als Abhängigkeit ergänzt.
|
||||||
|
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.2.0`.
|
||||||
|
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.2.0` angehoben.
|
||||||
|
|
||||||
|
## Betroffene Bereiche
|
||||||
|
|
||||||
|
- Tagesplan
|
||||||
|
- Wochenansicht
|
||||||
|
- Push-Erinnerungen
|
||||||
|
- Einstellungen
|
||||||
|
- PDF-Export
|
||||||
|
- Cloudron-Paketierung
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Nouri 1.2.1
|
||||||
|
|
||||||
|
Nouri 1.2.1 ist ein Feinschliff-Release auf Basis von 1.2.0. Der Schwerpunkt lag auf einer ruhigeren mobilen Navigation, klareren Theme-Bedienelementen und besser lesbaren Tageszeiten-Icons in Hell und Dunkel.
|
||||||
|
|
||||||
|
## Neu in 1.2.1
|
||||||
|
|
||||||
|
### Mobile Navigation ruhiger abgestimmt
|
||||||
|
|
||||||
|
- Der `Mehr`-Button in der mobilen Bottom-Navigation bleibt im Ruhezustand jetzt neutral.
|
||||||
|
- Wenn `Mehr` geöffnet ist, nutzt der Button dieselbe aktive Markierung wie die übrige Navigation.
|
||||||
|
- `Hell` und `Abmelden` wirken im mobilen Dark Mode zurückhaltender und sind nicht mehr unnötig stark eingefärbt.
|
||||||
|
|
||||||
|
### Besseres Theme-Umschalten
|
||||||
|
|
||||||
|
- Für den Wechsel zwischen Hell und Dunkel gibt es jetzt eigene Sonne- und Mond-Icons.
|
||||||
|
- Die Theme-Anzeige schaltet in Mobile und Desktop sichtbar mit um.
|
||||||
|
- Die Bedienelemente für den Darstellungswechsel wirken dadurch klarer und weniger technisch.
|
||||||
|
|
||||||
|
### Eigene Icons für Tageszeiten
|
||||||
|
|
||||||
|
- `Frühstück`, `Mittagessen`, `Abendessen` und die Snack-Zeiten haben jetzt eigene Symbole statt eines gemeinsamen Standardsymbols.
|
||||||
|
- Die Icons wurden aus `heinz.marketing` übernommen und lokal ins Projekt eingebunden.
|
||||||
|
- Dadurch sind Tageszeiten im Tagesplan und in der Wochenansicht schneller erfassbar.
|
||||||
|
|
||||||
|
### Icons auf Mobile lesbarer gemacht
|
||||||
|
|
||||||
|
- Die Tageszeiten-Kacheln nutzen jetzt quadratischere Icon-Flächen mit abgerundeten Ecken.
|
||||||
|
- Die Symbole wurden vergrößert und farblich klarer abgestimmt.
|
||||||
|
- Im Dark Mode wirken die Icon-Flächen weniger verwaschen.
|
||||||
|
- Im Light Mode wurde der Kontrast erhöht, damit die Symbole nicht mehr im Kartenhintergrund verschwinden.
|
||||||
|
|
||||||
|
## Technische Änderungen
|
||||||
|
|
||||||
|
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.2.1`.
|
||||||
|
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.2.1` angehoben.
|
||||||
|
|
||||||
|
## Betroffene Bereiche
|
||||||
|
|
||||||
|
- Mobile Navigation
|
||||||
|
- Theme-Umschaltung
|
||||||
|
- Tagesplan
|
||||||
|
- Wochenansicht
|
||||||
|
- Cloudron-Paketierung
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Nouri 1.2.2
|
||||||
|
|
||||||
|
Nouri 1.2.2 ist ein kleiner Pflege-Release auf Basis von 1.2.1. Der Schwerpunkt lag auf einer stimmigeren Oberfläche im Dark Mode und einer klareren Sprache in der neuen Lebensmittel-Logik.
|
||||||
|
|
||||||
|
## Neu in 1.2.2
|
||||||
|
|
||||||
|
- Die Builder-Bereiche in der Mahlzeitenansicht wirken im Dark Mode jetzt ruhiger und wärmer.
|
||||||
|
- Die hellgrauen Flächen bei `Zuhause`, `Merkliste` und dem direkten Anlegen neuer Lebensmittel wurden an die restliche Nouri-Oberfläche angepasst.
|
||||||
|
- Die Sprache im Lebensmittel-Formular wurde vereinfacht:
|
||||||
|
- `Passt eher zu` heißt jetzt `Baustein`
|
||||||
|
- `Kohlenhydratquelle` wird in der UI als `Kohlenhydrate` angezeigt
|
||||||
|
|
||||||
|
## Enthaltene Feinschliffe seit 1.2.1
|
||||||
|
|
||||||
|
- Neue Datenlogik für Lebensmittel und Vorschläge:
|
||||||
|
- Grundtyp, Rolle in Vorschlägen, Tageszeiten, Vorschlagsstärke und Mahlzeitenkern wurden klarer getrennt.
|
||||||
|
- Die Builder-Ansichten wurden mit kartenbasierten Auswahlfeldern und zusätzlichen Icons überarbeitet.
|
||||||
|
- Navigation und unterstützende Texte wurden weiter beruhigt und vereinheitlicht.
|
||||||
|
|
||||||
|
## Technisch
|
||||||
|
|
||||||
|
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.2.2`.
|
||||||
|
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.2.2` angehoben.
|
||||||
|
|
||||||
|
## Hinweis zum Update
|
||||||
|
|
||||||
|
- Beim Cloudron-Update sollte der `data`-Ordner weiterhin schreibbar sein, damit mögliche Datenbank-Updates sauber durchlaufen.
|
||||||
|
- Ein reguläres Cloudron-Update mit Backup ist für diesen Stand die sichere Variante.
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, g, send_from_directory
|
from flask import Flask, flash, g, redirect, request, send_from_directory, url_for
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
from .admin import admin_bp
|
from .admin import admin_bp
|
||||||
@@ -16,19 +17,44 @@ from .constants import (
|
|||||||
BUILDER_OPTIONS,
|
BUILDER_OPTIONS,
|
||||||
DAYPARTS,
|
DAYPARTS,
|
||||||
DEFAULT_CATEGORIES,
|
DEFAULT_CATEGORIES,
|
||||||
|
ENERGY_DENSITY_LABELS,
|
||||||
|
ENERGY_DENSITY_OPTIONS,
|
||||||
|
FOOD_ROLE_DESCRIPTIONS,
|
||||||
|
FOOD_ROLE_LABELS,
|
||||||
|
FOOD_ROLE_OPTIONS,
|
||||||
ITEM_KIND_LABELS,
|
ITEM_KIND_LABELS,
|
||||||
ITEM_KIND_SINGULAR_LABELS,
|
ITEM_KIND_SINGULAR_LABELS,
|
||||||
|
MEAL_STYLE_LABELS,
|
||||||
|
MEAL_STYLE_OPTIONS,
|
||||||
|
MEAL_TYPE_LABELS,
|
||||||
|
MEAL_TYPE_OPTIONS,
|
||||||
NOTIFICATION_CHANNEL_OPTIONS,
|
NOTIFICATION_CHANNEL_OPTIONS,
|
||||||
|
PROTEIN_PREFERENCE_LABELS,
|
||||||
|
PROTEIN_PREFERENCE_OPTIONS,
|
||||||
ROLE_LABELS,
|
ROLE_LABELS,
|
||||||
|
SUGGESTION_PRIORITY_LABELS,
|
||||||
|
SUGGESTION_PRIORITY_OPTIONS,
|
||||||
|
SUGGESTION_STYLE_LABELS,
|
||||||
|
SUGGESTION_STYLE_OPTIONS,
|
||||||
VISIBILITY_DESCRIPTIONS,
|
VISIBILITY_DESCRIPTIONS,
|
||||||
VISIBILITY_LABELS,
|
VISIBILITY_LABELS,
|
||||||
WEEKDAY_OPTIONS,
|
WEEKDAY_OPTIONS,
|
||||||
)
|
)
|
||||||
|
from .images import ensure_upload_structure, image_sizes, image_srcset, image_url
|
||||||
from .main import main_bp
|
from .main import main_bp
|
||||||
|
|
||||||
|
|
||||||
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||||
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||||
|
DEFAULT_RELEASE_URL = "https://git.hnz.io/hnzio/nouri-App/releases"
|
||||||
|
DAYPART_ICON_CLASSES = {
|
||||||
|
"breakfast": "icon-daypart-breakfast",
|
||||||
|
"morning-snack": "icon-daypart-morning-snack",
|
||||||
|
"lunch": "icon-daypart-lunch",
|
||||||
|
"afternoon-snack": "icon-daypart-afternoon-snack",
|
||||||
|
"dinner": "icon-daypart-dinner",
|
||||||
|
"late-snack": "icon-daypart-late-snack",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_secret_key(data_dir: Path) -> str:
|
def load_secret_key(data_dir: Path) -> str:
|
||||||
@@ -49,14 +75,41 @@ def load_secret_key(data_dir: Path) -> str:
|
|||||||
return secret_value
|
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.2.2"
|
||||||
|
|
||||||
|
|
||||||
|
def load_release_url() -> str:
|
||||||
|
return os.environ.get("NOURI_RELEASE_URL", DEFAULT_RELEASE_URL).strip() or DEFAULT_RELEASE_URL
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
root_dir = Path(__file__).resolve().parent.parent
|
root_dir = Path(__file__).resolve().parent.parent
|
||||||
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
|
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
|
||||||
upload_dir = data_dir / "uploads"
|
upload_dir = data_dir / "uploads"
|
||||||
db_path = data_dir / "nouri.sqlite3"
|
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)
|
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 = Flask(__name__, instance_relative_config=False)
|
||||||
app.config.update(
|
app.config.update(
|
||||||
@@ -68,7 +121,10 @@ def create_app() -> Flask:
|
|||||||
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
|
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
|
||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SAMESITE="Lax",
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
APP_VERSION="0.5.0",
|
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_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
|
||||||
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
|
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
|
||||||
VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"),
|
VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"),
|
||||||
@@ -83,6 +139,11 @@ def create_app() -> Flask:
|
|||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_globals() -> dict[str, object]:
|
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 {
|
return {
|
||||||
"item_kind_labels": ITEM_KIND_LABELS,
|
"item_kind_labels": ITEM_KIND_LABELS,
|
||||||
"item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS,
|
"item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS,
|
||||||
@@ -90,7 +151,22 @@ def create_app() -> Flask:
|
|||||||
"builder_labels": BUILDER_LABELS,
|
"builder_labels": BUILDER_LABELS,
|
||||||
"builder_descriptions": BUILDER_DESCRIPTIONS,
|
"builder_descriptions": BUILDER_DESCRIPTIONS,
|
||||||
"builder_options": BUILDER_OPTIONS,
|
"builder_options": BUILDER_OPTIONS,
|
||||||
|
"food_role_labels": FOOD_ROLE_LABELS,
|
||||||
|
"food_role_descriptions": FOOD_ROLE_DESCRIPTIONS,
|
||||||
|
"food_role_options": FOOD_ROLE_OPTIONS,
|
||||||
|
"suggestion_priority_labels": SUGGESTION_PRIORITY_LABELS,
|
||||||
|
"suggestion_priority_options": SUGGESTION_PRIORITY_OPTIONS,
|
||||||
"daypart_suggestions": DAYPARTS,
|
"daypart_suggestions": DAYPARTS,
|
||||||
|
"energy_density_options": ENERGY_DENSITY_OPTIONS,
|
||||||
|
"energy_density_labels": ENERGY_DENSITY_LABELS,
|
||||||
|
"meal_type_options": MEAL_TYPE_OPTIONS,
|
||||||
|
"meal_type_labels": MEAL_TYPE_LABELS,
|
||||||
|
"meal_style_options": MEAL_STYLE_OPTIONS,
|
||||||
|
"meal_style_labels": MEAL_STYLE_LABELS,
|
||||||
|
"suggestion_style_options": SUGGESTION_STYLE_OPTIONS,
|
||||||
|
"suggestion_style_labels": SUGGESTION_STYLE_LABELS,
|
||||||
|
"protein_preference_options": PROTEIN_PREFERENCE_OPTIONS,
|
||||||
|
"protein_preference_labels": PROTEIN_PREFERENCE_LABELS,
|
||||||
"visibility_labels": VISIBILITY_LABELS,
|
"visibility_labels": VISIBILITY_LABELS,
|
||||||
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
|
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
|
||||||
"role_labels": ROLE_LABELS,
|
"role_labels": ROLE_LABELS,
|
||||||
@@ -98,23 +174,71 @@ def create_app() -> Flask:
|
|||||||
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
|
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
|
||||||
"today": date.today(),
|
"today": date.today(),
|
||||||
"app_version": app.config["APP_VERSION"],
|
"app_version": app.config["APP_VERSION"],
|
||||||
|
"app_release_url": app.config["RELEASE_URL"],
|
||||||
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
|
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
|
||||||
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
|
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
|
||||||
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
||||||
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
|
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
|
||||||
|
"daypart_icon_class": lambda slug: DAYPART_ICON_CLASSES.get(slug, "icon-calendar"),
|
||||||
"is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin",
|
"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>")
|
@app.get("/uploads/<path:filename>")
|
||||||
def uploaded_file(filename: str):
|
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")
|
@app.get("/app.webmanifest")
|
||||||
def webmanifest():
|
def webmanifest():
|
||||||
return send_from_directory(root_dir / "nouri" / "static" / "pwa", "app.webmanifest", mimetype="application/manifest+json")
|
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")
|
@app.get("/service-worker.js")
|
||||||
def service_worker():
|
def service_worker():
|
||||||
return send_from_directory(root_dir / "nouri" / "static" / "pwa", "service-worker.js", mimetype="application/javascript")
|
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
|
return app
|
||||||
|
|||||||
@@ -289,3 +289,31 @@ def category_update(category_id: int):
|
|||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash("Die Zuordnung wurde aktualisiert.", "success")
|
flash("Die Zuordnung wurde aktualisiert.", "success")
|
||||||
return redirect(url_for("admin.category_settings"))
|
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"))
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ def normalize_login_value(raw: str) -> str:
|
|||||||
return raw.strip().lower()
|
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:
|
def validate_identity_fields(database, username: str, email: str | None, current_user_id: int | None = None) -> str | None:
|
||||||
if not username:
|
if not username:
|
||||||
return "Bitte einen Benutzernamen eintragen."
|
return "Bitte einen Benutzernamen eintragen."
|
||||||
@@ -140,6 +146,8 @@ def setup():
|
|||||||
error = "Bitte ein Passwort vergeben."
|
error = "Bitte ein Passwort vergeben."
|
||||||
elif error is None and password != password_repeat:
|
elif error is None and password != password_repeat:
|
||||||
error = "Die Passwörter stimmen nicht überein."
|
error = "Die Passwörter stimmen nicht überein."
|
||||||
|
elif error is None:
|
||||||
|
error = validate_password_strength(password)
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
database.execute(
|
database.execute(
|
||||||
@@ -244,6 +252,8 @@ def change_password():
|
|||||||
error = "Bitte ein neues Passwort eintragen."
|
error = "Bitte ein neues Passwort eintragen."
|
||||||
elif new_password != new_password_repeat:
|
elif new_password != new_password_repeat:
|
||||||
error = "Die neuen Passwörter stimmen nicht überein."
|
error = "Die neuen Passwörter stimmen nicht überein."
|
||||||
|
else:
|
||||||
|
error = validate_password_strength(new_password)
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
get_db().execute(
|
get_db().execute(
|
||||||
@@ -289,6 +299,10 @@ def validate_admin_user_form(
|
|||||||
return "Bitte ein Passwort vergeben."
|
return "Bitte ein Passwort vergeben."
|
||||||
if password and password != password_repeat:
|
if password and password != password_repeat:
|
||||||
return "Die Passwörter stimmen nicht überein."
|
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:
|
if current_user_id == g.user["id"] and not is_active:
|
||||||
return "Du kannst deinen eigenen Zugang hier nicht deaktivieren."
|
return "Du kannst deinen eigenen Zugang hier nicht deaktivieren."
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -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,17 @@ DAYPARTS = [
|
|||||||
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
|
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
DAYPART_SLUG_TO_MEAL_TYPE = {
|
||||||
|
"breakfast": "breakfast",
|
||||||
|
"morning-snack": "snack",
|
||||||
|
"lunch": "lunch",
|
||||||
|
"afternoon-snack": "snack",
|
||||||
|
"dinner": "dinner",
|
||||||
|
"late-snack": "snack",
|
||||||
|
}
|
||||||
|
|
||||||
DEFAULT_CATEGORIES = [
|
DEFAULT_CATEGORIES = [
|
||||||
"Brot & Getreide",
|
"Kohlenhydrate",
|
||||||
"Milchprodukt",
|
"Milchprodukt",
|
||||||
"Obst",
|
"Obst",
|
||||||
"Gemüse",
|
"Gemüse",
|
||||||
@@ -21,6 +30,7 @@ DEFAULT_CATEGORIES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
DEFAULT_CATEGORY_BUILDERS = {
|
DEFAULT_CATEGORY_BUILDERS = {
|
||||||
|
"Kohlenhydrate": "carb",
|
||||||
"Brot & Getreide": "carb",
|
"Brot & Getreide": "carb",
|
||||||
"Milchprodukt": "dairy",
|
"Milchprodukt": "dairy",
|
||||||
"Obst": "fruit",
|
"Obst": "fruit",
|
||||||
@@ -35,9 +45,10 @@ DEFAULT_CATEGORY_BUILDERS = {
|
|||||||
|
|
||||||
BUILDER_LABELS = {
|
BUILDER_LABELS = {
|
||||||
"protein": "Proteinquelle",
|
"protein": "Proteinquelle",
|
||||||
"carb": "Kohlenhydratquelle",
|
"carb": "Kohlenhydrate",
|
||||||
"veg": "Gemüse / Ballaststoffquelle",
|
"veg": "Gemüse / Ballaststoffquelle",
|
||||||
"nuts": "Nüsse / Samen",
|
"nuts": "Nüsse",
|
||||||
|
"seeds": "Saaten",
|
||||||
"fruit": "Obst",
|
"fruit": "Obst",
|
||||||
"dairy": "Milchprodukt",
|
"dairy": "Milchprodukt",
|
||||||
"neutral": "Neutral / sonstiges",
|
"neutral": "Neutral / sonstiges",
|
||||||
@@ -47,7 +58,8 @@ BUILDER_DESCRIPTIONS = {
|
|||||||
"protein": "Passt eher zu sättigenden Eiweißquellen.",
|
"protein": "Passt eher zu sättigenden Eiweißquellen.",
|
||||||
"carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.",
|
"carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.",
|
||||||
"veg": "Passt eher zu Gemüse oder ballaststoffreichen Begleitern.",
|
"veg": "Passt eher zu Gemüse oder ballaststoffreichen Begleitern.",
|
||||||
"nuts": "Passt eher zu Nüssen oder Samen.",
|
"nuts": "Passt eher zu Nüssen.",
|
||||||
|
"seeds": "Passt eher zu Saaten.",
|
||||||
"fruit": "Passt eher zu Obst.",
|
"fruit": "Passt eher zu Obst.",
|
||||||
"dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.",
|
"dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.",
|
||||||
"neutral": "Ohne feste Zuordnung, aber weiterhin gut nutzbar.",
|
"neutral": "Ohne feste Zuordnung, aber weiterhin gut nutzbar.",
|
||||||
@@ -55,6 +67,138 @@ BUILDER_DESCRIPTIONS = {
|
|||||||
|
|
||||||
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
|
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
|
||||||
|
|
||||||
|
FOOD_FLAVOR_OPTIONS = [
|
||||||
|
("neutral", "Neutral"),
|
||||||
|
("sweet", "Süß"),
|
||||||
|
("savory", "Herzhaft"),
|
||||||
|
]
|
||||||
|
|
||||||
|
FOOD_FLAVOR_LABELS = {
|
||||||
|
"neutral": "Neutral",
|
||||||
|
"sweet": "Süß",
|
||||||
|
"savory": "Herzhaft",
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOD_FLAVOR_DESCRIPTIONS = {
|
||||||
|
"neutral": "Passt ruhig in beide Richtungen und bleibt flexibel.",
|
||||||
|
"sweet": "Passt eher zu süßen Kombinationen, Frühstücksideen oder kleinen Snacks.",
|
||||||
|
"savory": "Passt eher zu herzhaften Kombinationen und ruhigeren Hauptmahlzeiten.",
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOD_ROLE_LABELS = {
|
||||||
|
"main": "Hauptbaustein",
|
||||||
|
"base": "Basis",
|
||||||
|
"complement": "Ergänzung",
|
||||||
|
"topping": "Topping",
|
||||||
|
"cooking": "Kochzutat",
|
||||||
|
"snack": "Snack-Baustein",
|
||||||
|
"solo": "Schnelle Einzelmahlzeit",
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOD_ROLE_DESCRIPTIONS = {
|
||||||
|
"main": "Kann einen Teller oder eine Hauptmahlzeit deutlich tragen.",
|
||||||
|
"base": "Passt gut als Grundlage und lässt sich ruhig ergänzen.",
|
||||||
|
"complement": "Hilft beim Ergänzen, steht aber selten für sich allein.",
|
||||||
|
"topping": "Passt eher oben drauf oder als kleines Extra.",
|
||||||
|
"cooking": "Hilft beim Kochen oder Abschmecken, ist aber selten selbst die Mahlzeit.",
|
||||||
|
"snack": "Passt gut für kleine Zwischenmahlzeiten oder als ruhige Ergänzung.",
|
||||||
|
"solo": "Kann auch alleine als schnelle, einfache Mahlzeit funktionieren.",
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOD_ROLE_OPTIONS = [(key, label) for key, label in FOOD_ROLE_LABELS.items()]
|
||||||
|
|
||||||
|
SUGGESTION_PRIORITY_OPTIONS = [
|
||||||
|
("prefer", "Gern vorschlagen"),
|
||||||
|
("normal", "Normal vorschlagen"),
|
||||||
|
("rare", "Eher selten automatisch vorschlagen"),
|
||||||
|
("never", "Nie automatisch vorschlagen"),
|
||||||
|
]
|
||||||
|
|
||||||
|
SUGGESTION_PRIORITY_LABELS = {
|
||||||
|
"prefer": "Gern vorschlagen",
|
||||||
|
"normal": "Normal vorschlagen",
|
||||||
|
"rare": "Eher selten automatisch vorschlagen",
|
||||||
|
"never": "Nie automatisch vorschlagen",
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
("easy", "Leicht und einfach"),
|
||||||
|
("snack", "Snackorientiert"),
|
||||||
|
]
|
||||||
|
|
||||||
|
SUGGESTION_STYLE_LABELS = {
|
||||||
|
"balanced": "Eher ausgewogen",
|
||||||
|
"fitness": "Fitness",
|
||||||
|
"protein": "Proteinbetont",
|
||||||
|
"easy": "Leicht und einfach",
|
||||||
|
"snack": "Snackorientiert",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROTEIN_PREFERENCE_OPTIONS = [
|
||||||
|
("mixed", "Offen gemischt"),
|
||||||
|
("veg-friendly", "Überwiegend vegetarisch"),
|
||||||
|
("rare-animal", "Fleisch und Fisch nur selten"),
|
||||||
|
("plant-forward", "Möglichst pflanzlich"),
|
||||||
|
]
|
||||||
|
|
||||||
|
PROTEIN_PREFERENCE_LABELS = {
|
||||||
|
"mixed": "Offen gemischt",
|
||||||
|
"veg-friendly": "Überwiegend vegetarisch",
|
||||||
|
"rare-animal": "Fleisch und Fisch nur selten",
|
||||||
|
"plant-forward": "Möglichst pflanzlich",
|
||||||
|
}
|
||||||
|
|
||||||
|
MEAL_TYPE_OPTIONS = [
|
||||||
|
("breakfast", "Frühstück"),
|
||||||
|
("lunch", "Mittagessen"),
|
||||||
|
("dinner", "Abendessen"),
|
||||||
|
("snack", "Snack"),
|
||||||
|
]
|
||||||
|
|
||||||
|
MEAL_TYPE_LABELS = {
|
||||||
|
"breakfast": "Frühstück",
|
||||||
|
"lunch": "Mittagessen",
|
||||||
|
"dinner": "Abendessen",
|
||||||
|
"snack": "Snack",
|
||||||
|
}
|
||||||
|
|
||||||
|
MEAL_STYLE_OPTIONS = [
|
||||||
|
("sweet", "Süß"),
|
||||||
|
("savory", "Herzhaft"),
|
||||||
|
("warm", "Warm"),
|
||||||
|
("cold", "Kalt"),
|
||||||
|
("quick", "Schnell"),
|
||||||
|
("simple", "Ruhig und einfach"),
|
||||||
|
("prep", "Gut vorbereitbar"),
|
||||||
|
("portable", "Für unterwegs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
MEAL_STYLE_LABELS = {
|
||||||
|
"sweet": "Süß",
|
||||||
|
"savory": "Herzhaft",
|
||||||
|
"warm": "Warm",
|
||||||
|
"cold": "Kalt",
|
||||||
|
"quick": "Schnell",
|
||||||
|
"simple": "Ruhig und einfach",
|
||||||
|
"prep": "Gut vorbereitbar",
|
||||||
|
"portable": "Für unterwegs",
|
||||||
|
}
|
||||||
|
|
||||||
WEEKDAY_OPTIONS = [
|
WEEKDAY_OPTIONS = [
|
||||||
(0, "Montag"),
|
(0, "Montag"),
|
||||||
(1, "Dienstag"),
|
(1, "Dienstag"),
|
||||||
|
|||||||
@@ -8,7 +8,340 @@ from flask import Flask, current_app, g
|
|||||||
from flask.cli import with_appcontext
|
from flask.cli import with_appcontext
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
|
from .constants import (
|
||||||
|
DAYPARTS,
|
||||||
|
DAYPART_SLUG_TO_MEAL_TYPE,
|
||||||
|
DEFAULT_CATEGORIES,
|
||||||
|
DEFAULT_CATEGORY_BUILDERS,
|
||||||
|
)
|
||||||
|
|
||||||
|
CURRENT_SCHEMA_VERSION = "1.2.2"
|
||||||
|
|
||||||
|
ANIMAL_HINTS = (
|
||||||
|
"huhn",
|
||||||
|
"hähn",
|
||||||
|
"rind",
|
||||||
|
"hack",
|
||||||
|
"schwein",
|
||||||
|
"speck",
|
||||||
|
"salami",
|
||||||
|
"wurst",
|
||||||
|
"thunfisch",
|
||||||
|
"lachs",
|
||||||
|
"fisch",
|
||||||
|
"garnelen",
|
||||||
|
"shrimp",
|
||||||
|
"sardinen",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name_for_profile(name: str | None) -> str:
|
||||||
|
return (name or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def infer_food_flavor_profile(
|
||||||
|
name: str | None,
|
||||||
|
category: str | None,
|
||||||
|
base_type: str | None = None,
|
||||||
|
suggestion_role: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
normalized_name = normalize_name_for_profile(name)
|
||||||
|
normalized_category = (category or "").strip().lower()
|
||||||
|
normalized_base_type = (base_type or "").strip().lower()
|
||||||
|
normalized_role = (suggestion_role or "").strip().lower()
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("proteinpulver", "eiweißpulver", "whey", "clear whey")):
|
||||||
|
return "neutral"
|
||||||
|
if any(token in normalized_name for token in ("schoko", "choco", "müsli", "granola", "cornflakes", "fruchtjoghurt", "vanillejoghurt", "pudding")):
|
||||||
|
return "sweet"
|
||||||
|
if any(token in normalized_name for token in ("banane", "apfel", "birne", "beeren", "himbeer", "erdbeer", "heidelbeer", "mango", "kiwi", "trauben")):
|
||||||
|
return "sweet"
|
||||||
|
if any(token in normalized_name for token in ("räucher", "tofu", "tempeh", "hack", "salami", "wurst", "thunfisch", "lachs", "fisch", "huhn", "hähn", "rind", "schwein", "aufstrich", "pesto", "humus", "hummus", "reisgericht", "chili", "curry")):
|
||||||
|
return "savory"
|
||||||
|
if any(token in normalized_name for token in ("naturjoghurt", "joghurt natur", "quark", "skyr", "haferflocken", "gurke", "karotte", "karotten", "kartoffel", "kartoffeln", "reis", "nudeln", "brot", "brötchen")):
|
||||||
|
return "neutral"
|
||||||
|
|
||||||
|
if "obst" in normalized_category or normalized_base_type == "fruit":
|
||||||
|
return "sweet"
|
||||||
|
if any(token in normalized_category for token in ("eiweiß", "protein")) or normalized_base_type == "protein":
|
||||||
|
return "savory"
|
||||||
|
if any(token in normalized_category for token in ("gemüse",)) or normalized_base_type in {"veg", "carb", "dairy", "nuts", "seeds"}:
|
||||||
|
return "neutral"
|
||||||
|
if normalized_role in {"topping", "cooking"}:
|
||||||
|
return "neutral"
|
||||||
|
return "neutral"
|
||||||
|
|
||||||
|
|
||||||
|
def infer_food_profile(name: str | None, category: str | None, energy_density: str | None) -> dict[str, object]:
|
||||||
|
normalized_name = normalize_name_for_profile(name)
|
||||||
|
normalized_category = (category or "").strip().lower()
|
||||||
|
|
||||||
|
base_type = "neutral"
|
||||||
|
if "eiweiß" in normalized_category or "protein" in normalized_category:
|
||||||
|
base_type = "protein"
|
||||||
|
elif "kohlenhyd" in normalized_category or "brot" in normalized_category or "getreide" in normalized_category:
|
||||||
|
base_type = "carb"
|
||||||
|
elif "milch" in normalized_category:
|
||||||
|
base_type = "dairy"
|
||||||
|
elif "obst" in normalized_category:
|
||||||
|
base_type = "fruit"
|
||||||
|
elif "gemüse" in normalized_category:
|
||||||
|
base_type = "veg"
|
||||||
|
elif "nüsse" in normalized_name or "nuss" in normalized_name:
|
||||||
|
base_type = "nuts"
|
||||||
|
elif "saat" in normalized_name or "leinsamen" in normalized_name or "chia" in normalized_name:
|
||||||
|
base_type = "seeds"
|
||||||
|
|
||||||
|
suggestion_role = "base"
|
||||||
|
suggestion_priority = "normal"
|
||||||
|
can_be_meal_core = 0
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("proteinpulver", "eiweißpulver", "whey", "clear whey")):
|
||||||
|
return {
|
||||||
|
"base_type": "protein",
|
||||||
|
"suggestion_role": "complement",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("flohsamen", "flohsamenschalen", "leinsamen", "chia", "hanfsamen")):
|
||||||
|
return {
|
||||||
|
"base_type": "seeds",
|
||||||
|
"suggestion_role": "topping",
|
||||||
|
"suggestion_priority": "normal",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if "tomatenmark" in normalized_name:
|
||||||
|
return {
|
||||||
|
"base_type": "neutral",
|
||||||
|
"suggestion_role": "cooking",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("saure gurken", "essiggurken", "cornichons", "gurkenscheiben")):
|
||||||
|
return {
|
||||||
|
"base_type": "veg",
|
||||||
|
"suggestion_role": "complement",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("tofu", "tempeh", "vegane schnitzel", "vegane nuggets", "veganes hack", "sojageschnetzeltes")):
|
||||||
|
return {
|
||||||
|
"base_type": "protein",
|
||||||
|
"suggestion_role": "main",
|
||||||
|
"suggestion_priority": "prefer",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("thunfisch", "lachs", "fisch", "huhn", "hähn", "rind", "schwein", "hack")):
|
||||||
|
return {
|
||||||
|
"base_type": "protein",
|
||||||
|
"suggestion_role": "main",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("joghurt", "skyr", "quark", "hüttenkäse", "körniger frischkäse")):
|
||||||
|
return {
|
||||||
|
"base_type": "dairy",
|
||||||
|
"suggestion_role": "base",
|
||||||
|
"suggestion_priority": "prefer",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("müsli", "hafer", "porridge", "cornflakes", "brot", "brötchen", "reis", "nudel", "kartoffel", "wrap")):
|
||||||
|
return {
|
||||||
|
"base_type": "carb",
|
||||||
|
"suggestion_role": "base",
|
||||||
|
"suggestion_priority": "normal",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("beeren", "banane", "apfel", "obst", "birne", "trauben", "kiwi")):
|
||||||
|
return {
|
||||||
|
"base_type": "fruit",
|
||||||
|
"suggestion_role": "complement",
|
||||||
|
"suggestion_priority": "prefer",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("gemüse", "brokkoli", "spinat", "erbsen", "paprika", "karotte", "zucchini", "salat", "tomate", "tk gemüse")):
|
||||||
|
return {
|
||||||
|
"base_type": "veg",
|
||||||
|
"suggestion_role": "complement",
|
||||||
|
"suggestion_priority": "prefer",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("nussmus", "erdnuss", "mandeln", "walnüsse", "cashew")):
|
||||||
|
return {
|
||||||
|
"base_type": "nuts",
|
||||||
|
"suggestion_role": "topping",
|
||||||
|
"suggestion_priority": "normal",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("terrine", "5-minuten", "instant", "cup noodles")):
|
||||||
|
return {
|
||||||
|
"base_type": "carb" if (energy_density or "neutral") != "high" else "neutral",
|
||||||
|
"suggestion_role": "solo",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if base_type in {"protein", "carb", "dairy"}:
|
||||||
|
suggestion_role = "base"
|
||||||
|
can_be_meal_core = 1
|
||||||
|
elif base_type in {"veg", "fruit"}:
|
||||||
|
suggestion_role = "complement"
|
||||||
|
elif base_type in {"nuts", "seeds"}:
|
||||||
|
suggestion_role = "topping"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"base_type": base_type,
|
||||||
|
"suggestion_role": suggestion_role,
|
||||||
|
"suggestion_priority": suggestion_priority,
|
||||||
|
"can_be_meal_core": can_be_meal_core,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def infer_meal_tags(name: str | None, legacy_category: str | None) -> str:
|
||||||
|
normalized_name = normalize_name_for_profile(name)
|
||||||
|
normalized_category = (legacy_category or "").strip().lower()
|
||||||
|
tags: list[str] = []
|
||||||
|
|
||||||
|
if normalized_category == "warmes":
|
||||||
|
tags.extend(["warm", "savory"])
|
||||||
|
if normalized_category == "kleines essen":
|
||||||
|
tags.extend(["simple", "quick"])
|
||||||
|
if normalized_category == "snack":
|
||||||
|
tags.append("simple")
|
||||||
|
if any(token in normalized_name for token in ("porridge", "müsli", "joghurt", "quark")):
|
||||||
|
tags.append("sweet")
|
||||||
|
if any(token in normalized_name for token in ("salat", "brot", "toast", "tofu", "reis", "nudel", "pfanne")):
|
||||||
|
tags.append("savory")
|
||||||
|
if any(token in normalized_name for token in ("to go", "unterwegs", "wrap")):
|
||||||
|
tags.append("portable")
|
||||||
|
if any(token in normalized_name for token in ("overnight", "vorbereitet", "meal prep")):
|
||||||
|
tags.append("prep")
|
||||||
|
if any(token in normalized_name for token in ("schnell", "5-minuten", "instant")):
|
||||||
|
tags.append("quick")
|
||||||
|
if any(token in normalized_name for token in ("einfach", "ruhig")):
|
||||||
|
tags.append("simple")
|
||||||
|
|
||||||
|
unique_tags: list[str] = []
|
||||||
|
for tag in tags:
|
||||||
|
if tag and tag not in unique_tags:
|
||||||
|
unique_tags.append(tag)
|
||||||
|
return ",".join(unique_tags)
|
||||||
|
|
||||||
|
|
||||||
|
def infer_meal_type_from_dayparts(database: sqlite3.Connection, item_id: int) -> str:
|
||||||
|
row = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT dayparts.slug
|
||||||
|
FROM item_dayparts
|
||||||
|
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
|
||||||
|
WHERE item_dayparts.item_id = ?
|
||||||
|
ORDER BY dayparts.sort_order
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(item_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return "snack"
|
||||||
|
return DAYPART_SLUG_TO_MEAL_TYPE.get(row["slug"], "snack")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_item_profiles(database: sqlite3.Connection) -> None:
|
||||||
|
rows = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, kind, name, category, energy_density
|
||||||
|
FROM items
|
||||||
|
ORDER BY id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
item_id = int(row["id"])
|
||||||
|
if row["kind"] == "food":
|
||||||
|
profile = infer_food_profile(row["name"], row["category"], row["energy_density"])
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE items
|
||||||
|
SET base_type = ?,
|
||||||
|
suggestion_role = ?,
|
||||||
|
suggestion_priority = ?,
|
||||||
|
can_be_meal_core = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
profile["base_type"],
|
||||||
|
profile["suggestion_role"],
|
||||||
|
profile["suggestion_priority"],
|
||||||
|
profile["can_be_meal_core"],
|
||||||
|
item_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
meal_type = infer_meal_type_from_dayparts(database, item_id)
|
||||||
|
meal_tags = infer_meal_tags(row["name"], row["category"])
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE items
|
||||||
|
SET meal_type = COALESCE(NULLIF(meal_type, ''), ?),
|
||||||
|
meal_tags = CASE
|
||||||
|
WHEN meal_tags IS NULL OR meal_tags = '' THEN ?
|
||||||
|
ELSE meal_tags
|
||||||
|
END,
|
||||||
|
category = CASE
|
||||||
|
WHEN kind = 'meal' AND category IN ('Kohlenhydrate', 'Milchprodukt', 'Obst', 'Gemüse', 'Eiweißquelle', 'Snack', 'Warmes', 'Kleines Essen')
|
||||||
|
THEN NULL
|
||||||
|
ELSE category
|
||||||
|
END
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(meal_type, meal_tags, item_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_food_flavor_profiles(database: sqlite3.Connection) -> None:
|
||||||
|
if get_meta(database, "food_flavor_profiles_migrated") == "1":
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, category, base_type, suggestion_role, flavor_profile
|
||||||
|
FROM items
|
||||||
|
WHERE kind = 'food'
|
||||||
|
ORDER BY id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
current_flavor = (row["flavor_profile"] or "").strip().lower()
|
||||||
|
if current_flavor in {"sweet", "savory"}:
|
||||||
|
continue
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE items
|
||||||
|
SET flavor_profile = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
infer_food_flavor_profile(
|
||||||
|
row["name"],
|
||||||
|
row["category"],
|
||||||
|
row["base_type"],
|
||||||
|
row["suggestion_role"],
|
||||||
|
),
|
||||||
|
int(row["id"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
set_meta(database, "food_flavor_profiles_migrated", "1")
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> sqlite3.Connection:
|
def get_db() -> sqlite3.Connection:
|
||||||
@@ -16,9 +349,11 @@ def get_db() -> sqlite3.Connection:
|
|||||||
g.db = sqlite3.connect(
|
g.db = sqlite3.connect(
|
||||||
current_app.config["DATABASE_PATH"],
|
current_app.config["DATABASE_PATH"],
|
||||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
detect_types=sqlite3.PARSE_DECLTYPES,
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
g.db.row_factory = sqlite3.Row
|
g.db.row_factory = sqlite3.Row
|
||||||
g.db.execute("PRAGMA foreign_keys = ON")
|
g.db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
g.db.execute("PRAGMA busy_timeout = 30000")
|
||||||
return g.db
|
return g.db
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +382,36 @@ def add_column_if_missing(database: sqlite3.Connection, table_name: str, definit
|
|||||||
database.execute(f"ALTER TABLE {table_name} ADD COLUMN {definition}")
|
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:
|
def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||||
|
ensure_meta_table(database)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS households (
|
CREATE TABLE IF NOT EXISTS households (
|
||||||
@@ -96,6 +460,14 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
|||||||
add_column_if_missing(database, "items", "owner_user_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", "target_user_id INTEGER")
|
||||||
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||||
|
add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "flavor_profile TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'")
|
||||||
|
add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'")
|
||||||
|
add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0")
|
||||||
|
add_column_if_missing(database, "items", "meal_type TEXT")
|
||||||
|
add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''")
|
||||||
|
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
|
||||||
if table_exists(database, "shopping_entries"):
|
if table_exists(database, "shopping_entries"):
|
||||||
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
|
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
|
||||||
@@ -117,12 +489,18 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
|||||||
reminders_enabled INTEGER NOT NULL DEFAULT 1,
|
reminders_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
push_enabled INTEGER NOT NULL DEFAULT 0,
|
push_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
notification_channel TEXT NOT NULL DEFAULT 'in_app',
|
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_before_shopping INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_on_shopping_day 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_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
|
||||||
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
|
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
|
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_week_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,
|
||||||
|
push_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||||
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||||
remind_nuts INTEGER NOT NULL DEFAULT 0,
|
remind_nuts INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -153,6 +531,32 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS shopping_needs (
|
CREATE TABLE IF NOT EXISTS shopping_needs (
|
||||||
@@ -185,6 +589,15 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
|||||||
add_column_if_missing(database, "plan_entries", "owner_user_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'")
|
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", "protein_preference TEXT NOT NULL DEFAULT 'mixed'")
|
||||||
|
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")
|
||||||
|
add_column_if_missing(database, "user_settings", "push_small_snack INTEGER NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
|
|
||||||
def ensure_default_household(database: sqlite3.Connection) -> int:
|
def ensure_default_household(database: sqlite3.Connection) -> int:
|
||||||
household = database.execute(
|
household = database.execute(
|
||||||
@@ -211,6 +624,41 @@ def first_user_id(database: sqlite3.Connection) -> int | None:
|
|||||||
|
|
||||||
def sync_default_categories(database: sqlite3.Connection) -> None:
|
def sync_default_categories(database: sqlite3.Connection) -> None:
|
||||||
for household_id in household_ids(database):
|
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):
|
for sort_order, name in enumerate(DEFAULT_CATEGORIES, start=10):
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
@@ -230,6 +678,7 @@ def sync_default_categories(database: sqlite3.Connection) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
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", "household_id INTEGER")
|
||||||
add_column_if_missing(database, "users", "email TEXT")
|
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", "role TEXT NOT NULL DEFAULT 'member'")
|
||||||
@@ -237,10 +686,10 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
add_column_if_missing(database, "users", "updated_at TEXT")
|
add_column_if_missing(database, "users", "updated_at TEXT")
|
||||||
|
|
||||||
default_household_id = ensure_default_household(database)
|
default_household_id = ensure_default_household(database)
|
||||||
database.execute("UPDATE households SET shopping_weekday = COALESCE(shopping_weekday, 5)")
|
database.execute("UPDATE households SET shopping_weekday = 5 WHERE shopping_weekday IS NULL")
|
||||||
database.execute("UPDATE households SET shopping_prep_days = COALESCE(shopping_prep_days, 1)")
|
database.execute("UPDATE households SET shopping_prep_days = 1 WHERE shopping_prep_days IS NULL")
|
||||||
database.execute(
|
database.execute(
|
||||||
"UPDATE households SET shopping_reminder_time = COALESCE(NULLIF(shopping_reminder_time, ''), '18:00')"
|
"UPDATE households SET shopping_reminder_time = '18:00' WHERE shopping_reminder_time IS NULL OR shopping_reminder_time = ''"
|
||||||
)
|
)
|
||||||
database.execute(
|
database.execute(
|
||||||
"UPDATE users SET household_id = ? WHERE household_id IS NULL",
|
"UPDATE users SET household_id = ? WHERE household_id IS NULL",
|
||||||
@@ -265,8 +714,23 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
add_column_if_missing(database, table_name, "owner_user_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, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||||
add_column_if_missing(database, "items", "target_user_id INTEGER")
|
add_column_if_missing(database, "items", "target_user_id INTEGER")
|
||||||
|
add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "flavor_profile TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'")
|
||||||
|
add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'")
|
||||||
|
add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0")
|
||||||
|
add_column_if_missing(database, "items", "meal_type TEXT")
|
||||||
|
add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''")
|
||||||
|
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_date TEXT")
|
||||||
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
|
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", "protein_preference TEXT NOT NULL DEFAULT 'mixed'")
|
||||||
|
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")
|
||||||
|
add_column_if_missing(database, "user_settings", "push_small_snack INTEGER NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
if default_owner_id is not None:
|
if default_owner_id is not None:
|
||||||
database.execute(
|
database.execute(
|
||||||
@@ -305,12 +769,28 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
database.execute("UPDATE plan_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)
|
sync_default_categories(database)
|
||||||
|
migrate_item_profiles(database)
|
||||||
|
migrate_food_flavor_profiles(database)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO user_settings (user_id)
|
INSERT OR IGNORE INTO user_settings (user_id)
|
||||||
SELECT id FROM users
|
SELECT id FROM users
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
database.execute("UPDATE items SET energy_density = 'neutral' WHERE energy_density IS NULL OR energy_density = ''")
|
||||||
|
database.execute("UPDATE items SET base_type = 'neutral' WHERE base_type IS NULL OR base_type = ''")
|
||||||
|
database.execute("UPDATE items SET flavor_profile = 'neutral' WHERE flavor_profile IS NULL OR flavor_profile = ''")
|
||||||
|
database.execute("UPDATE items SET suggestion_role = 'base' WHERE suggestion_role IS NULL OR suggestion_role = ''")
|
||||||
|
database.execute("UPDATE items SET suggestion_priority = 'normal' WHERE suggestion_priority IS NULL OR suggestion_priority = ''")
|
||||||
|
database.execute("UPDATE items SET can_be_meal_core = 0 WHERE can_be_meal_core IS NULL")
|
||||||
|
database.execute("UPDATE items SET meal_tags = '' WHERE meal_tags IS NULL")
|
||||||
|
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 protein_preference = 'mixed' WHERE protein_preference IS NULL OR protein_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("UPDATE user_settings SET push_small_snack = 0 WHERE push_small_snack IS NULL")
|
||||||
|
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
@@ -349,6 +829,13 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
ON shopping_needs (household_id, activation_date, is_activated)
|
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:
|
def apply_schema(database: sqlite3.Connection) -> None:
|
||||||
|
|||||||
@@ -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,293 @@
|
|||||||
|
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"},
|
||||||
|
]
|
||||||
|
|
||||||
|
SNACK_PUSH_RULE = {
|
||||||
|
"slugs": ("morning-snack", "afternoon-snack", "late-snack"),
|
||||||
|
"setting": "push_small_snack",
|
||||||
|
"hour": 15,
|
||||||
|
"minute": 0,
|
||||||
|
"end_hour": 20,
|
||||||
|
"label": "Etwas Kleines",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 plan_exists_for_any_daypart(user, *, planned_date: date, daypart_ids: list[int]) -> bool:
|
||||||
|
if not daypart_ids:
|
||||||
|
return False
|
||||||
|
placeholders = ", ".join("?" for _ in daypart_ids)
|
||||||
|
row = get_db().execute(
|
||||||
|
f"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM plan_entries
|
||||||
|
WHERE household_id = ?
|
||||||
|
AND plan_date = ?
|
||||||
|
AND daypart_id IN ({placeholders})
|
||||||
|
AND (visibility = 'shared' OR owner_user_id = ?)
|
||||||
|
""",
|
||||||
|
[int(user["household_id"]), planned_date.isoformat(), *daypart_ids, 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 build_small_snack_push_message(suggestion: dict | None) -> tuple[str, str]:
|
||||||
|
title = "Nouri · Etwas Kleines"
|
||||||
|
if suggestion and suggestion.get("title"):
|
||||||
|
return title, f"Für später wäre etwas Kleines möglich. Zuhause passt gerade: {suggestion['title']}."
|
||||||
|
return title, "Für später wäre etwas Kleines möglich. Vielleicht passt heute etwas Einfaches wie Nüsse oder ein Apfel."
|
||||||
|
|
||||||
|
|
||||||
|
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 best_small_snack_suggestion_for_user(user, daypart_ids: list[int]) -> tuple[int | None, dict | None]:
|
||||||
|
previous_user = getattr(g, "user", None)
|
||||||
|
g.user = user
|
||||||
|
try:
|
||||||
|
for daypart_id in daypart_ids:
|
||||||
|
suggestions = build_home_recipe_suggestions(daypart_id, limit=1)
|
||||||
|
if suggestions:
|
||||||
|
return daypart_id, suggestions[0]
|
||||||
|
finally:
|
||||||
|
g.user = previous_user
|
||||||
|
return (daypart_ids[0] if daypart_ids else None), 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
|
||||||
|
|
||||||
|
snack_rule = SNACK_PUSH_RULE
|
||||||
|
if settings.get(snack_rule["setting"]) and due_for_rule(
|
||||||
|
now,
|
||||||
|
hour=snack_rule["hour"],
|
||||||
|
minute=snack_rule["minute"],
|
||||||
|
end_hour=snack_rule["end_hour"],
|
||||||
|
):
|
||||||
|
snack_daypart_ids = [
|
||||||
|
int(dayparts[slug]["id"])
|
||||||
|
for slug in snack_rule["slugs"]
|
||||||
|
if slug in dayparts
|
||||||
|
]
|
||||||
|
if snack_daypart_ids and not plan_exists_for_any_daypart(
|
||||||
|
user,
|
||||||
|
planned_date=planned_date,
|
||||||
|
daypart_ids=snack_daypart_ids,
|
||||||
|
):
|
||||||
|
event_key = f"meal-push:{planned_date.isoformat()}:small-snack"
|
||||||
|
if not reminder_event_exists(int(user["id"]), event_key):
|
||||||
|
daypart_id, suggestion = best_small_snack_suggestion_for_user(user, snack_daypart_ids)
|
||||||
|
title, body = build_small_snack_push_message(suggestion)
|
||||||
|
url = build_push_target_url(
|
||||||
|
planned_date=planned_date,
|
||||||
|
daypart_id=daypart_id or snack_daypart_ids[0],
|
||||||
|
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,5 +1,11 @@
|
|||||||
PRAGMA foreign_keys = ON;
|
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 (
|
CREATE TABLE IF NOT EXISTS households (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -44,12 +50,19 @@ CREATE TABLE IF NOT EXISTS user_settings (
|
|||||||
reminders_enabled INTEGER NOT NULL DEFAULT 1,
|
reminders_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
push_enabled INTEGER NOT NULL DEFAULT 0,
|
push_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
notification_channel TEXT NOT NULL DEFAULT 'in_app',
|
notification_channel TEXT NOT NULL DEFAULT 'in_app',
|
||||||
|
suggestion_style TEXT NOT NULL DEFAULT 'balanced',
|
||||||
|
energy_preference TEXT NOT NULL DEFAULT 'neutral',
|
||||||
|
protein_preference TEXT NOT NULL DEFAULT 'mixed',
|
||||||
remind_before_shopping INTEGER NOT NULL DEFAULT 1,
|
remind_before_shopping INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_on_shopping_day 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_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
|
||||||
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
|
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
|
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_week_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,
|
||||||
|
push_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||||
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||||
remind_nuts INTEGER NOT NULL DEFAULT 0,
|
remind_nuts INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -74,6 +87,24 @@ CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
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 (
|
CREATE TABLE IF NOT EXISTS dayparts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
@@ -90,6 +121,14 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
|
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
category TEXT,
|
category TEXT,
|
||||||
|
base_type TEXT NOT NULL DEFAULT 'neutral',
|
||||||
|
flavor_profile TEXT NOT NULL DEFAULT 'neutral',
|
||||||
|
suggestion_role TEXT NOT NULL DEFAULT 'base',
|
||||||
|
suggestion_priority TEXT NOT NULL DEFAULT 'normal',
|
||||||
|
can_be_meal_core INTEGER NOT NULL DEFAULT 0,
|
||||||
|
meal_type TEXT,
|
||||||
|
meal_tags TEXT NOT NULL DEFAULT '',
|
||||||
|
energy_density TEXT NOT NULL DEFAULT 'neutral',
|
||||||
note TEXT,
|
note TEXT,
|
||||||
photo_filename TEXT,
|
photo_filename TEXT,
|
||||||
availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')),
|
availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')),
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<rect x="3" y="3" width="58" height="58" rx="18" fill="#E3A06B"/>
|
||||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
<path d="M16 36H48C46.9 44.2 40.0727 50 31.8 50H32.2C23.9273 50 17.1 44.2 16 36Z" fill="#FFF5EC"/>
|
||||||
<stop offset="0%" stop-color="#ffd7be"/>
|
<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"/>
|
||||||
<stop offset="100%" stop-color="#e39a63"/>
|
<rect x="24" y="39" width="16" height="6" rx="3" fill="#8C533B"/>
|
||||||
</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>
|
</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">
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<title>Nouri</title>
|
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="nouriBg" x1="88" y1="72" x2="420" y2="440" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0%" stop-color="#ffd7be"/>
|
<stop stop-color="#F6C394"/>
|
||||||
<stop offset="55%" stop-color="#f5b17a"/>
|
<stop offset="1" stop-color="#DE9862"/>
|
||||||
<stop offset="100%" stop-color="#d58c57"/>
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%">
|
<linearGradient id="nouriBowl" x1="161" y1="176" x2="349" y2="339" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0%" stop-color="#b5dfc8"/>
|
<stop stop-color="#FFF8F0"/>
|
||||||
<stop offset="100%" stop-color="#70aa87"/>
|
<stop offset="1" stop-color="#FDE7D5"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="256" height="256" rx="64" fill="url(#bg)"/>
|
<rect x="24" y="24" width="464" height="464" rx="122" fill="url(#nouriBg)"/>
|
||||||
<circle cx="128" cy="128" r="96" fill="rgba(255,255,255,0.16)"/>
|
<rect x="48" y="48" width="416" height="416" rx="104" fill="white" fill-opacity="0.12"/>
|
||||||
<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="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="M84 105c7-21.3 24-34 44-34s37 12.7 44 34" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="14"/>
|
<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="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="M164 287H348C344.6 332.419 307.445 368 261.2 368H250.8C204.555 368 167.4 332.419 164 287Z" fill="#F7B37D"/>
|
||||||
<path d="M129 143h41" stroke="#f0a46c" stroke-linecap="round" stroke-width="10"/>
|
<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="M92 96l-10 62" stroke="#fff9f4" stroke-linecap="round" stroke-width="10"/>
|
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 4.5 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 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="M362.2 37L213.9 16 81.7 86.7 16 222.1 42 370.4 149.8 475 298.1 496 430.3 425.3 496 289.9 470 141.6 362.2 37zM208 144a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM144 336a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm224-64a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>
|
||||||
|
After Width: | Height: | Size: 505 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="M243.2 .3c4.2-.2 8.5-.3 12.8-.3 62.1 0 118.9 22.1 163.3 58.8L314.6 163.4 243.2 .3zM194 7.6L307.4 266.6 267.3 306.8 12 178.3C38.8 94.2 107.7 29 194 7.6zM1.6 226.8l166 83.6-108.9 108.9C22.1 374.9 0 318.1 0 256 0 246.1 .6 236.4 1.6 226.8zM92.7 453.2l120.1-120.1 11.2 5.6 0 171.3c-49.5-6.2-94.7-26.5-131.3-56.8zM341.2 224l-5.9-13.4 117.9-117.9c30.3 36.6 50.6 81.7 56.8 131.3l-168.8 0z"/></svg>
|
||||||
|
After Width: | Height: | Size: 648 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 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="M56 16L40 16 0 152c0 41.5 31.6 75.6 72 79.6l0 264.4 48 0 0-264.4c40.4-4 72-38.1 72-79.6l-40-136-16 0 0 136-16 0-16-136-16 0-16 136-16 0 0-136zm584 0S512 32 512 160l0 160 80 0 0 176 48 0 0-480zM336 32c-43.8 0-84.7 12.6-119.2 34.3l19.1 64.9c27.4-22 62.2-35.2 100.1-35.2 52.3 0 98.8 25.1 128 64 0-29.7 5.5-55.2 14.5-76.9-38.7-31.9-88.3-51.1-142.5-51.1zm0 384c-86.1 0-156.3-68-159.9-153.2-2.7 1.5-5.4 3-8.1 4.3l0 137c41 46.5 101.1 75.8 168 75.8 82.9 0 155.3-45 194-112l-66 0 0-16c-29.2 38.9-75.7 64-128 64zM448 256a112 112 0 1 0 -224 0 112 112 0 1 0 224 0z"/></svg>
|
||||||
|
After Width: | Height: | Size: 820 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="M359.7 21.9C272.6 43.5 208 122.2 208 216 208 326.4 297.5 416 408 416 426.5 416 444.4 413.5 461.4 408.8 414.8 471.4 340.1 512 256 512 114.6 512 0 397.4 0 256S114.6 0 256 0c36.9 0 72 7.8 103.7 21.9z"/></svg>
|
||||||
|
After Width: | Height: | Size: 464 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="M0 64l512 0 0 112-512 0 0-112zM320 384l96-48 96 0 0 112-512 0 0-112 224 0 96 48zM144.2 209.1l111.8 29.8 111.8-29.8 8-2.1 8 2c74.8 18.7 117.2 29.3 127 31.8l-15.5 62.1c-11.2-2.8-50.9-12.7-119-29.8l-112 29.9-8.2 2.2-8.2-2.2-112-29.9c-68.1 17-107.8 27-119 29.8L1.2 240.7c9.9-2.5 52.2-13.1 127-31.8l8-2 8 2.1z"/></svg>
|
||||||
|
After Width: | Height: | Size: 572 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="M208 96l0-16c0-44.2 35.8-80 80-80l32 0 0 32c0 44.2-35.8 80-80 80l-32 0 0-16zM0 288c0-76.3 35.7-160 112-160l112 32 112-32c76.3 0 112 83.7 112 160 0 128-80 224-160 224l-64-16-64 16C80 512 0 416 0 288z"/></svg>
|
||||||
|
After Width: | Height: | Size: 466 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><path fill="currentColor" d="M99.1 99.8c36.8-31.2 88.7-50.7 158.9-51.8 70.8 1.3 118.7 27.7 149.8 67.9 32.6 42.2 48.7 102.3 48.7 172s-16.1 129.8-48.7 172c-31 40.2-79 66.6-149.8 67.9-70.2-1.1-122.1-20.6-158.9-51.8 14.2 2.5 29.3 3.8 45.4 3.8 4.1 0 8.1-.1 12-.2l0 .2c80.4 0 138.3-19.7 176.2-55.9 38-36.3 51.8-85.6 51.8-136.1s-13.8-99.8-51.8-136.1C294.8 115.7 236.9 96 156.5 96l0 .2c-3.9-.2-7.9-.2-12-.2-16.1 0-31.2 1.3-45.4 3.8zM252.5 .2C125.1 3.6 42.8 62.2 3.5 150.3l38.2 27.5c21.9-20.1 54.9-33.8 102.8-33.8 53.4 0 88.4 16.9 110.2 41.2 22.3 24.8 33.8 60.5 33.8 102.8S277 366 254.7 390.8c-21.9 24.4-56.8 41.2-110.2 41.2-47.9 0-80.9-13.6-102.8-33.8L3.5 425.7c39.3 88.2 121.6 146.7 249 150.1l0 .2c1.9 0 3.8 0 5.6 0 2.1 0 4.2 0 6.4 0 97.8 0 170.8-31.5 219.2-85.3 47.9-53.4 68.8-125.7 68.8-202.7S531.6 138.7 483.6 85.3c-48.3-53.8-121.4-85.3-219.2-85.3-2.1 0-4.3 0-6.4 0-1.9 0-3.7 0-5.6 0l0 .2zM216.5 252c0-26.5-14.4-48-48-48s-48 21.5-48 48 14.4 48 48 48 48-21.5 48-48zm-144 96c25.2 0 36-16.1 36-36s-10.8-36-36-36-36 16.1-36 36 10.8 36 36 36z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><path fill="currentColor" d="M309.6 0L266.4 0 222 47.9c-13.9 15-33.5 23.3-53.9 23L89.2 63 63 89.4 70.9 164.2c.3 19.5-7.3 38.3-21.1 52.1L0 266.1 0 309.6 47.9 354c15 13.9 23.3 33.5 23 53.9l-7.9 78.8 28 26 73.3-12.8c23.9-2.2 47.3 7.7 62.4 26.4l40.2 49.6 42.3 0 40.2-49.6c15.1-18.7 38.5-28.5 62.4-26.4l73.3 12.8 28-26-7.9-78.8c-.3-20.4 8-40 23-53.9l47.9-44.4 0-43.5-49.8-49.8c-13.8-13.8-21.4-32.6-21.1-52.1l7.9-74.8-26.3-26.4-78.8 7.9c-20.4 .3-40-8-53.9-23L309.6 0zM288 64.4l4.8 9.4c26.8 52.1 87.4 77.2 143.2 59.3l10-3.2-3.2 10c-17.9 55.8 7.2 116.4 59.3 143.2l9.4 4.8-9.4 4.8c-52.1 26.8-77.2 87.4-59.3 143.2l3.2 10-10-3.2c-55.8-17.9-116.4 7.2-143.2 59.3l-4.8 9.4-4.8-9.4c-26.8-52.1-87.4-77.2-143.2-59.3l-10 3.2 3.2-10c17.9-55.8-7.2-116.4-59.3-143.2l-9.4-4.8 9.4-4.8c52.1-26.8 77.2-87.4 59.3-143.2l-3.2-10 10 3.2c55.8 17.9 116.4-7.2 143.2-59.3l4.8-9.4zM322.3 224c8.6 14.4 13.7 36.5 13.7 64s-5.1 49.6-13.7 64c-7.8 13.1-18.4 20-34.3 20s-26.5-6.9-34.3-20c-8.6-14.4-13.7-36.5-13.7-64s5.1-49.6 13.7-64c7.8-13.1 18.4-20 34.3-20s26.5 6.9 34.3 20zM288 156c-43.2 0-77.2 14-100.2 39.6-22.6 25.1-31.8 58.5-31.8 92.4s9.2 67.3 31.8 92.4c23 25.6 57 39.6 100.2 39.6s77.2-14 100.2-39.6C410.8 355.3 420 321.9 420 288s-9.2-67.3-31.8-92.4C365.2 170 331.2 156 288 156z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M312.2 49.4L312.2 0L264.2 0L264.2 49.4L218.2 16.5L186.2 51.8L264.3 141L264.3 246.4L173 193.7L134.8 81.5L88.2 91.6L93.7 147.9L50.9 123.2L26.9 164.8L69.6 189.5L18.1 212.9L32.7 258.3L149 235.3L240.3 288L149 340.7L32.7 317.7L18.1 363.1L69.6 386.5L26.8 411.2L50.8 452.8L93.6 428.1L88.1 484.4L134.7 494.5L172.9 382.3L264.2 329.6L264.2 435L186.1 524.2L218.1 559.5L264.1 526.6L264.1 576L312.1 576L312.1 526.6L358.2 559.5L390.2 524.2L312.1 435L312.1 329.6L403.4 382.3L441.6 494.5L488.2 484.4L482.7 428.1L525.5 452.8L549.5 411.2L506.7 386.5L558.2 363.1L543.6 317.7L427.3 340.7L336 288L427.3 235.3L543.6 258.3L558.2 212.9L506.7 189.5L549.4 164.8L525.4 123.2L482.7 147.9L488.2 91.6L441.6 81.5L403.4 193.7L312.1 246.4L312.1 141L390.2 51.8L358.2 16.5L312.1 49.4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1016 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M191.6 66.9C211.6 38.8 243.9 24 288 24C332.1 24 364.4 38.8 384.4 66.9C400.1 89 406 116.5 407.6 144L480 144C533 144 576 187 576 240L576 552L0 552L0 240C0 187 43 144 96 144L168.4 144C169.9 116.5 175.9 89 191.6 66.9zM324.4 92.1C320.7 85.1 316.3 80.4 311.2 77.4C306.1 74.4 298.8 72 288 72C277.2 72 269.9 74.3 264.8 77.4C259.7 80.5 255.3 85.1 251.6 92.1C245.1 104.2 241.5 121.7 240.4 144L335.6 144C334.5 121.7 330.9 104.2 324.4 92.1zM480 504L480 384L331.5 384C333.8 395.1 337.2 411.1 341.6 432L234.4 432C238.8 411.1 242.2 395.1 244.5 384L96 384L96 504L480 504zM321.4 336L480 336L480 240C480 213.5 458.5 192 432 192L144 192C117.5 192 96 213.5 96 240L96 336L254.6 336L264.5 289.1L311.5 289.1L321.4 336z"/></svg>
|
||||||
|
After Width: | Height: | Size: 963 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M118.2 24L457.8 24L464.7 35.9L548.7 179.9L552 185.5L552 456C552 509 509 552 456 552L120 552C67 552 24 509 24 456L24 185.5L27.3 179.9L111.3 35.9L118.2 24zM120 456C120 482.5 141.5 504 168 504L408 504C434.5 504 456 482.5 456 456L456 216L120 216L120 456zM129.3 168L264 168L264 72L161.3 72L129.3 168zM312 168L446.7 168L414.7 72L312 72L312 168z"/></svg>
|
||||||
|
After Width: | Height: | Size: 606 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M44.6 23C-9.1 63.4-6.2 115.1 25.7 157.4L179 41.9C147.1-.5 98.2-17.5 44.6 23zM399.9 41.9L553.2 157.4C585.1 115.1 588 63.4 534.3 23C480.6-17.4 431.8-.5 399.9 41.9zM288 120C339.2 120 373.7 140.5 396.4 173C419.9 206.7 432 255.2 432 312C432 368.8 419.9 417.3 396.4 451C373.7 483.5 339.1 504 288 504C236.9 504 202.3 483.5 179.6 451C156.1 417.3 144 368.8 144 312C144 255.2 156.1 206.7 179.6 173C202.3 140.5 236.9 120 288 120zM288 72C207 72 146 98.1 105.4 143.2C65.3 187.9 48 248.2 48 312C48 362 58.7 410 82.7 449.9L29.7 512.5L66.3 543.5L112.8 488.5C153.2 528.8 211.8 552 288 552C364.2 552 422.8 528.9 463.2 488.5L509.7 543.5L546.3 512.5L493.3 449.9C517.3 410 528 362 528 312C528 248.3 510.7 187.9 470.6 143.2C430 98.1 369 72 288 72zM312 192L264 192L264 329.3C265.7 329.9 295.1 339.7 352.4 358.8L364.4 362.8L379.6 317.3C378.9 317.1 356.3 309.5 312 294.8L312 192z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M528 24L488.2 5.9C481.9 11.4 476.2 17.5 470.5 23.6C460 34.9 445.4 51.8 429.1 74.4C396.4 119.6 356.1 187.8 325.3 280.4L314.8 312L471.8 312L399.8 552L528.1 552L528.1 24zM480 109.1L480 264L425.8 264C441.5 197 462 145.6 480 109.1zM144 192C117.5 192 96 170.5 96 144L96 24L48 24L48 192C48 241.5 85.5 282.3 133.6 287.4L78.5 552L257.5 552L202.4 287.4C250.5 282.2 288 241.5 288 192L288 24L240 24L240 144C240 170.5 218.5 192 192 192L192 24L144 24L144 192zM198.5 504L137.5 504L168 357.7L198.5 504z"/></svg>
|
||||||
|
After Width: | Height: | Size: 754 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M552.5 48L552.5 24L528.5 24L528.5 48L552.5 48zM129.3 481.1C175.6 520.8 224.7 538.1 273.2 535.5C327.3 532.6 376.7 505.1 417.1 464.8C486.5 395.4 519.7 289.4 535.9 204.4C544.1 161.3 548.2 122.4 550.3 94.3C551.3 80.2 551.8 68.8 552.1 60.8C552.2 56.8 552.3 53.7 552.3 51.6C552.3 50.5 552.3 49.7 552.3 49.1L552.3 48.4L552.3 48.2L552.3 48.1L552.3 48.1L528.3 48.1C528.3 24.1 528.3 24.1 528.3 24.1L528.2 24.1L528 24.1L527.3 24.1C526.7 24.1 525.9 24.1 524.8 24.1C522.6 24.1 519.5 24.2 515.6 24.3C507.6 24.6 496.2 25.1 482.1 26.1C454 28.2 415.1 32.3 372 40.5C288.6 56.4 185.2 88.6 115.8 155.2L115.8 155.2C115.1 155.9 114.4 156.6 113.8 157.3C113.1 158 112.4 158.7 111.7 159.4C71.3 199.8 43.9 249.2 40.9 303.3C38.3 351.8 55.6 401 95.3 447.2L7.5 535L41.4 569L129.3 481.1zM196.8 413.6L226.4 384L360.5 384L360.5 336L274.4 336L425.4 185L391.5 151.1L240.5 302.1L240.5 216L192.5 216L192.5 350.1L162.8 379.8C128.6 341.6 113 308.8 110.1 280.8C107 250.6 118.5 221.2 147.8 191.1C206.8 133.6 299.4 103.2 381.2 87.6C422 79.8 459 75.9 485.8 73.9C492.4 73.4 498.4 73.1 503.7 72.8C503.4 78.1 503.1 84 502.6 90.7C500.6 117.5 496.7 154.5 488.9 195.3C474.4 271 447.3 356.2 397.6 415.1L397.5 415C366.4 446.1 333.2 461.4 298.9 460.5C269 459.7 234.6 446.4 196.8 413.6z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M312 144L312 48L264 48L264 144L192 144L192 48L144 48L144 144L96 144C43 144 0 187 0 240L0 528L576 528L576 240C576 187 533 144 480 144L432 144L432 48L384 48L384 144L312 144zM432 192C458.5 192 480 213.5 480 240L480 336C472.6 336 462.4 332.4 449 319C430.4 300.4 408.6 288 384 288C359.4 288 337.6 300.4 319 319C305.6 332.4 295.4 336 288 336C280.6 336 270.4 332.4 257 319C238.4 300.4 216.6 288 192 288C167.4 288 145.6 300.4 127 319C113.6 332.4 103.4 336 96 336L96 240C96 213.5 117.5 192 144 192L432 192zM96 480L96 384C120.6 384 142.4 371.6 161 353C174.4 339.6 184.6 336 192 336C199.4 336 209.6 339.6 223 353C241.6 371.6 263.4 384 288 384C312.6 384 334.4 371.6 353 353C366.4 339.6 376.6 336 384 336C391.4 336 401.6 339.6 415 353C433.6 371.6 455.4 384 480 384L480 480L96 480z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! 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="M309.6 0L266.4 0L222 47.9C208.1 62.9 188.5 71.2 168.1 70.9L89.2 63L63 89.4L70.9 164.2C71.2 183.7 63.6 202.5 49.8 216.3L0 266.1L0 309.6L47.9 354C62.9 367.9 71.2 387.5 70.9 407.9L63 486.7L91 512.7L164.3 499.9C188.2 497.7 211.6 507.6 226.7 526.3L266.9 575.9L309.2 575.9L349.4 526.3C364.5 507.6 387.9 497.8 411.8 499.9L485.1 512.7L513.1 486.7L505.2 407.9C504.9 387.5 513.2 367.9 528.2 354L576.1 309.6L576.1 266.1L526.3 216.3C512.5 202.5 504.9 183.7 505.2 164.2L513.1 89.4L486.8 63L408 70.9C387.6 71.2 368 62.9 354.1 47.9L309.6 0zM288 64.4L292.8 73.8C319.6 125.9 380.2 151 436 133.1L446 129.9L442.8 139.9C424.9 195.7 450 256.3 502.1 283.1L511.5 287.9L502.1 292.7C450 319.5 424.9 380.1 442.8 435.9L446 445.9L436 442.7C380.2 424.8 319.6 449.9 292.8 502L288 511.4L283.2 502C256.4 449.9 195.8 424.8 140 442.7L130 445.9L133.2 435.9C151.1 380.1 126 319.5 73.9 292.7L64.5 287.9L73.9 283.1C126 256.3 151.1 195.7 133.2 139.9L130 129.9L140 133.1C195.8 151 256.4 125.9 283.2 73.8L288 64.4zM322.3 224C330.9 238.4 336 260.5 336 288C336 315.5 330.9 337.6 322.3 352C314.5 365.1 303.9 372 288 372C272.1 372 261.5 365.1 253.7 352C245.1 337.6 240 315.5 240 288C240 260.5 245.1 238.4 253.7 224C261.5 210.9 272.1 204 288 204C303.9 204 314.5 210.9 322.3 224zM288 156C244.8 156 210.8 170 187.8 195.6C165.2 220.7 156 254.1 156 288C156 321.9 165.2 355.3 187.8 380.4C210.8 406 244.8 420 288 420C331.2 420 365.2 406 388.2 380.4C410.8 355.3 420 321.9 420 288C420 254.1 410.8 220.7 388.2 195.6C365.2 170 331.2 156 288 156z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -4,6 +4,38 @@
|
|||||||
return meta ? meta.getAttribute("content") : "";
|
return meta ? meta.getAttribute("content") : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scrollKey = "nouri-week-scroll";
|
||||||
|
|
||||||
|
const rememberScroll = () => {
|
||||||
|
sessionStorage.setItem(scrollKey, String(window.scrollY));
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreScroll = () => {
|
||||||
|
const savedScroll = sessionStorage.getItem(scrollKey);
|
||||||
|
if (!savedScroll) return;
|
||||||
|
sessionStorage.removeItem(scrollKey);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({ top: Number(savedScroll), left: 0, behavior: "auto" });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const postAndRefreshInPlace = async (form) => {
|
||||||
|
const payload = new URLSearchParams(new FormData(form));
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
body: payload.toString(),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("request failed");
|
||||||
|
}
|
||||||
|
rememberScroll();
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
const initWeekDragAndDrop = () => {
|
const initWeekDragAndDrop = () => {
|
||||||
const board = document.querySelector(".week-board");
|
const board = document.querySelector(".week-board");
|
||||||
if (!board) return;
|
if (!board) return;
|
||||||
@@ -63,11 +95,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
rememberScroll();
|
||||||
if (result.redirect_url) {
|
if (result.redirect_url) {
|
||||||
window.location.href = result.redirect_url;
|
window.location.href = result.redirect_url;
|
||||||
} else {
|
} else {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
rememberScroll();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initWeekCopyForward = () => {
|
||||||
|
document.querySelectorAll(".js-copy-forward-form").forEach((form) => {
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postAndRefreshInPlace(form);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
@@ -75,7 +122,239 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initWeekSlotPicker = () => {
|
||||||
|
const board = document.querySelector(".week-board");
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
const closeAllPickers = () => {
|
||||||
|
board.querySelectorAll(".week-card").forEach((card) => {
|
||||||
|
card.classList.remove("has-open-picker");
|
||||||
|
});
|
||||||
|
board.querySelectorAll(".week-slot").forEach((slot) => {
|
||||||
|
slot.classList.remove("is-picker-open");
|
||||||
|
});
|
||||||
|
board.querySelectorAll(".week-slot-picker").forEach((picker) => {
|
||||||
|
picker.hidden = true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
board.querySelectorAll("[data-week-slot-picker-open]").forEach((button) => {
|
||||||
|
button.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const slot = button.closest(".week-slot");
|
||||||
|
if (!slot) return;
|
||||||
|
const picker = slot.querySelector(".week-slot-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
const card = slot.closest(".week-card");
|
||||||
|
const shouldOpen = picker.hidden;
|
||||||
|
closeAllPickers();
|
||||||
|
if (shouldOpen) {
|
||||||
|
picker.hidden = false;
|
||||||
|
slot.classList.add("is-picker-open");
|
||||||
|
if (card) {
|
||||||
|
card.classList.add("has-open-picker");
|
||||||
|
}
|
||||||
|
const filterInput = picker.querySelector("[data-filter-input]");
|
||||||
|
if (filterInput instanceof HTMLInputElement) {
|
||||||
|
filterInput.value = "";
|
||||||
|
filterInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
window.requestAnimationFrame(() => filterInput.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
board.querySelectorAll("[data-week-slot-picker-close]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const slot = button.closest(".week-slot");
|
||||||
|
if (!slot) return;
|
||||||
|
const picker = slot.querySelector(".week-slot-picker");
|
||||||
|
const card = slot.closest(".week-card");
|
||||||
|
if (!picker) return;
|
||||||
|
picker.hidden = true;
|
||||||
|
slot.classList.remove("is-picker-open");
|
||||||
|
if (card) {
|
||||||
|
card.classList.remove("has-open-picker");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
board.querySelectorAll(".js-week-slot-submit").forEach((form) => {
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postAndRefreshInPlace(form);
|
||||||
|
} catch (_error) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
if (target.closest(".week-slot")) return;
|
||||||
|
closeAllPickers();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initWeekEntryDialogs = () => {
|
||||||
|
const board = document.querySelector(".week-board");
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
const openDialog = (trigger) => {
|
||||||
|
const dialogId = trigger.getAttribute("data-week-entry-dialog-id");
|
||||||
|
if (!dialogId) return;
|
||||||
|
const dialog = document.getElementById(dialogId);
|
||||||
|
if (!(dialog instanceof HTMLDialogElement)) return;
|
||||||
|
if (!dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
board.querySelectorAll("[data-week-entry-open]").forEach((entry) => {
|
||||||
|
entry.addEventListener("click", (event) => {
|
||||||
|
if (event.target instanceof Element && event.target.closest("button, a, input, select, textarea, label, form")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openDialog(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
openDialog(entry);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".week-entry-dialog").forEach((dialog) => {
|
||||||
|
if (!(dialog instanceof HTMLDialogElement)) return;
|
||||||
|
|
||||||
|
dialog.addEventListener("click", (event) => {
|
||||||
|
const rect = dialog.getBoundingClientRect();
|
||||||
|
const clickedInside =
|
||||||
|
rect.top <= event.clientY &&
|
||||||
|
event.clientY <= rect.top + rect.height &&
|
||||||
|
rect.left <= event.clientX &&
|
||||||
|
event.clientX <= rect.left + rect.width;
|
||||||
|
if (!clickedInside) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-week-entry-close]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const dialog = button.closest(".week-entry-dialog");
|
||||||
|
if (dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".js-week-entry-submit").forEach((form) => {
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postAndRefreshInPlace(form);
|
||||||
|
} catch (_error) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncActionContainerVisibility = (container) => {
|
||||||
|
if (!(container instanceof HTMLElement)) return;
|
||||||
|
const hasVisibleButtons = Array.from(container.querySelectorAll("button")).some((button) => {
|
||||||
|
return !button.hidden;
|
||||||
|
});
|
||||||
|
container.hidden = !hasVisibleButtons;
|
||||||
|
};
|
||||||
|
|
||||||
|
const revealActionButton = (container, selector) => {
|
||||||
|
if (!(container instanceof HTMLElement) || !selector) return;
|
||||||
|
const button = container.querySelector(`button[data-target="${selector}"]`);
|
||||||
|
if (!(button instanceof HTMLButtonElement)) return;
|
||||||
|
button.hidden = false;
|
||||||
|
container.hidden = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initDaySnackReveal = () => {
|
||||||
|
document.querySelectorAll("[data-day-snack-open]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const selector = button.getAttribute("data-target");
|
||||||
|
if (!selector) return;
|
||||||
|
const tile = document.querySelector(selector);
|
||||||
|
if (!(tile instanceof HTMLDetailsElement)) return;
|
||||||
|
tile.hidden = false;
|
||||||
|
tile.open = true;
|
||||||
|
button.hidden = true;
|
||||||
|
tile.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||||
|
syncActionContainerVisibility(button.closest("[data-day-snack-actions]"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-day-snack-hide]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const selector = button.getAttribute("data-target");
|
||||||
|
if (!selector) return;
|
||||||
|
const tile = document.querySelector(selector);
|
||||||
|
if (!(tile instanceof HTMLDetailsElement)) return;
|
||||||
|
tile.open = false;
|
||||||
|
tile.hidden = true;
|
||||||
|
revealActionButton(document.querySelector("[data-day-snack-actions]"), selector);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initWeekSnackReveal = () => {
|
||||||
|
document.querySelectorAll("[data-week-snack-slot-open]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const selector = button.getAttribute("data-target");
|
||||||
|
if (!selector) return;
|
||||||
|
const slot = document.querySelector(selector);
|
||||||
|
if (!(slot instanceof HTMLElement)) return;
|
||||||
|
slot.hidden = false;
|
||||||
|
button.hidden = true;
|
||||||
|
syncActionContainerVisibility(button.closest("[data-week-snack-actions]"));
|
||||||
|
const openButton = slot.querySelector("[data-week-slot-picker-open]");
|
||||||
|
if (openButton instanceof HTMLButtonElement) {
|
||||||
|
openButton.click();
|
||||||
|
} else {
|
||||||
|
slot.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-week-snack-slot-hide]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const selector = button.getAttribute("data-target");
|
||||||
|
if (!selector) return;
|
||||||
|
const slot = document.querySelector(selector);
|
||||||
|
if (!(slot instanceof HTMLElement)) return;
|
||||||
|
const picker = slot.querySelector(".week-slot-picker");
|
||||||
|
if (picker instanceof HTMLElement) {
|
||||||
|
picker.hidden = true;
|
||||||
|
}
|
||||||
|
slot.classList.remove("is-picker-open");
|
||||||
|
slot.hidden = true;
|
||||||
|
const card = slot.closest(".week-card");
|
||||||
|
if (card) {
|
||||||
|
card.classList.remove("has-open-picker");
|
||||||
|
}
|
||||||
|
revealActionButton(slot.closest(".week-card")?.querySelector("[data-week-snack-actions]"), selector);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
restoreScroll();
|
||||||
initWeekDragAndDrop();
|
initWeekDragAndDrop();
|
||||||
|
initWeekCopyForward();
|
||||||
|
initWeekSlotPicker();
|
||||||
|
initWeekEntryDialogs();
|
||||||
|
initDaySnackReveal();
|
||||||
|
initWeekSnackReveal();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -12,7 +12,22 @@
|
|||||||
root.dataset.theme = finalTheme;
|
root.dataset.theme = finalTheme;
|
||||||
|
|
||||||
toggles().forEach((button) => {
|
toggles().forEach((button) => {
|
||||||
button.textContent = finalTheme === "dark" ? "Hell" : "Dunkel";
|
const nextModeLabel = finalTheme === "dark" ? "Hell" : "Dunkel";
|
||||||
|
const label = button.querySelector("[data-theme-label]");
|
||||||
|
const icon = button.querySelector("[data-theme-icon]");
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
label.textContent = nextModeLabel;
|
||||||
|
} else {
|
||||||
|
button.textContent = nextModeLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.toggle("icon-sun-theme", finalTheme === "dark");
|
||||||
|
icon.classList.toggle("icon-moon-theme", finalTheme !== "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
button.setAttribute("aria-label", `${nextModeLabel} aktivieren`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,33 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const initMobileSheet = () => {
|
const initMobileSheet = () => {
|
||||||
const sheet = document.querySelector("[data-mobile-sheet]");
|
const sheet = document.querySelector("[data-mobile-sheet]");
|
||||||
const backdrop = document.querySelector("[data-mobile-sheet-backdrop]");
|
const navStack = document.querySelector("[data-mobile-nav-stack]");
|
||||||
const openButtons = document.querySelectorAll("[data-mobile-sheet-open]");
|
const openButtons = document.querySelectorAll("[data-mobile-sheet-open]");
|
||||||
const closeButtons = document.querySelectorAll("[data-mobile-sheet-close]");
|
if (!sheet || !navStack || !openButtons.length) return;
|
||||||
if (!sheet || !backdrop || !openButtons.length) return;
|
|
||||||
|
|
||||||
const closeSheet = () => {
|
const closeSheet = () => {
|
||||||
sheet.hidden = true;
|
sheet.hidden = true;
|
||||||
backdrop.hidden = true;
|
navStack.classList.remove("is-open");
|
||||||
document.body.classList.remove("sheet-open");
|
|
||||||
openButtons.forEach((button) => button.classList.remove("is-open"));
|
openButtons.forEach((button) => button.classList.remove("is-open"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const openSheet = () => {
|
const openSheet = () => {
|
||||||
sheet.hidden = false;
|
sheet.hidden = false;
|
||||||
backdrop.hidden = false;
|
navStack.classList.add("is-open");
|
||||||
document.body.classList.add("sheet-open");
|
|
||||||
openButtons.forEach((button) => button.classList.add("is-open"));
|
openButtons.forEach((button) => button.classList.add("is-open"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleSheet = () => {
|
||||||
|
if (sheet.hidden) {
|
||||||
|
openSheet();
|
||||||
|
} else {
|
||||||
|
closeSheet();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
openButtons.forEach((button) => {
|
openButtons.forEach((button) => {
|
||||||
button.addEventListener("click", openSheet);
|
button.addEventListener("click", toggleSheet);
|
||||||
});
|
});
|
||||||
closeButtons.forEach((button) => {
|
|
||||||
button.addEventListener("click", closeSheet);
|
|
||||||
});
|
|
||||||
backdrop.addEventListener("click", closeSheet);
|
|
||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
@@ -37,6 +38,9 @@
|
|||||||
sheet.querySelectorAll("a").forEach((link) => {
|
sheet.querySelectorAll("a").forEach((link) => {
|
||||||
link.addEventListener("click", closeSheet);
|
link.addEventListener("click", closeSheet);
|
||||||
});
|
});
|
||||||
|
sheet.querySelectorAll("button[data-theme-toggle]").forEach((button) => {
|
||||||
|
button.addEventListener("click", closeSheet);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const initFilterInputs = () => {
|
const initFilterInputs = () => {
|
||||||
@@ -47,12 +51,57 @@
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const items = Array.from(container.querySelectorAll("[data-filter-label]"));
|
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 applyFilter = () => {
|
||||||
const term = input.value.trim().toLowerCase();
|
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) => {
|
items.forEach((item) => {
|
||||||
const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase();
|
item.hidden = !allowedItems.has(item);
|
||||||
item.hidden = Boolean(term) && !haystack.includes(term);
|
|
||||||
});
|
});
|
||||||
|
syncGroups();
|
||||||
};
|
};
|
||||||
|
|
||||||
input.addEventListener("input", applyFilter);
|
input.addEventListener("input", applyFilter);
|
||||||
@@ -60,8 +109,53 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
initMobileSheet();
|
initMobileSheet();
|
||||||
initFilterInputs();
|
initFilterInputs();
|
||||||
|
initIosPullToRefresh();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -6,9 +6,16 @@
|
|||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"display_override": ["standalone", "minimal-ui"],
|
||||||
"background_color": "#fff6ef",
|
"background_color": "#fff6ef",
|
||||||
"theme_color": "#efab72",
|
"theme_color": "#de9862",
|
||||||
|
"categories": ["food", "lifestyle", "productivity"],
|
||||||
"icons": [
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/brand/pwa-180.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"src": "/static/brand/pwa-192.png",
|
"src": "/static/brand/pwa-192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
@@ -18,6 +25,12 @@
|
|||||||
"src": "/static/brand/pwa-512.png",
|
"src": "/static/brand/pwa-512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"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>
|
||||||
@@ -1,19 +1,35 @@
|
|||||||
const CACHE_NAME = "nouri-v0-5-0";
|
const CACHE_NAME = "nouri-v1-0-0";
|
||||||
const APP_SHELL = [
|
const OFFLINE_URL = "/static/pwa/offline.html";
|
||||||
"/",
|
const STATIC_ASSETS = [
|
||||||
"/static/css/styles.css",
|
"/static/css/styles.css",
|
||||||
"/static/js/theme.js",
|
"/static/js/theme.js",
|
||||||
"/static/js/ui.js",
|
"/static/js/ui.js",
|
||||||
"/static/js/planner.js",
|
"/static/js/planner.js",
|
||||||
"/static/js/pwa.js",
|
"/static/js/pwa.js",
|
||||||
|
"/static/brand/pwa-180.png",
|
||||||
"/static/brand/pwa-192.png",
|
"/static/brand/pwa-192.png",
|
||||||
"/static/brand/pwa-512.png",
|
"/static/brand/pwa-512.png",
|
||||||
|
"/static/brand/pwa-maskable-512.png",
|
||||||
|
"/static/brand/pwa-badge.png",
|
||||||
"/static/brand/favicon.svg",
|
"/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) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)).then(() => self.skipWaiting())
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,19 +43,34 @@ self.addEventListener("activate", (event) => {
|
|||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
if (event.request.method !== "GET") return;
|
if (event.request.method !== "GET") return;
|
||||||
event.respondWith(
|
|
||||||
caches.match(event.request).then((cached) => {
|
const requestUrl = new URL(event.request.url);
|
||||||
if (cached) return cached;
|
const isSameOrigin = requestUrl.origin === self.location.origin;
|
||||||
return fetch(event.request).then((response) => {
|
const isStaticAsset = isSameOrigin && (
|
||||||
if (!response || response.status !== 200 || response.type !== "basic") {
|
requestUrl.pathname.startsWith("/static/")
|
||||||
return response;
|
|| requestUrl.pathname === "/app.webmanifest"
|
||||||
}
|
|| requestUrl.pathname === "/service-worker.js"
|
||||||
const clone = response.clone();
|
|
||||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
|
||||||
return response;
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
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) => {
|
self.addEventListener("push", (event) => {
|
||||||
|
|||||||
@@ -66,6 +66,12 @@
|
|||||||
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
|
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -44,7 +44,12 @@
|
|||||||
<article class="item-card">
|
<article class="item-card">
|
||||||
<div class="item-media">
|
<div class="item-media">
|
||||||
{% if item.photo_filename %}
|
{% 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 %}
|
{% else %}
|
||||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -56,7 +61,21 @@
|
|||||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
<span class="chip">{{ item.for_label }}</span>
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
{% if item.kind == 'food' %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.base_type_label }}</span>
|
||||||
|
<span class="chip">{{ item.suggestion_role_label }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">{{ item_kind_labels[item.kind] }}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.meal_type_label }}</span>
|
||||||
|
<span class="chip">{{ energy_density_labels[item.energy_density] }}</span>
|
||||||
|
{% for tag in item.meal_tag_labels %}
|
||||||
|
<span class="chip">{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if item.dayparts %}
|
{% if item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
{% for daypart in item.dayparts %}
|
{% for daypart in item.dayparts %}
|
||||||
|
|||||||
@@ -5,7 +5,22 @@
|
|||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<p class="eyebrow">Erster Start</p>
|
<p class="eyebrow">Erster Start</p>
|
||||||
<h1>Den ersten Haushalt-Zugang anlegen</h1>
|
<h1>Den ersten Haushalt-Zugang anlegen</h1>
|
||||||
<p class="lead">Danach könnt ihr Nouri gemeinsam nutzen. Persönliche und gemeinsame Einträge lassen sich später ruhig auseinanderhalten.</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">
|
<form method="post" class="stack-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
|
|||||||
@@ -2,63 +2,74 @@
|
|||||||
<html lang="de" data-theme="auto">
|
<html lang="de" data-theme="auto">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<title>{% block title %}Nouri{% endblock %}</title>
|
<title>{% block title %}Nouri{% endblock %}</title>
|
||||||
<meta name="theme-color" content="#efab72">
|
<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-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
<meta name="apple-mobile-web-app-title" content="Nouri">
|
<meta name="apple-mobile-web-app-title" content="Nouri">
|
||||||
|
<meta name="application-name" content="Nouri">
|
||||||
<meta name="csrf-token" content="{{ csrf_token_value }}">
|
<meta name="csrf-token" content="{{ csrf_token_value }}">
|
||||||
<meta name="nouri-push-public-key" content="{{ push_public_key }}">
|
<meta name="nouri-push-public-key" content="{{ push_public_key }}">
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='brand/favicon.svg') }}">
|
<link rel="icon" type="image/svg+xml" href="{{ asset_url('brand/favicon.svg') }}">
|
||||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/pwa-192.png') }}">
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ asset_url('brand/pwa-180.png') }}">
|
||||||
<link rel="manifest" href="{{ url_for('webmanifest') }}">
|
<link rel="manifest" href="{{ url_for('webmanifest') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
<link rel="stylesheet" href="{{ asset_url('css/styles.css') }}">
|
||||||
<script defer src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
<script defer src="{{ asset_url('js/theme.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/planner.js') }}"></script>
|
<script defer src="{{ asset_url('js/planner.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/ui.js') }}"></script>
|
<script defer src="{{ asset_url('js/ui.js') }}"></script>
|
||||||
<script defer src="{{ url_for('static', filename='js/pwa.js') }}"></script>
|
<script defer src="{{ asset_url('js/pwa.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="{% if g.user %}has-mobile-nav{% endif %}">
|
<body class="{% if g.user %}has-mobile-nav{% endif %}">
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
<div class="desktop-header-main">
|
||||||
<span class="brand-mark">
|
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
||||||
<img src="{{ url_for('static', filename='brand/nouri-icon.svg') }}" alt="">
|
<span class="brand-mark">
|
||||||
</span>
|
<img src="{{ asset_url('brand/nouri-icon.svg') }}" alt="">
|
||||||
<span class="brand-copy">
|
</span>
|
||||||
<strong>Nouri</strong>
|
<span class="brand-copy">
|
||||||
<small>einfach essen planen</small>
|
<strong>Nouri</strong>
|
||||||
</span>
|
<small>einfach essen planen</small>
|
||||||
</a>
|
</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 %}
|
{% if g.user %}
|
||||||
<nav class="site-nav desktop-nav">
|
<div class="desktop-header-sub">
|
||||||
<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>
|
<div class="header-actions desktop-actions">
|
||||||
<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>
|
<button class="theme-toggle ghost-button" type="button" data-theme-toggle aria-label="Darstellung wechseln">
|
||||||
<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>
|
<span class="ui-icon icon-sun-theme" data-theme-icon></span>
|
||||||
<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>
|
<span data-theme-label>Hell</span>
|
||||||
<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>
|
</button>
|
||||||
<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 class="ghost-button" href="{{ url_for('main.settings_view') }}">Optionen</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 class="user-chip" href="{{ url_for('auth.profile') }}">
|
||||||
<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>
|
<span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span>
|
||||||
<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>
|
<small>{{ role_labels[g.user.role] }}</small>
|
||||||
</nav>
|
</a>
|
||||||
|
{% if g.user.role == 'admin' %}
|
||||||
<div class="header-actions desktop-actions">
|
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Nutzer</a>
|
||||||
<button class="theme-toggle ghost-button" type="button" data-theme-toggle>Modus</button>
|
{% endif %}
|
||||||
<a class="ghost-button" href="{{ url_for('main.settings_view') }}">Optionen</a>
|
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||||
<a class="user-chip" href="{{ url_for('auth.profile') }}">
|
{{ csrf_input() }}
|
||||||
<span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span>
|
<button class="ghost-button" type="submit">Abmelden</button>
|
||||||
<small>{{ role_labels[g.user.role] }}</small>
|
</form>
|
||||||
</a>
|
</div>
|
||||||
{% 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">
|
<button class="mobile-profile-link ghost-button" type="button" data-mobile-sheet-open aria-label="Mehr öffnen">
|
||||||
@@ -83,7 +94,7 @@
|
|||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="footer-copy">
|
<div class="footer-copy">
|
||||||
<span>Version {{ app_version }}</span>
|
<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>
|
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-copy">
|
<div class="footer-copy">
|
||||||
@@ -93,59 +104,54 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if g.user %}
|
{% if g.user %}
|
||||||
<div class="mobile-sheet-backdrop" data-mobile-sheet-backdrop hidden></div>
|
<div class="mobile-nav-stack" data-mobile-nav-stack>
|
||||||
<aside class="mobile-more-sheet" data-mobile-sheet hidden aria-label="Mehr">
|
<nav class="mobile-nav-extension" data-mobile-sheet hidden aria-label="Mehr Navigation">
|
||||||
<div class="mobile-sheet-head">
|
<a class="mobile-extra-link" href="{{ url_for('main.item_list', kind='food') }}"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></a>
|
||||||
<div>
|
<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>
|
||||||
<strong>{{ g.user.display_name or g.user.username }}</strong>
|
<a class="mobile-extra-link" href="{{ url_for('main.home_view') }}"><span class="ui-icon icon-house"></span><span>Zuhause</span></a>
|
||||||
<small>{{ role_labels[g.user.role] }}</small>
|
<a class="mobile-extra-link" href="{{ url_for('main.archive_view') }}"><span class="ui-icon icon-archive"></span><span>Archiv</span></a>
|
||||||
</div>
|
<a class="mobile-extra-link" href="{{ url_for('main.template_library') }}"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></a>
|
||||||
<button class="ghost-button" type="button" data-mobile-sheet-close>Schließen</button>
|
<a class="mobile-extra-link" href="{{ url_for('main.settings_view') }}"><span class="ui-icon icon-sliders"></span><span>Optionen</span></a>
|
||||||
</div>
|
<a class="mobile-extra-link" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
|
||||||
<nav class="mobile-sheet-links card-link-grid">
|
|
||||||
<a class="menu-card" href="{{ url_for('main.item_list', kind='food') }}"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></a>
|
|
||||||
<a class="menu-card" href="{{ url_for('main.item_list', kind='meal') }}"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></a>
|
|
||||||
<a class="menu-card" href="{{ url_for('main.home_view') }}"><span class="ui-icon icon-house"></span><span>Zuhause</span></a>
|
|
||||||
<a class="menu-card" href="{{ url_for('main.archive_view') }}"><span class="ui-icon icon-archive"></span><span>Archiv</span></a>
|
|
||||||
<a class="menu-card" href="{{ url_for('main.template_library') }}"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></a>
|
|
||||||
<a class="menu-card" href="{{ url_for('main.settings_view') }}"><span class="ui-icon icon-sliders"></span><span>Optionen</span></a>
|
|
||||||
<a class="menu-card" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
|
|
||||||
{% if g.user.role == 'admin' %}
|
{% if g.user.role == 'admin' %}
|
||||||
<a class="menu-card" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
|
<a class="mobile-extra-link" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
|
||||||
<a class="menu-card" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</nav>
|
<button class="mobile-extra-link mobile-extra-button" type="button" data-theme-toggle aria-label="Darstellung wechseln">
|
||||||
<div class="mobile-sheet-actions">
|
<span class="ui-icon icon-sun-theme" data-theme-icon></span>
|
||||||
<button class="ghost-button" type="button" data-theme-toggle>Modus wechseln</button>
|
<span data-theme-label>Hell</span>
|
||||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
</button>
|
||||||
|
<form method="post" action="{{ url_for('auth.logout') }}" class="mobile-extra-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Abmelden</button>
|
<button class="mobile-extra-link mobile-extra-button" type="submit">
|
||||||
|
<span class="ui-icon icon-ellipsis"></span>
|
||||||
|
<span>Abmelden</span>
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</nav>
|
||||||
</aside>
|
|
||||||
|
|
||||||
<nav class="mobile-bottom-nav" aria-label="Mobile Navigation">
|
<nav class="mobile-bottom-nav" aria-label="Mobile Navigation">
|
||||||
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">
|
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">
|
||||||
<span class="ui-icon icon-sparkles"></span>
|
<span class="ui-icon icon-sparkles"></span>
|
||||||
<span>Heute</span>
|
<span>Heute</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}">
|
<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 class="ui-icon icon-cart-shopping"></span>
|
||||||
<span>Einkauf</span>
|
<span>Einkauf</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}">
|
<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 class="ui-icon icon-calendar"></span>
|
||||||
<span>Plan</span>
|
<span>Plan</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">
|
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">
|
||||||
<span class="ui-icon icon-calendar-days"></span>
|
<span class="ui-icon icon-calendar-days"></span>
|
||||||
<span>Woche</span>
|
<span>Woche</span>
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="mobile-nav-button" data-mobile-sheet-open>
|
<button type="button" class="mobile-nav-button" data-mobile-sheet-open>
|
||||||
<span class="ui-icon icon-ellipsis"></span>
|
<span class="ui-icon icon-ellipsis"></span>
|
||||||
<span>Mehr</span>
|
<span>Mehr</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Heute</p>
|
<p class="eyebrow">Heute</p>
|
||||||
<h1>Ein ruhiger Blick auf euren Alltag</h1>
|
<h1>Ein guter 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>
|
<p class="lead">Du siehst, was zuhause da ist, was schon geplant wurde, welche Vorlagen passen und wo heute noch etwas dazukommen kann.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
|
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
|
||||||
@@ -13,6 +13,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="stats-grid">
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<span>Zuhause</span>
|
<span>Zuhause</span>
|
||||||
@@ -74,7 +91,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel dashboard-spaced-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Kurz griffbereit</h2>
|
<h2>Kurz griffbereit</h2>
|
||||||
<a href="{{ url_for('main.home_view') }}">Alles unter Zuhause</a>
|
<a href="{{ url_for('main.home_view') }}">Alles unter Zuhause</a>
|
||||||
@@ -105,7 +122,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="two-column">
|
<section class="two-column">
|
||||||
<article class="panel">
|
<article class="panel dashboard-spaced-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Was zuhause gut zusammenpasst</h2>
|
<h2>Was zuhause gut zusammenpasst</h2>
|
||||||
<a href="{{ url_for('main.home_view') }}">Zuhause öffnen</a>
|
<a href="{{ url_for('main.home_view') }}">Zuhause öffnen</a>
|
||||||
@@ -117,8 +134,26 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>{{ suggestion.title }}</strong>
|
<strong>{{ suggestion.title }}</strong>
|
||||||
<small>{{ suggestion.reason }}</small>
|
<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>
|
</div>
|
||||||
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
|
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -188,8 +223,8 @@
|
|||||||
<span>{{ card.planned_count }} Einträge</span>
|
<span>{{ card.planned_count }} Einträge</span>
|
||||||
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
|
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span>Noch frei</span>
|
<span>Noch offen</span>
|
||||||
<small>ruhiger Einstieg für den Tag</small>
|
<small>Du kannst mit einem Eintrag anfangen.</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -51,8 +51,26 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>{{ suggestion.title }}</strong>
|
<strong>{{ suggestion.title }}</strong>
|
||||||
<small>{{ suggestion.reason }}</small>
|
<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>
|
</div>
|
||||||
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
|
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -72,7 +90,12 @@
|
|||||||
<article class="item-card compact">
|
<article class="item-card compact">
|
||||||
<div class="item-media">
|
<div class="item-media">
|
||||||
{% if item.photo_filename %}
|
{% if item.photo_filename %}
|
||||||
<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 %}
|
{% else %}
|
||||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -84,7 +107,18 @@
|
|||||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
<span class="chip">{{ item.for_label }}</span>
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
{% if item.kind == 'food' %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.base_type_label }}</span>
|
||||||
|
<span class="chip">{{ item.suggestion_role_label }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">{{ item_kind_labels[item.kind] }}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.meal_type_label }}</span>
|
||||||
|
<span class="chip">{{ energy_density_labels[item.energy_density] }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if item.components %}
|
{% if item.components %}
|
||||||
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -4,8 +4,22 @@
|
|||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||||
<h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1>
|
<h1>
|
||||||
<p class="lead">Nur das Nötigste: Name, Sichtbarkeit, für wen etwas gedacht ist, Bild, Tageszeiten und eine kleine Notiz.</p>
|
{% if item and kind == 'meal' %}
|
||||||
|
{{ item.name }}
|
||||||
|
{% elif item %}
|
||||||
|
{{ item.name }} bearbeiten
|
||||||
|
{% else %}
|
||||||
|
Neue {{ item_kind_singular_labels[kind] }}
|
||||||
|
{% endif %}
|
||||||
|
</h1>
|
||||||
|
<p class="lead">
|
||||||
|
{% if kind == 'food' %}
|
||||||
|
Name, Sichtbarkeit und ein paar ruhige Hinweise dazu, wie ein Lebensmittel in Vorschlägen gut passt.
|
||||||
|
{% else %}
|
||||||
|
Name, Sichtbarkeit, Tageszeit und ein kurzer Charakter der Idee. So bleiben Mahlzeitenideen alltagsnah und leicht pflegbar.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% if item %}
|
{% if item %}
|
||||||
<div class="intro-pills">
|
<div class="intro-pills">
|
||||||
@@ -45,15 +59,107 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label>
|
{% if kind == 'food' %}
|
||||||
Kategorie
|
<div class="dual-grid">
|
||||||
<select name="category">
|
<label>
|
||||||
<option value="">Ohne Kategorie</option>
|
Baustein
|
||||||
{% for category in categories %}
|
<select name="base_type">
|
||||||
<option value="{{ category }}" {% if form_data.category == category %}selected{% endif %}>{{ category }}</option>
|
{% for value, label in builder_options %}
|
||||||
{% endfor %}
|
<option value="{{ value }}" {% if form_data.base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
</select>
|
{% endfor %}
|
||||||
</label>
|
</select>
|
||||||
|
<small class="helper-text">{{ builder_descriptions[form_data.base_type] }}</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Geschmacksrichtung
|
||||||
|
<select name="flavor_profile">
|
||||||
|
{% for value, label in food_flavor_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.flavor_profile == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="helper-text">{{ food_flavor_descriptions[form_data.flavor_profile] }}</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dual-grid">
|
||||||
|
<label>
|
||||||
|
Rolle in Vorschlägen
|
||||||
|
<select name="suggestion_role">
|
||||||
|
{% for value, label in food_role_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.suggestion_role == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="helper-text">{{ food_role_descriptions[form_data.suggestion_role] }}</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Wird eher vorgeschlagen
|
||||||
|
<select name="suggestion_priority">
|
||||||
|
{% for value, label in suggestion_priority_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.suggestion_priority == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="inline-check">
|
||||||
|
<input type="checkbox" name="can_be_meal_core" value="1" {% if form_data.can_be_meal_core %}checked{% endif %}>
|
||||||
|
<span>Kann gut eine Mahlzeit tragen</span>
|
||||||
|
</label>
|
||||||
|
<small class="helper-text">Praktisch für Dinge wie Tofu, Joghurt oder Müsli. Extras wie Proteinpulver, Tomatenmark oder Saaten bleiben so eher Ergänzungen.</small>
|
||||||
|
{% else %}
|
||||||
|
<div class="dual-grid">
|
||||||
|
<label>
|
||||||
|
Mahlzeittyp
|
||||||
|
<select name="meal_type">
|
||||||
|
{% for value, label in meal_type_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.meal_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Charakter der Mahlzeit</legend>
|
||||||
|
<div class="checkbox-grid meal-style-option-grid">
|
||||||
|
{% for value, label in meal_style_options %}
|
||||||
|
<label class="meal-style-option">
|
||||||
|
<input type="checkbox" name="meal_tags" value="{{ value }}" {% if value in form_data.meal_tags %}checked{% endif %}>
|
||||||
|
<span class="meal-style-option-card">
|
||||||
|
<span class="meal-style-option-icon">
|
||||||
|
<span class="ui-icon icon-meal-style-{{ value }}"></span>
|
||||||
|
</span>
|
||||||
|
<span class="meal-style-option-label">{{ label }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<small class="helper-text">Nur das auswählen, was wirklich hilft. Alles andere kann leer bleiben.</small>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if kind == 'food' %}
|
||||||
|
<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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Notiz
|
Notiz
|
||||||
@@ -67,26 +173,57 @@
|
|||||||
|
|
||||||
{% if item and item.photo_filename %}
|
{% if item and item.photo_filename %}
|
||||||
<div class="inline-photo">
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<fieldset>
|
{% if kind == 'food' %}
|
||||||
<legend>Passende Tageszeiten</legend>
|
<fieldset>
|
||||||
<div class="checkbox-grid">
|
<legend>Passende Tageszeiten</legend>
|
||||||
{% for daypart in dayparts %}
|
<div class="checkbox-grid daypart-option-grid">
|
||||||
<label class="check-option">
|
{% for daypart in dayparts %}
|
||||||
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
<label class="daypart-option">
|
||||||
<span>{{ daypart.name }}</span>
|
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
||||||
</label>
|
<span class="daypart-option-card">
|
||||||
{% endfor %}
|
<span class="daypart-option-icon">
|
||||||
</div>
|
<span class="ui-icon {{ daypart_icon_class(daypart.slug) }}"></span>
|
||||||
</fieldset>
|
</span>
|
||||||
|
<span class="daypart-option-label">{{ daypart.name }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if kind == 'meal' %}
|
{% if kind == 'meal' %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Bestandteile der Mahlzeitenidee</legend>
|
<legend>Bestandteile der Mahlzeitenidee</legend>
|
||||||
<p class="muted">Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenstellen.</p>
|
<p class="muted">Du kannst eine Mahlzeitenidee frei benennen oder aus sichtbaren Lebensmitteln zusammensetzen. Nouri nutzt dabei später Grundtyp, Rolle und Tageszeit der Lebensmittel für ruhigere Vorschläge.</p>
|
||||||
|
{% if selected_components %}
|
||||||
|
<div class="selected-component-stack">
|
||||||
|
<p class="helper-text">Schon ausgewählt</p>
|
||||||
|
<div class="selected-components-grid">
|
||||||
|
{% for component in selected_components %}
|
||||||
|
<article class="selected-component-card">
|
||||||
|
<input type="hidden" name="component_ids" value="{{ component.id }}">
|
||||||
|
<div class="selected-component-main">
|
||||||
|
<strong>{{ component.name }}</strong>
|
||||||
|
<small>{{ component.base_type_label }} · {{ component.visibility_label }}</small>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button selected-component-remove" type="submit" name="remove_component_id" value="{{ component.id }}">
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="inline-form">
|
<div class="inline-form">
|
||||||
<label class="wide">
|
<label class="wide">
|
||||||
Lebensmittel suchen
|
Lebensmittel suchen
|
||||||
@@ -97,10 +234,12 @@
|
|||||||
placeholder="z. B. Reis, Banane, Joghurt"
|
placeholder="z. B. Reis, Banane, Joghurt"
|
||||||
data-filter-input
|
data-filter-input
|
||||||
data-filter-target="#meal-components-list"
|
data-filter-target="#meal-components-list"
|
||||||
|
data-filter-limit="3"
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
<button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button>
|
<button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button>
|
||||||
</div>
|
</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 %}
|
{% if food_groups %}
|
||||||
<div class="stack-sections" id="meal-components-list">
|
<div class="stack-sections" id="meal-components-list">
|
||||||
{% for group in food_groups %}
|
{% for group in food_groups %}
|
||||||
@@ -111,9 +250,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="checkbox-grid filterable-checkbox-group" data-filter-group>
|
<div class="checkbox-grid filterable-checkbox-group" data-filter-group>
|
||||||
{% for food in group["items"] %}
|
{% for food in group["items"] %}
|
||||||
<label class="check-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }}">
|
<label class="check-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }} {{ food.base_type_label|lower }} {{ food.suggestion_role_label|lower }}">
|
||||||
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
|
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
|
||||||
<span>{{ food.name }} · {{ food.visibility_label }} · {{ food.for_label }}</span>
|
<span>{{ food.name }} · {{ food.base_type_label }} · {{ food.visibility_label }}</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,14 +273,49 @@
|
|||||||
<input type="text" name="quick_food_name" value="{{ form_data.quick_food_name }}" placeholder="z. B. Hüttenkäse">
|
<input type="text" name="quick_food_name" value="{{ form_data.quick_food_name }}" placeholder="z. B. Hüttenkäse">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Kategorie
|
Baustein
|
||||||
<select name="quick_food_category">
|
<select name="quick_food_base_type">
|
||||||
<option value="">Ohne Kategorie</option>
|
{% for value, label in builder_options %}
|
||||||
{% for category in categories %}
|
<option value="{{ value }}" {% if form_data.quick_food_base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
<option value="{{ category }}" {% if form_data.quick_food_category == category %}selected{% endif %}>{{ category }}</option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Geschmacksrichtung
|
||||||
|
<select name="quick_food_flavor_profile">
|
||||||
|
{% for value, label in food_flavor_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.quick_food_flavor_profile == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Rolle in Vorschlägen
|
||||||
|
<select name="quick_food_role">
|
||||||
|
{% for value, label in food_role_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.quick_food_role == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Wird eher vorgeschlagen
|
||||||
|
<select name="quick_food_priority">
|
||||||
|
{% for value, label in suggestion_priority_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.quick_food_priority == value %}selected{% endif %}>{{ label }}</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="inline-check">
|
||||||
|
<input type="checkbox" name="quick_food_can_be_meal_core" value="1" {% if form_data.quick_food_can_be_meal_core %}checked{% endif %}>
|
||||||
|
<span>Kann gut eine Mahlzeit tragen</span>
|
||||||
|
</label>
|
||||||
<label class="wide">
|
<label class="wide">
|
||||||
Notiz
|
Notiz
|
||||||
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
|
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
|
||||||
|
|||||||
@@ -54,7 +54,12 @@
|
|||||||
<article class="item-card">
|
<article class="item-card">
|
||||||
<div class="item-media">
|
<div class="item-media">
|
||||||
{% if item.photo_filename %}
|
{% 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 %}
|
{% else %}
|
||||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -69,10 +74,25 @@
|
|||||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
<span class="chip">{{ item.for_label }}</span>
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">
|
{% if item.kind == 'food' %}
|
||||||
{% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %}
|
<div class="chip-row">
|
||||||
· {{ item_kind_labels[item.kind] }}
|
<span class="chip">{{ item.base_type_label }}</span>
|
||||||
</p>
|
<span class="chip">{{ item.suggestion_role_label }}</span>
|
||||||
|
<span class="chip">{{ item.suggestion_priority_label }}</span>
|
||||||
|
{% if item.can_be_meal_core %}
|
||||||
|
<span class="chip status-okay">Trägt gut eine Mahlzeit</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="muted">{{ item_kind_labels[item.kind] }}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.meal_type_label }}</span>
|
||||||
|
<span class="chip">{{ energy_density_labels[item.energy_density] }}</span>
|
||||||
|
{% for tag in item.meal_tag_labels %}
|
||||||
|
<span class="chip">{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if item.dayparts %}
|
{% if item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
{% for daypart in item.dayparts %}
|
{% for daypart in item.dayparts %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Tagesplan</p>
|
<p class="eyebrow">Tagesplan</p>
|
||||||
<h1>{{ weekday_name(selected_date) }}, {{ selected_date.strftime('%d.%m.%Y') }}</h1>
|
<h1>{{ weekday_name(selected_date) }}, {{ selected_date.strftime('%d.%m.%Y') }}</h1>
|
||||||
<p class="lead">Der Tagesplan bleibt bewusst ruhig. Jede Tageszeit ist eine eigene Kachel und öffnet sich erst, wenn du sie brauchst.</p>
|
<p class="lead">Jede Tageszeit hat ihren eigenen Platz und öffnet sich erst dann, wenn du etwas eintragen möchtest.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="week-nav">
|
<div class="week-nav">
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=previous_day.isoformat()) }}">Vorheriger Tag</a>
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=previous_day.isoformat()) }}">Vorheriger Tag</a>
|
||||||
@@ -54,24 +54,80 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="planner-day-stack">
|
<section class="planner-day-stack">
|
||||||
|
{% set hidden_snack_sections = sections | selectattr('is_snack_daypart') | rejectattr('visible_by_default') | list %}
|
||||||
|
{% if hidden_snack_sections %}
|
||||||
|
<section class="panel compact-form-panel snack-reveal-panel" data-day-snack-actions>
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Zwischenmahlzeit hinzufügen</h2>
|
||||||
|
</div>
|
||||||
|
<div class="chip-row snack-reveal-actions">
|
||||||
|
{% for section in hidden_snack_sections %}
|
||||||
|
<button
|
||||||
|
class="ghost-button snack-reveal-button"
|
||||||
|
type="button"
|
||||||
|
data-day-snack-open
|
||||||
|
data-target="#daypart-{{ section.daypart.id }}"
|
||||||
|
>
|
||||||
|
{{ section.daypart.name }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
<details class="day-tile" id="daypart-{{ section.daypart.id }}" {% if section.is_open %}open{% endif %}>
|
<details
|
||||||
|
class="day-tile{% if section.entries %} has-entries{% endif %}{% if section.selected_quick_action %} has-selection{% endif %}"
|
||||||
|
id="daypart-{{ section.daypart.id }}"
|
||||||
|
{% if section.is_snack_daypart and not section.visible_by_default %}hidden data-day-snack-tile{% endif %}
|
||||||
|
{% if section.is_open %}open{% endif %}
|
||||||
|
>
|
||||||
<summary class="day-tile-summary">
|
<summary class="day-tile-summary">
|
||||||
<div class="day-tile-summary-main">
|
<div class="day-tile-summary-main">
|
||||||
<div class="day-tile-icon"><span class="ui-icon icon-calendar"></span></div>
|
<div class="day-tile-icon"><span class="ui-icon {{ daypart_icon_class(section.daypart.slug) }}"></span></div>
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ section.daypart.name }}</h2>
|
<h2>{{ section.daypart.name }}</h2>
|
||||||
{% if section.summary_items %}
|
{% if section.summary_items %}
|
||||||
<p class="muted">{{ section.summary_items|join(', ') }}</p>
|
<p class="day-tile-summary-text">{{ section.summary_items|join(', ') }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">Noch frei. Öffnen, wenn du etwas ergänzen möchtest.</p>
|
<p class="muted">Noch offen. Öffnen, wenn du etwas eintragen möchtest.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="status-pill">{{ section.entries|length }} geplant</span>
|
<span class="status-pill{% if section.entries %} status-home{% endif %}">{{ section.entries|length }} geplant</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="day-tile-body">
|
<div class="day-tile-body">
|
||||||
|
{% if section.selected_quick_action %}
|
||||||
|
<div class="suggestion-card selected-quick-action">
|
||||||
|
<span class="status-pill status-home">Schon ausgewählt</span>
|
||||||
|
<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 %}
|
{% if section.balance_suggestion %}
|
||||||
<div class="suggestion-card">
|
<div class="suggestion-card">
|
||||||
<strong>{{ section.balance_suggestion.text }}</strong>
|
<strong>{{ section.balance_suggestion.text }}</strong>
|
||||||
@@ -121,20 +177,34 @@
|
|||||||
<h3>Passt gut dazu</h3>
|
<h3>Passt gut dazu</h3>
|
||||||
<div class="quick-add-row compact-quick-row">
|
<div class="quick-add-row compact-quick-row">
|
||||||
{% for suggestion in section.recipe_suggestions %}
|
{% for suggestion in section.recipe_suggestions %}
|
||||||
<form method="post" action="{{ url_for('main.planner_generated_meal') }}">
|
{% if suggestion.existing_item_id %}
|
||||||
{{ csrf_input() }}
|
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
|
||||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
{{ csrf_input() }}
|
||||||
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
<input type="hidden" name="meal_name" value="{{ suggestion.title }}">
|
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||||
<input type="hidden" name="visibility" value="shared">
|
<input type="hidden" name="item_id" value="{{ suggestion.existing_item_id }}">
|
||||||
{% for component_id in suggestion.component_ids %}
|
<input type="hidden" name="visibility" value="{{ suggestion.visibility or 'shared' }}">
|
||||||
<input type="hidden" name="component_ids" value="{{ component_id }}">
|
<button class="quick-add-button compact-button" type="submit">
|
||||||
{% endfor %}
|
<span>{{ suggestion.title }}</span>
|
||||||
<button class="quick-add-button compact-button" type="submit">
|
<small>{{ suggestion.reason }}</small>
|
||||||
<span>{{ suggestion.title }}</span>
|
</button>
|
||||||
<small>{{ suggestion.reason }}</small>
|
</form>
|
||||||
</button>
|
{% else %}
|
||||||
</form>
|
<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 %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,10 +213,10 @@
|
|||||||
<div class="planner-subsection">
|
<div class="planner-subsection">
|
||||||
<label class="planner-search">
|
<label class="planner-search">
|
||||||
<span>Suche</span>
|
<span>Suche</span>
|
||||||
<input type="text" placeholder="Lebensmittel oder Mahlzeiten suchen" data-filter-input data-filter-target="#planner-list-{{ section.daypart.id }}">
|
<input type="text" placeholder="Lebensmittel oder Mahlzeiten suchen" data-filter-input data-filter-target="#planner-list-{{ section.daypart.id }}" data-filter-limit="3">
|
||||||
</label>
|
</label>
|
||||||
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
|
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
|
||||||
{% for item in section.food_candidates %}
|
{% 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 }}">
|
<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() }}
|
{{ csrf_input() }}
|
||||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
@@ -155,7 +225,10 @@
|
|||||||
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||||
<button class="picker-row" type="submit">
|
<button class="picker-row" type="submit">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
{% if item.availability_state == 'home' %}<small>zuhause</small>{% endif %}
|
<small>
|
||||||
|
{{ item_kind_labels[item.kind] }}
|
||||||
|
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
||||||
|
</small>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -188,11 +261,45 @@
|
|||||||
{% if entry.note %}
|
{% if entry.note %}
|
||||||
<p>{{ entry.note }}</p>
|
<p>{{ entry.note }}</p>
|
||||||
{% endif %}
|
{% 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>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty-state">Hier ist noch nichts eingetragen. Ein kleiner Anfang reicht völlig.</p>
|
<p class="empty-state">Hier ist noch nichts eingetragen. Ein kleiner Anfang reicht völlig.</p>
|
||||||
|
{% if section.is_snack_daypart %}
|
||||||
|
<div class="row-actions snack-inline-actions">
|
||||||
|
<button
|
||||||
|
class="ghost-button"
|
||||||
|
type="button"
|
||||||
|
data-day-snack-hide
|
||||||
|
data-target="#daypart-{{ section.daypart.id }}"
|
||||||
|
>
|
||||||
|
Wieder ausblenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -4,12 +4,19 @@
|
|||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Wochenansicht</p>
|
<p class="eyebrow">Wochenansicht</p>
|
||||||
<h1>Ein ruhiger Blick auf die nächsten sieben Tage</h1>
|
<h1>Die nächsten sieben Tage auf einen Blick</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>
|
<p class="lead">Du kannst Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und sehen, was erst später für den Einkauf wichtig wird.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="week-nav">
|
<div class="week-nav">
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
|
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
|
||||||
<span>{{ week_start.strftime('%d.%m.%Y') }} bis {{ week_end.strftime('%d.%m.%Y') }}</span>
|
<span>{{ week_start.strftime('%d.%m.%Y') }} bis {{ week_end.strftime('%d.%m.%Y') }}</span>
|
||||||
|
<details class="export-menu">
|
||||||
|
<summary class="ghost-button export-menu-trigger">PDF exportieren</summary>
|
||||||
|
<div class="export-menu-panel">
|
||||||
|
<a href="{{ url_for('main.planner_export_pdf', week=week_start.isoformat(), mode='mine') }}">Meinen Essensplan</a>
|
||||||
|
<a href="{{ url_for('main.planner_export_pdf', week=week_start.isoformat(), mode='household') }}">Unseren Essensplan</a>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner', week=next_week.isoformat()) }}">Nächste Woche</a>
|
<a class="ghost-button" href="{{ url_for('main.planner', week=next_week.isoformat()) }}">Nächste Woche</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -80,36 +87,213 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if card.filled_dayparts %}
|
{% if not card.filled_dayparts %}
|
||||||
<p class="week-card-count">{{ card.planned_count }} Einträge</p>
|
<p class="empty-state week-card-empty-copy">Noch offen. Du kannst den Tag nach und nach füllen.</p>
|
||||||
<div class="chip-row">
|
{% endif %}
|
||||||
{% for slot in card.filled_dayparts %}
|
|
||||||
<span class="chip">{{ slot.name }} · {{ slot.count }}</span>
|
{% if card.hidden_snack_slots %}
|
||||||
{% endfor %}
|
<div class="week-card-snack-actions" data-week-snack-actions>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Snacks ergänzen</p>
|
||||||
|
</div>
|
||||||
|
<div class="chip-row snack-reveal-actions">
|
||||||
|
{% for hidden_slot in card.hidden_snack_slots %}
|
||||||
|
<button
|
||||||
|
class="ghost-button snack-reveal-button"
|
||||||
|
type="button"
|
||||||
|
data-week-snack-slot-open
|
||||||
|
data-target="#week-slot-{{ card.date.isoformat() }}-{{ hidden_slot.id }}"
|
||||||
|
>
|
||||||
|
{% if hidden_slot.name == 'Vormittagssnack' %}
|
||||||
|
Vormittag
|
||||||
|
{% elif hidden_slot.name == 'Nachmittagssnack' %}
|
||||||
|
Nachmittag
|
||||||
|
{% elif hidden_slot.name == 'Später Snack' %}
|
||||||
|
Abend
|
||||||
|
{% else %}
|
||||||
|
{{ hidden_slot.name }}
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">{{ card.preview_items | join(', ') }}</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-state">Noch offen. Du kannst den Tag ganz leicht nach und nach füllen.</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="week-slot-stack">
|
<div class="week-slot-stack">
|
||||||
{% for slot in card.slots %}
|
{% for slot in card.slots %}
|
||||||
<div class="week-slot drop-slot" data-target-date="{{ card.date.isoformat() }}" data-target-daypart-id="{{ slot.daypart.id }}">
|
<div
|
||||||
|
class="week-slot drop-slot{% if slot.entries %} has-entries{% endif %}{% if slot.is_snack_daypart %} week-slot-snack{% endif %}"
|
||||||
|
id="week-slot-{{ card.date.isoformat() }}-{{ slot.daypart.id }}"
|
||||||
|
data-target-date="{{ card.date.isoformat() }}"
|
||||||
|
data-target-daypart-id="{{ slot.daypart.id }}"
|
||||||
|
{% if slot.is_snack_daypart and not slot.visible_by_default %}hidden data-week-snack-slot{% endif %}
|
||||||
|
>
|
||||||
<div class="week-slot-head">
|
<div class="week-slot-head">
|
||||||
<strong>{{ slot.daypart.name }}</strong>
|
<div class="week-slot-title">
|
||||||
<span>{{ slot.entries|length }}</span>
|
<span class="ui-icon {{ daypart_icon_class(slot.daypart.slug) }}"></span>
|
||||||
|
<strong>{{ slot.daypart.name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="week-slot-head-meta">
|
||||||
|
<span class="week-slot-count{% if slot.entries %} status-home{% endif %}">{{ slot.entries|length }}</span>
|
||||||
|
<button class="week-slot-add" type="button" data-week-slot-picker-open aria-label="{{ slot.daypart.name }} an {{ weekday_name(card.date) }} direkt ergänzen">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="week-slot-picker" hidden>
|
||||||
|
<div class="week-slot-picker-head">
|
||||||
|
<strong>{{ slot.daypart.name }} ergänzen</strong>
|
||||||
|
<button class="ghost-button week-slot-picker-close" type="button" data-week-slot-picker-close>Schließen</button>
|
||||||
|
</div>
|
||||||
|
<label class="planner-search week-slot-picker-search">
|
||||||
|
<span>Suche</span>
|
||||||
|
<input type="text" placeholder="Mahlzeiten oder Ideen suchen" data-filter-input data-filter-target="#week-slot-picker-list-{{ card.date.isoformat() }}-{{ slot.daypart.id }}">
|
||||||
|
</label>
|
||||||
|
<div id="week-slot-picker-list-{{ card.date.isoformat() }}-{{ slot.daypart.id }}">
|
||||||
|
{% if slot.picker.meal_candidates %}
|
||||||
|
<div class="planner-subsection">
|
||||||
|
<h3>Mahlzeitenideen</h3>
|
||||||
|
<div class="quick-add-row compact-quick-row">
|
||||||
|
{% for item in slot.picker.meal_candidates %}
|
||||||
|
<form method="post" action="{{ url_for('main.planner_day', date=card.date.isoformat()) }}" class="js-week-slot-submit" data-filter-label="{{ item.name|lower }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="daypart_id" value="{{ slot.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 slot.picker.recipe_suggestions %}
|
||||||
|
<div class="planner-subsection">
|
||||||
|
<h3>Passt gut dazu</h3>
|
||||||
|
<div class="quick-add-row compact-quick-row">
|
||||||
|
{% for suggestion in slot.picker.recipe_suggestions %}
|
||||||
|
{% if suggestion.existing_item_id %}
|
||||||
|
<form method="post" action="{{ url_for('main.planner_day', date=card.date.isoformat()) }}" class="js-week-slot-submit" data-filter-label="{{ suggestion.title|lower }} {{ suggestion.reason|lower }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="daypart_id" value="{{ slot.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') }}" class="js-week-slot-submit" data-filter-label="{{ suggestion.title|lower }} {{ suggestion.reason|lower }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="daypart_id" value="{{ slot.daypart.id }}">
|
||||||
|
<input type="hidden" name="meal_name" value="{{ suggestion.title }}">
|
||||||
|
<input type="hidden" name="visibility" value="{{ suggestion.visibility or '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 %}
|
||||||
|
{% if not slot.picker.meal_candidates and not slot.picker.recipe_suggestions %}
|
||||||
|
<p class="empty-state">Hier ist gerade noch nichts vorbereitet. Im Tagesplan kannst du jederzeit etwas Neues anlegen.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if slot.entries %}
|
{% if slot.entries %}
|
||||||
<div class="week-entry-stack">
|
<div class="week-entry-stack">
|
||||||
{% for entry in slot.entries %}
|
{% for entry in slot.entries %}
|
||||||
<article class="plan-chip draggable-plan-entry" draggable="{{ 'true' if entry.can_edit else 'false' }}" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}">
|
<article
|
||||||
|
class="plan-chip draggable-plan-entry{% if entry.can_edit %} is-editable{% endif %}"
|
||||||
|
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) }}"
|
||||||
|
{% if entry.can_edit %}
|
||||||
|
data-week-entry-open
|
||||||
|
data-week-entry-dialog-id="week-entry-dialog-{{ entry.id }}"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="{{ entry.item_name }} bearbeiten"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
<strong>{{ entry.item_name }}</strong>
|
<strong>{{ entry.item_name }}</strong>
|
||||||
<small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
|
<small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
|
||||||
</article>
|
</article>
|
||||||
|
{% if entry.can_edit %}
|
||||||
|
<dialog class="week-entry-dialog" id="week-entry-dialog-{{ entry.id }}">
|
||||||
|
<div class="week-entry-dialog-card">
|
||||||
|
<div class="week-entry-dialog-head">
|
||||||
|
<div>
|
||||||
|
<h3>{{ entry.item_name }}</h3>
|
||||||
|
<p>{{ slot.daypart.name }} · {{ weekday_name(card.date) }}, {{ card.date.strftime('%d.%m.%Y') }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" type="button" data-week-entry-close>Schließen</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('main.planner_update', entry_id=entry.id) }}" class="planner-entry-inline-form js-week-entry-submit">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="return_week" value="{{ week_start.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>
|
||||||
|
<div class="week-entry-dialog-actions">
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id) }}" class="week-entry-remove-form js-week-entry-submit">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="return_week" value="{{ week_start.isoformat() }}">
|
||||||
|
<button class="ghost-button" type="submit">Eintrag entfernen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="week-slot-actions">
|
||||||
|
{% if slot.copy_allowed %}
|
||||||
|
<form method="post" action="{{ url_for('main.planner_slot_copy_forward') }}" class="js-copy-forward-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="source_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="daypart_id" value="{{ slot.daypart.id }}">
|
||||||
|
<button class="ghost-button week-slot-copy" type="submit">Zum nächsten Tag kopieren</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="week-slot-empty">Hierher ziehen</p>
|
<div class="week-slot-empty">
|
||||||
|
<p>Hierher ziehen</p>
|
||||||
|
{% if slot.is_snack_daypart %}
|
||||||
|
<button
|
||||||
|
class="ghost-button week-slot-hide"
|
||||||
|
type="button"
|
||||||
|
data-week-snack-slot-hide
|
||||||
|
data-target="#week-slot-{{ card.date.isoformat() }}-{{ slot.daypart.id }}"
|
||||||
|
>
|
||||||
|
Wieder ausblenden
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Optionen</p>
|
<p class="eyebrow">Optionen</p>
|
||||||
<h1>Ruhige Einstellungen für Alltag, Einkauf und Erinnerungen</h1>
|
<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 ob Nouri sich wie eine App auf dem Home-Bildschirm verhalten soll.</p>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -33,23 +33,29 @@
|
|||||||
Erinnerung ungefähr um
|
Erinnerung ungefähr um
|
||||||
<input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}">
|
<input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}">
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Speichern</button>
|
<div class="form-actions">
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Home-Bildschirm & Push</h2>
|
<h2>Für den Homescreen</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="stack-sections">
|
<div class="stack-sections">
|
||||||
<div class="pwa-card">
|
<div class="pwa-card">
|
||||||
<strong>Als Web-App nutzen</strong>
|
<strong>Auf dem iPhone installieren</strong>
|
||||||
<p class="muted">Auf dem iPhone kannst du Nouri über Teilen → Zum Home-Bildschirm hinzufügen. Danach wirkt die App deutlich app-näher.</p>
|
<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>
|
||||||
<div class="pwa-card">
|
<div class="pwa-card">
|
||||||
<strong>Push-Mitteilungen</strong>
|
<strong>Push-Mitteilungen</strong>
|
||||||
{% if push_ready %}
|
{% if push_ready %}
|
||||||
<p class="muted">Push ist vorbereitet. Du kannst es auf diesem Gerät freigeben und später testweise prüfen.</p>
|
<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">
|
<div class="row-actions">
|
||||||
<button class="secondary" type="button" data-push-enable>Push erlauben</button>
|
<button class="secondary" type="button" data-push-enable>Push erlauben</button>
|
||||||
<button class="ghost-button" type="button" data-push-disable>Push beenden</button>
|
<button class="ghost-button" type="button" data-push-disable>Push beenden</button>
|
||||||
@@ -61,7 +67,10 @@
|
|||||||
</form>
|
</form>
|
||||||
<small class="helper-text">{{ push_subscription_count }} aktives Gerät{% if push_subscription_count != 1 %}e{% endif %}</small>
|
<small class="helper-text">{{ push_subscription_count }} aktives Gerät{% if push_subscription_count != 1 %}e{% endif %}</small>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">Push wird sichtbar, sobald VAPID-Schlüssel für die App gesetzt sind.</p>
|
<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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,6 +98,26 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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>
|
||||||
|
<label>
|
||||||
|
Proteinquellen eher
|
||||||
|
<select name="protein_preference">
|
||||||
|
{% for value, label in protein_preference_options %}
|
||||||
|
<option value="{{ value }}" {% if user_settings.protein_preference == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="helper-text">Hilft dabei, Fleisch und Fisch bei Bedarf leiser mitzudenken, ohne sie ganz auszuschließen.</small>
|
||||||
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -103,12 +132,17 @@
|
|||||||
<legend>Planung</legend>
|
<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_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="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="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>
|
<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>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Alltag</legend>
|
<legend>Alltag</legend>
|
||||||
|
<label class="inline-check"><input type="checkbox" name="push_small_snack" value="1" {% if user_settings.push_small_snack %}checked{% endif %}><span>Am Nachmittag an etwas Kleines erinnern</span></label>
|
||||||
<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_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="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_templates" value="1" {% if user_settings.suggest_templates %}checked{% endif %}><span>Häufig genutzte Tages- und Wochenvorlagen vorschlagen</span></label>
|
||||||
@@ -122,4 +156,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
Flask==3.1.1
|
Flask==3.1.1
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
pywebpush==2.3.0
|
pywebpush==2.3.0
|
||||||
|
Pillow==11.2.1; python_version < "3.14"
|
||||||
|
fpdf2==2.8.3
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,14 +1,23 @@
|
|||||||
#!/bin/sh
|
#!/bin/bash
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
export NOURI_DATA_DIR="${NOURI_DATA_DIR:-/app/data}"
|
mkdir -p /app/data/uploads
|
||||||
mkdir -p "${NOURI_DATA_DIR}"
|
|
||||||
touch "${NOURI_DATA_DIR}/nouri.sqlite3"
|
|
||||||
mkdir -p "${NOURI_DATA_DIR}/uploads"
|
|
||||||
|
|
||||||
exec gunicorn \
|
# Vorhandene lokale SQLite-Datei beim allerersten Start übernehmen
|
||||||
--bind 0.0.0.0:8000 \
|
if [ ! -f /app/data/nouri.sqlite3 ] && [ -f /app/bootstrap-data/nouri.sqlite3 ]; then
|
||||||
--workers 2 \
|
cp /app/bootstrap-data/nouri.sqlite3 /app/data/nouri.sqlite3
|
||||||
--threads 4 \
|
fi
|
||||||
--timeout 60 \
|
|
||||||
wsgi:app
|
# 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
|
||||||
|
|||||||