Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aff40eff49 | |||
| 6b2c495cf2 | |||
| 8fc2492918 | |||
| 1034ea72a8 | |||
| d3c58c5dd2 | |||
| 43fdd7081c | |||
| 5e9beb1d98 | |||
| 06be1371d3 | |||
| 85c72879cb | |||
| 216dde1414 | |||
| 6f6269c66d | |||
| 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 | |||
| 96ab52e1ba | |||
| d8b56e6b67 | |||
| b68ed62887 | |||
| a4e7292930 | |||
| 9c164dc2e7 | |||
| 0231b28935 | |||
| a8b7eb09df | |||
| 31287813c8 | |||
| 24ebb26ffd | |||
| 36bde02c54 |
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
data
|
||||||
|
instance
|
||||||
@@ -9,3 +9,6 @@ __pycache__/
|
|||||||
|
|
||||||
data/
|
data/
|
||||||
instance/
|
instance/
|
||||||
|
.cloudron-push.env
|
||||||
|
.env.local
|
||||||
|
.env.push.local
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"id": "io.hnz.nouri",
|
||||||
|
"title": "Nouri",
|
||||||
|
"author": "Florian Heinz",
|
||||||
|
"description": "Private Flask app for meals, shopping and gentle food planning",
|
||||||
|
"tagline": "einfach essen planen",
|
||||||
|
"version": "1.3.3",
|
||||||
|
"upstreamVersion": "1.3.3",
|
||||||
|
"healthCheckPath": "/",
|
||||||
|
"httpPort": 8000,
|
||||||
|
"manifestVersion": 2,
|
||||||
|
"addons": {
|
||||||
|
"localstorage": {
|
||||||
|
"sqlite": {
|
||||||
|
"paths": [
|
||||||
|
"/app/data/nouri.sqlite3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app/code
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PORT=8000
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
sqlite3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN groupadd --gid 1000 cloudron \
|
||||||
|
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/cloudron cloudron
|
||||||
|
|
||||||
|
COPY requirements.txt /app/code/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||||
|
|
||||||
|
COPY . /app/code
|
||||||
|
|
||||||
|
# Lokale Daten für den ersten Start sichern und danach /app/code/data auf /app/data zeigen lassen
|
||||||
|
RUN if [ -d /app/code/data ]; then mv /app/code/data /app/bootstrap-data; else mkdir -p /app/bootstrap-data; fi \
|
||||||
|
&& ln -s /app/data /app/code/data \
|
||||||
|
&& chmod +x /app/code/start.sh \
|
||||||
|
&& chown -R cloudron:cloudron /app/code /app/bootstrap-data \
|
||||||
|
&& chown -h cloudron:cloudron /app/code/data
|
||||||
|
|
||||||
|
USER cloudron
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["/app/code/start.sh"]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
PolyForm Noncommercial License 1.0.0
|
||||||
|
|
||||||
|
Acceptance
|
||||||
|
|
||||||
|
In order to get any license under these terms, you must agree to them as
|
||||||
|
both strict obligations and conditions to all your licenses.
|
||||||
|
|
||||||
|
Copyright License
|
||||||
|
|
||||||
|
The licensor grants you a copyright license for the software to do everything
|
||||||
|
you might do with the software that would otherwise infringe the licensor's
|
||||||
|
copyright in it for any permitted purpose.
|
||||||
|
|
||||||
|
Limitations
|
||||||
|
|
||||||
|
You may use this software for private, personal, internal, or other
|
||||||
|
noncommercial purposes only.
|
||||||
|
|
||||||
|
Any use of this software or any derivative work for commercial advantage or
|
||||||
|
monetary compensation is not permitted under this license.
|
||||||
|
|
||||||
|
Distribution of modified or unmodified copies must include this license text
|
||||||
|
and all copyright notices.
|
||||||
|
|
||||||
|
Patent License
|
||||||
|
|
||||||
|
The licensor grants you a patent license for the software that covers patent
|
||||||
|
claims the licensor can license, or becomes able to license, that you would
|
||||||
|
otherwise infringe by using the software for any permitted purpose.
|
||||||
|
|
||||||
|
Notices
|
||||||
|
|
||||||
|
You must ensure that anyone who gets a copy of any part of this software from
|
||||||
|
you also gets a copy of these terms.
|
||||||
|
|
||||||
|
No Liability
|
||||||
|
|
||||||
|
As far as the law allows, the software comes as is, without any warranty or
|
||||||
|
condition, and the licensor will not be liable to you for any damages arising
|
||||||
|
out of these terms or the use or nature of the software, under any kind of
|
||||||
|
legal claim.
|
||||||
|
|
||||||
|
Definitions
|
||||||
|
|
||||||
|
The licensor is the person or entity offering these terms, and the software is
|
||||||
|
the software the licensor makes available under these terms, including any
|
||||||
|
portion of it.
|
||||||
|
|
||||||
|
Noncommercial means not primarily intended for or directed toward commercial
|
||||||
|
advantage or monetary compensation.
|
||||||
|
|
||||||
|
If you need a standardized reference text, see https://polyformproject.org/licenses/noncommercial/1.0.0/
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Push-Setup für Nouri
|
||||||
|
|
||||||
|
## 1. VAPID-Schlüssel erzeugen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
. .venv/bin/activate
|
||||||
|
python scripts/generate_vapid_keys.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Das Script gibt drei Zeilen aus:
|
||||||
|
|
||||||
|
- `NOURI_VAPID_PUBLIC_KEY`
|
||||||
|
- `NOURI_VAPID_PRIVATE_KEY`
|
||||||
|
- `NOURI_VAPID_SUBJECT`
|
||||||
|
|
||||||
|
## 2. In Cloudron eintragen
|
||||||
|
|
||||||
|
In der bestehenden Nouri-App unter `Settings` → `Environment Variables` diese drei Werte anlegen:
|
||||||
|
|
||||||
|
```text
|
||||||
|
NOURI_VAPID_PUBLIC_KEY=...
|
||||||
|
NOURI_VAPID_PRIVATE_KEY=...
|
||||||
|
NOURI_VAPID_SUBJECT=mailto:mail@hnz.io
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach die App neu starten.
|
||||||
|
|
||||||
|
## 3. Auf dem iPhone aktivieren
|
||||||
|
|
||||||
|
1. `nouri.heinz.media` in Safari öffnen
|
||||||
|
2. `Teilen` → `Zum Home-Bildschirm`
|
||||||
|
3. die installierte Web-App öffnen
|
||||||
|
4. in Nouri `Optionen` öffnen
|
||||||
|
5. `Push erlauben` tippen
|
||||||
|
6. danach optional `Test-Mitteilung senden`
|
||||||
|
|
||||||
|
## 4. Bereits vorbereitete lokale Datei
|
||||||
|
|
||||||
|
Wenn lokal bereits eine Datei `.cloudron-push.env` liegt, kannst du deren Werte direkt nach Cloudron übernehmen.
|
||||||
|
|
||||||
|
Die Datei ist absichtlich in `.gitignore`, damit keine geheimen Schlüssel committed werden.
|
||||||
@@ -1,16 +1,29 @@
|
|||||||
# Nouri
|
# Nouri
|
||||||
|
|
||||||
Nouri ist eine kleine private Flask-App fuer einen Haushalt, um Essensideen, Einkaeufe, vorhandene Lebensmittel und einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten.
|
Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten.
|
||||||
|
|
||||||
## Merkmale in Version 0.1
|
## Merkmale in Version 1.0.0
|
||||||
|
|
||||||
- Lebensmittel und Mahlzeitenideen anlegen
|
- Lebensmittel und Mahlzeitenideen anlegen
|
||||||
- Fotos lokal hochladen
|
- Fotos lokal hochladen
|
||||||
- Einkaufsliste mit Abhaken
|
- Einkaufsliste mit Abhaken
|
||||||
- "Zuhause" als sichtbarer Vorrat
|
- „Zuhause“ als sichtbarer Vorrat mit Tageszeit-Filtern
|
||||||
- Archiv zum spaeteren Wiederverwenden
|
- Archiv mit Suche und schneller Wiederaufnahme
|
||||||
- Tages- und Wochenplanung nach Tageszeiten
|
- Tagesplan mit schnellen Vorschlägen je Tageszeit
|
||||||
- einfache Benutzeranmeldung fuer einen Haushalt
|
- Wochenansicht für die nächsten 7 Tage
|
||||||
|
- einfache Suche und Filter für Lebensmittel und Mahlzeitenideen
|
||||||
|
- mehrere Haushaltsnutzer mit Rollen
|
||||||
|
- gemeinsame und persönliche Inhalte
|
||||||
|
- Profilseite und Passwortänderung
|
||||||
|
- kleine Admin-Verwaltung für Nutzer
|
||||||
|
- kompaktere mobile Navigation mit Bottom-Bar
|
||||||
|
- Tagesvorlagen und Wochenvorlagen
|
||||||
|
- kleine Pakete für wiederkehrende Einkaufs- oder Planungsbausteine
|
||||||
|
- ruhige Hinweise und Vorschläge aus Zuhause, Archiv und bisherigen Planungen
|
||||||
|
- globale Kategorien pro Haushalt
|
||||||
|
- „Für wen?“ direkt an Lebensmitteln und Mahlzeiten
|
||||||
|
- Einkaufsrhythmus mit geplantem Einkaufstag und später aktivierten Bedarfen
|
||||||
|
- PWA-Grundlage mit Web App Manifest, Service Worker und optionalem Web Push
|
||||||
|
|
||||||
## Lokal starten
|
## Lokal starten
|
||||||
|
|
||||||
@@ -21,18 +34,114 @@ pip install -r requirements.txt
|
|||||||
flask --app wsgi run --debug
|
flask --app wsgi run --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
Dann `http://127.0.0.1:5000` oeffnen und beim ersten Start einen ersten Haushalt-Benutzer unter `/setup` anlegen.
|
Dann `http://127.0.0.1:5000` öffnen und beim ersten Start einen ersten Haushalt-Benutzer unter `/auth/setup` anlegen.
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
Die App legt Daten standardmaessig unter `./data` ab.
|
Die App legt Daten standardmäßig unter `./data` ab.
|
||||||
|
|
||||||
Wichtige Umgebungsvariablen:
|
Wichtige Umgebungsvariablen:
|
||||||
|
|
||||||
- `NOURI_SECRET_KEY`: Session-Secret fuer Produktion
|
- `NOURI_SECRET_KEY`: Session-Secret für Produktion
|
||||||
- `NOURI_DATA_DIR`: Pfad fuer Datenbank und Uploads, z. B. `/app/data` auf Cloudron
|
- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, 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_PRIVATE_KEY`: privater VAPID-Schlüssel für Web Push
|
||||||
|
- `NOURI_VAPID_SUBJECT`: Kontaktangabe für Web Push, z. B. `mailto:mail@hnz.io`
|
||||||
|
|
||||||
## Cloudron-Hinweis
|
## Migration und Datenhaltung
|
||||||
|
|
||||||
Fuer Cloudron spaeter `NOURI_DATA_DIR=/app/data` setzen, damit Datenbank und Uploads persistent liegen.
|
Beim Start erweitert Nouri das SQLite-Schema pragmatisch direkt weiter. Vorhandene Daten bleiben dabei erhalten und werden weiterverwendet.
|
||||||
|
|
||||||
|
Wichtig für die Trennung zwischen lokal und Produktion:
|
||||||
|
|
||||||
|
- lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data`
|
||||||
|
- auf Cloudron nutzt Nouri `/app/data`
|
||||||
|
- `data/` ist nicht für Git oder das Paket gedacht
|
||||||
|
- produktive Daten unter `/app/data` bleiben bei Updates erhalten
|
||||||
|
|
||||||
|
## Cloudron
|
||||||
|
|
||||||
|
Nouri ist so vorbereitet, dass Code und persistente Daten sauber getrennt bleiben:
|
||||||
|
|
||||||
|
- Code liegt im Container unter `/app/code`
|
||||||
|
- persistente Daten liegen unter `/app/data`
|
||||||
|
- Datenbank und Uploads werden nicht aus dem lokalen `./data` nach Produktion übernommen
|
||||||
|
- Updates ersetzen den Code, aber nicht die produktiven Inhalte in `/app/data`
|
||||||
|
|
||||||
|
### Neu installieren
|
||||||
|
|
||||||
|
1. Paket oder Image bauen und nach Cloudron hochladen.
|
||||||
|
2. Die App einmal per Cloudron installieren.
|
||||||
|
3. Nach dem ersten Start Nouri öffnen.
|
||||||
|
4. Den ersten Haushalt-Zugang unter `/auth/setup` anlegen.
|
||||||
|
5. Danach Push, Erinnerungen und Einkaufstag in den Optionen einrichten.
|
||||||
|
|
||||||
|
### Bestehende Installation aktualisieren
|
||||||
|
|
||||||
|
Wenn Nouri bereits installiert ist, bitte **kein neues `cloudron install`** ausführen.
|
||||||
|
Stattdessen die bestehende App aktualisieren, zum Beispiel mit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cloudron update --no-backup --app <deine-app> --server <dein-server> --token <dein-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Dabei gilt:
|
||||||
|
|
||||||
|
- produktive Daten unter `/app/data` bleiben erhalten
|
||||||
|
- lokale Testdaten aus `./data` werden nicht mit hochgeladen
|
||||||
|
- die bestehende Installation läuft mit demselben persistenten Datenordner weiter
|
||||||
|
|
||||||
|
### Wichtige Cloudron-Variablen
|
||||||
|
|
||||||
|
Für eine saubere produktive Installation sind diese Werte sinnvoll:
|
||||||
|
|
||||||
|
- `NOURI_DATA_DIR=/app/data`
|
||||||
|
- `NOURI_SECURE_COOKIES=1`
|
||||||
|
- `NOURI_TIMEZONE=Europe/Berlin`
|
||||||
|
- `NOURI_SECRET_KEY=<eigenes-secret>`
|
||||||
|
- `NOURI_VAPID_PUBLIC_KEY=<public-key>`
|
||||||
|
- `NOURI_VAPID_PRIVATE_KEY=<private-key>`
|
||||||
|
- `NOURI_VAPID_SUBJECT=mailto:mail@hnz.io`
|
||||||
|
|
||||||
|
## Push einrichten
|
||||||
|
|
||||||
|
### 1. VAPID-Schlüssel erzeugen
|
||||||
|
|
||||||
|
Die Schritte dafür stehen kompakt in [PUSH_SETUP.md](PUSH_SETUP.md).
|
||||||
|
|
||||||
|
### 2. VAPID-Werte in Cloudron setzen
|
||||||
|
|
||||||
|
Zum Beispiel so:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cloudron env set --app <deine-app> \
|
||||||
|
NOURI_VAPID_PUBLIC_KEY='...' \
|
||||||
|
NOURI_VAPID_PRIVATE_KEY='...' \
|
||||||
|
NOURI_VAPID_SUBJECT='mailto:mail@hnz.io'
|
||||||
|
```
|
||||||
|
|
||||||
|
Danach die App neu starten:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cloudron restart --app <deine-app>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Push auf dem Gerät aktivieren
|
||||||
|
|
||||||
|
1. Nouri im Browser oder auf dem iPhone öffnen.
|
||||||
|
2. In `Optionen` auf **Push erlauben** tippen.
|
||||||
|
3. Optional eine Test-Mitteilung senden.
|
||||||
|
4. Auf dem iPhone Nouri am besten zusätzlich zum Home-Bildschirm hinzufügen.
|
||||||
|
|
||||||
|
Wichtig:
|
||||||
|
|
||||||
|
- Push funktioniert nur auf Geräten, die sich einmal aktiv registriert haben.
|
||||||
|
- Ohne VAPID-Werte bleibt Push bewusst deaktiviert.
|
||||||
|
- Die Browser- oder iPhone-Freigabe allein reicht nicht: Hinweise müssen zusätzlich in Nouri eingeschaltet sein.
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Nouri ist in diesem Repository für private, nicht-kommerzielle Nutzung freigegeben. Details stehen in [LICENSE.md](LICENSE.md).
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Nouri 0.5.0
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- Einkaufsrhythmus mit geplantem Einkaufstag und ruhiger Vorlauf-Logik
|
||||||
|
- Optionen für Erinnerungen, Hinweise und kleine Routinen
|
||||||
|
- Ausgewogene Ergänzungsvorschläge ohne Diät- oder Kontrollsprache
|
||||||
|
- Einfache Rezeptideen aus zuhause vorhandenen Lebensmitteln
|
||||||
|
- PWA-Vorbereitung mit Home-Screen-Nutzung, Service Worker und optionalem Web Push
|
||||||
|
- Überarbeitetes mobiles Mehr-Menü als Karten-Sheet mit Icons
|
||||||
|
|
||||||
|
## Neu in 0.5.0
|
||||||
|
|
||||||
|
### Planung und Vorschläge
|
||||||
|
|
||||||
|
- Der Tagesplan priorisiert jetzt vorhandene Mahlzeitenideen, dann passende Kombinationsvorschläge und danach einzelne Lebensmittel.
|
||||||
|
- Für Mittag- und Abendessen zeigt Nouri kleine Ergänzungsideen, wenn noch etwas gut dazupassen könnte.
|
||||||
|
- Zuhause vorhandene Lebensmittel werden zu einfachen Frühstücks- und Hauptmahlzeit-Ideen kombiniert.
|
||||||
|
|
||||||
|
### Einkauf
|
||||||
|
|
||||||
|
- Fehlende Lebensmittel aus zukünftigen Planungen landen nicht mehr sofort auf der Einkaufsliste.
|
||||||
|
- Stattdessen merkt Nouri sie zunächst für spätere Einkäufe vor und aktiviert sie passend zum eingestellten Einkaufstag.
|
||||||
|
- Auch bei Mahlzeiten werden nur die tatsächlich fehlenden Lebensmittel auf den Einkauf bezogen, nicht die Mahlzeit selbst.
|
||||||
|
|
||||||
|
### Einstellungen
|
||||||
|
|
||||||
|
- Neuer Bereich `Optionen` für Einkaufstag, Vorlauf, Hinweise, Routinen und Push.
|
||||||
|
- Hinweise lassen sich pro Person fein, aber weiterhin niedrigschwellig steuern.
|
||||||
|
- Push kann optional je Gerät aktiviert oder wieder beendet werden.
|
||||||
|
|
||||||
|
### Mobile und PWA
|
||||||
|
|
||||||
|
- Web App Manifest und Service Worker sind ergänzt.
|
||||||
|
- Nouri lässt sich besser auf dem iPhone zum Home-Bildschirm hinzufügen.
|
||||||
|
- Das mobile Mehr-Menü öffnet sich als kompaktes Karten-Sheet direkt über der Bottom Navigation.
|
||||||
|
|
||||||
|
### Haushalt und Kategorien
|
||||||
|
|
||||||
|
- Kategorien können jetzt zusätzlich einem ruhigen Baustein zugeordnet werden, zum Beispiel Proteinquelle, Gemüse oder Obst.
|
||||||
|
- Diese Zuordnung hilft nur intern bei Vorschlägen und fühlt sich nicht wie Tracking an.
|
||||||
|
|
||||||
|
## Technische Hinweise
|
||||||
|
|
||||||
|
- Neue SQLite-Tabellen und Spalten werden beim Start automatisch ergänzt.
|
||||||
|
- Für Web Push werden VAPID-Schlüssel über Umgebungsvariablen unterstützt.
|
||||||
|
- Cloudron-Version wurde auf `0.5.0` angehoben.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Nouri 0.5.1
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- Smartphone-Navigation unten neu als echte Erweiterung umgesetzt
|
||||||
|
- obere Nouri-Leiste auf kleinen Geräten nicht mehr sticky, sondern sauber fest positioniert
|
||||||
|
- PWA-Cache für frische Layout- und Einstellungsänderungen bereinigt
|
||||||
|
- Cloudron-Version auf `0.5.1` angehoben
|
||||||
|
|
||||||
|
## Neu in 0.5.1
|
||||||
|
|
||||||
|
### Mobile Navigation
|
||||||
|
|
||||||
|
- `Mehr` ist auf Smartphones kein schwebendes Overlay mehr.
|
||||||
|
- Die zusätzlichen Punkte klappen jetzt direkt aus der unteren Navigation heraus auf.
|
||||||
|
- Die Zusatzpunkte nutzen dieselbe kompakte Größe wie die unteren Menüpunkte.
|
||||||
|
- Der untere Navigationsbereich wird dabei nicht weichgezeichnet.
|
||||||
|
|
||||||
|
### Mobile Header
|
||||||
|
|
||||||
|
- Die obere Nouri-Leiste scrollt auf kleinen Geräten nicht mehr mit dem Inhalt.
|
||||||
|
- Die bisherige `sticky`-Logik für den Header wurde entfernt, damit es keine widersprüchlichen Zustände mehr gibt.
|
||||||
|
|
||||||
|
### PWA
|
||||||
|
|
||||||
|
- Der Service Worker verwendet einen aktualisierten Cache-Namen.
|
||||||
|
- Navigationsseiten werden frischer geladen, damit Änderungen an Einstellungen und Layout nicht an altem Cache hängen bleiben.
|
||||||
|
|
||||||
|
## Cloudron
|
||||||
|
|
||||||
|
- `CloudronManifest.json` wurde auf `0.5.1` angehoben.
|
||||||
|
- Damit lässt sich das Update sauber als neue App-Version ausrollen.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Nouri 0.6.0
|
||||||
|
|
||||||
|
Nouri 0.6.0 bringt die App näher an einen stabilen 1.0-Stand. Der Schwerpunkt liegt auf Reife, Alltagstauglichkeit und einem ruhigeren, verlässlicheren Gesamteindruck statt auf großen neuen Kernfunktionen.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
### Design und Brand
|
||||||
|
|
||||||
|
- Neues Nouri-App-Icon für Header, Favicon und PWA
|
||||||
|
- Überarbeitete PWA-Icons inklusive zusätzlicher Größen und maskierbarer Variante
|
||||||
|
- Ruhigeres visuelles Finish bei Karten, Fokuszuständen, Setup und leeren Bereichen
|
||||||
|
- Mobile Header-Logik bereinigt, damit der obere Bereich auf Smartphones nicht fest mitscrollt
|
||||||
|
- Desktop-Navigation wieder klarer als durchlaufende Leiste direkt neben dem Logo
|
||||||
|
|
||||||
|
### Bilder und Performance
|
||||||
|
|
||||||
|
- Bild-Uploads werden jetzt in mehrere sinnvolle Größen abgeleitet
|
||||||
|
- Listen und Formulare nutzen responsive Bildauslieferung statt immer die volle Originalgröße
|
||||||
|
- Statische Assets bekommen sauberes Cache-Busting über Versions-URLs
|
||||||
|
- Uploads und statische Marken-Assets werden vorsichtig browserfreundlich gecacht
|
||||||
|
|
||||||
|
### PWA und iPhone-Nutzung
|
||||||
|
|
||||||
|
- Überarbeitetes Web App Manifest
|
||||||
|
- Verbesserter Service Worker für eine stabilere App-Shell
|
||||||
|
- Kleine Offline-Seite für kurze Verbindungsabbrüche
|
||||||
|
- Bessere Einbindung für Homescreen-Nutzung auf iPhones
|
||||||
|
|
||||||
|
### Backup und Stabilität
|
||||||
|
|
||||||
|
- Admin-Bereich in den Optionen für Backup-Export und Restore
|
||||||
|
- Backup als ZIP mit App-Daten und Uploads
|
||||||
|
- Restore mit klarer Bestätigung statt versehentlicher Überschreibung
|
||||||
|
- Freundlichere Behandlung von zu großen Bild-Uploads
|
||||||
|
- Robusterer Umgang mit vorhandenen älteren Datenstrukturen
|
||||||
|
- Startet lokal jetzt auch ohne Pillow-Build auf Python 3.14, statt schon beim Import zu scheitern
|
||||||
|
|
||||||
|
### Planung und Vorschläge
|
||||||
|
|
||||||
|
- Automatische Mahlzeitenvorschläge robuster aufgebaut
|
||||||
|
- Bausteinlogik für Protein, Kohlenhydrate und Gemüse verbessert
|
||||||
|
- Bestehende Kombinationen werden beim Übernehmen besser wiederverwendet
|
||||||
|
- Suche beim Zusammenstellen von Mahlzeiten zeigt nur die drei passendsten Treffer
|
||||||
|
- Kategorie-Richtung von „Brot & Getreide“ in Richtung „Kohlenhydrate“ weitergeführt
|
||||||
|
|
||||||
|
## Technische Änderungen
|
||||||
|
|
||||||
|
- Neue Hilfsmodule für Bildverarbeitung und Backup/Restore
|
||||||
|
- App-Metadaten-Tabelle für robustere Schema-Verwaltung vorbereitet
|
||||||
|
- Cloudron-Version auf 0.6.0 angehoben
|
||||||
|
- Pillow für Bildverarbeitung ergänzt und auf Python 3.14 lokal als optionale Abhängigkeit abgefedert
|
||||||
|
|
||||||
|
## Hinweise zum Update
|
||||||
|
|
||||||
|
- Nach dem Update kann ein harter Reload im Browser sinnvoll sein, damit neue CSS-, JS- und PWA-Dateien sicher geladen werden.
|
||||||
|
- Vor einem Restore empfiehlt sich immer zuerst ein frischer Backup-Export aus der laufenden App.
|
||||||
|
- Vorhandene Kategorien und ältere Daten werden beim Start weiter normalisiert, statt hart ersetzt zu werden.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Nouri 1.0.0
|
||||||
|
|
||||||
|
Nouri 1.0.0 fasst die ruhige Alltagsplanung, gemeinsame Nutzung im Haushalt und die ersten echten Push-Erinnerungen in einen stabileren Release-Stand zusammen. Der Fokus dieser Version liegt auf Verlässlichkeit, klareren Vorschlägen und einem saubereren produktiven Betrieb auf Cloudron.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
- Vorschläge lassen sich jetzt gezielter über drei Modi steuern:
|
||||||
|
- `Eher ausgewogen`
|
||||||
|
- `Fitness`
|
||||||
|
- `Proteinbetont`
|
||||||
|
- Lebensmittel können zusätzlich als `Eher leicht`, `Neutral` oder `Eher gehaltvoll` markiert werden.
|
||||||
|
- Push-Erinnerungen für fehlendes Frühstück, Mittagessen und Abendessen sind vorbereitet und in den Optionen pro Nutzer schaltbar.
|
||||||
|
- Push öffnet direkt den passenden Tagesplan und kann eine schon vorbereitete Mahlzeitenidee mitbringen.
|
||||||
|
- Saaten sind in der Kategorien-Logik jetzt separat unter `Passt eher zu` auswählbar.
|
||||||
|
|
||||||
|
## Planung und Vorschläge
|
||||||
|
|
||||||
|
- Nouri bevorzugt bei Vorschlägen weiterhin zuerst vorhandene Mahlzeitenideen.
|
||||||
|
- Wenn noch keine passende Mahlzeitenidee da ist, werden Kombinationen aus den zuhause vorhandenen Lebensmitteln vorgeschlagen.
|
||||||
|
- Für `Fitness` werden proteinbetonte und eher leichte Kombinationen bevorzugt.
|
||||||
|
- Für `Proteinbetont` werden proteinreiche Kombinationen priorisiert, ohne zusätzlich auf „leicht“ zu ziehen.
|
||||||
|
- Im Tagesplan können vorbereitete Vorschläge direkter übernommen werden.
|
||||||
|
|
||||||
|
## Erinnerungen und Push
|
||||||
|
|
||||||
|
- Neue Push-Schalter in den Optionen:
|
||||||
|
- Frühstück um `08:00`
|
||||||
|
- Mittagessen um `12:00`
|
||||||
|
- Abendessen um `18:00`
|
||||||
|
- Die Push-Nachricht enthält nach Möglichkeit direkt einen passenden Vorschlag.
|
||||||
|
- Für den Versand wurde ein kleiner Reminder-Worker ergänzt.
|
||||||
|
- Push bleibt weiterhin optional und funktioniert nur auf Geräten, die sich aktiv registriert haben.
|
||||||
|
|
||||||
|
## Sicherheit und Stabilität
|
||||||
|
|
||||||
|
- Passwortprüfung wurde angezogen.
|
||||||
|
- Sichere Cookie-Konfiguration für HTTPS ist vorbereitet.
|
||||||
|
- Zusätzliche Sicherheitsheader werden gesetzt.
|
||||||
|
- Die Schema-Upgrades wurden weitergezogen, damit neue Einstellungen und Reminder-Daten sauber in bestehende Installationen einlaufen.
|
||||||
|
|
||||||
|
## Cloudron und Betrieb
|
||||||
|
|
||||||
|
- README wurde für Cloudron klarer dokumentiert:
|
||||||
|
- Neuinstallation
|
||||||
|
- Update statt Neuinstallation
|
||||||
|
- persistente Daten unter `/app/data`
|
||||||
|
- wichtige Umgebungsvariablen
|
||||||
|
- VAPID- und Push-Einrichtung
|
||||||
|
- Cloudron-Paketversion und App-Version stehen jetzt auf `1.0.0`.
|
||||||
|
- Der Service Worker nutzt einen neuen Cache-Namen für den Release-Stand `1.0.0`.
|
||||||
|
|
||||||
|
## Upgrade-Hinweis
|
||||||
|
|
||||||
|
- Bestehende SQLite-Daten bleiben erhalten.
|
||||||
|
- Neue Felder und Tabellen werden beim Start ergänzt.
|
||||||
|
- Nach dem Update lohnt sich ein kurzer Blick in `Optionen`, damit Vorschlagsstil, Push und Erinnerungen einmal bewusst gesetzt sind.
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Nouri 1.3.0
|
||||||
|
|
||||||
|
Nouri 1.3.0 ist ein größerer Alltags- und Pflege-Release auf Basis von 1.2.2. Der Schwerpunkt liegt auf klareren Lebensmittel-Zuständen, einer ruhigeren Einkaufsliste und Formularen, die beim Bearbeiten nicht mehr aus dem Arbeitsfluss reißen.
|
||||||
|
|
||||||
|
## Neu in 1.3.0
|
||||||
|
|
||||||
|
- Lebensmittel haben jetzt eine klarere Alltagslogik:
|
||||||
|
- `Zuhause` ist ein Bestandsstatus
|
||||||
|
- `Gerade nicht da` ist ein fehlender Bestand
|
||||||
|
- `Archiviert` bleibt eine bewusste Entscheidung
|
||||||
|
- `Unsortiert` ist ein neuer Zwischenstatus für schnelle Sammelerfassung
|
||||||
|
- Über `Schnell anlegen` lassen sich mehrere Lebensmittel auf einmal erfassen.
|
||||||
|
- Die Einträge landen zunächst in `Unsortiert`
|
||||||
|
- sie tauchen erst nach späterer Einordnung regulär im Alltag auf
|
||||||
|
- Die Lebensmittelkarten wurden deutlich vereinfacht:
|
||||||
|
- Bild oder passendes Icon
|
||||||
|
- Titel
|
||||||
|
- Bearbeitung über die ganze Kachel
|
||||||
|
- Archivieren über ein kleines `x`
|
||||||
|
- Zusatzinfos nur noch als ruhige Hover-Ebene
|
||||||
|
- Die Einkaufsliste wurde klarer und direkter:
|
||||||
|
- Suche nach Lebensmitteln statt Dropdown
|
||||||
|
- Einträge mit Bild oder passendem Icon
|
||||||
|
- Bearbeitung über Popup
|
||||||
|
- Archivierte und unsortierte Lebensmittel können ebenfalls über die Suche wieder auf die Einkaufsliste gesetzt werden
|
||||||
|
|
||||||
|
## Enthaltene Feinschliffe seit 1.2.2
|
||||||
|
|
||||||
|
- Die Lebensmittel- und Mahlzeitenlogik wurde weiter geschärft:
|
||||||
|
- Geschmacksrichtung `süß`, `herzhaft`, `neutral`
|
||||||
|
- bessere Filterung kulinarisch passender Vorschläge
|
||||||
|
- klarere Builder-Begriffe und ruhigere Formulare
|
||||||
|
- Auswahlbereiche für Tageszeiten, Mahlzeit-Charakter und Builder-Felder wurden weiter vereinheitlicht.
|
||||||
|
- Ausgewählte Zutaten in Mahlzeiten und Paketen sind sichtbarer und leichter bearbeitbar.
|
||||||
|
- Mehr Formulare unterstützen jetzt einen ruhigeren Bearbeitungsfluss:
|
||||||
|
- `Speichern`
|
||||||
|
- `Speichern und schließen`
|
||||||
|
- Scroll-Position bleibt beim Weiterbearbeiten erhalten
|
||||||
|
- Die Navigation und mehrere Detailansichten wurden weiter beruhigt und konsistenter gemacht.
|
||||||
|
|
||||||
|
## Technisch
|
||||||
|
|
||||||
|
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.0`.
|
||||||
|
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.0` angehoben.
|
||||||
|
- Die Datenbank kennt jetzt zusätzliche Zustände und Felder für die überarbeitete Lebensmittel-Logik, unter anderem für `Unsortiert`.
|
||||||
|
|
||||||
|
## Hinweis zum Update
|
||||||
|
|
||||||
|
- Beim Cloudron-Update sollte der `data`-Ordner weiterhin schreibbar sein, damit Schema-Änderungen sauber angewendet werden können.
|
||||||
|
- Für produktive Updates bleibt ein reguläres Cloudron-Backup vor dem Rollout die sichere Variante.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Nouri 1.3.1
|
||||||
|
|
||||||
|
Nouri 1.3.1 ist ein kleiner Stabilitäts- und Einkaufslisten-Release auf Basis von 1.3.0. Der Fokus liegt auf zuverlässigeren Backups, besseren Einkaufshinweisen und einem ruhigeren Dark Theme.
|
||||||
|
|
||||||
|
## Neu in 1.3.1
|
||||||
|
|
||||||
|
- Einkaufseinträge können jetzt einen kleinen Hinweis bekommen, zum Beispiel `TK`, `Dose` oder `frisch`.
|
||||||
|
- Derselbe Artikel kann mehrfach auf der Einkaufsliste stehen, wenn sich der Hinweis unterscheidet:
|
||||||
|
- `Erbsen · TK`
|
||||||
|
- `Erbsen · Dose`
|
||||||
|
- Hinweise werden klein auf den Einkaufskarten angezeigt.
|
||||||
|
- Einkaufshinweise lassen sich im Detaildialog eines Einkaufseintrags nachträglich bearbeiten.
|
||||||
|
- Die obere Einkaufssuche nutzt jetzt echte auswählbare Trefferkarten statt einer Browser-`datalist`, damit die Auswahl auch auf iOS zuverlässig funktioniert.
|
||||||
|
- Die Treffer werden erst während der Suche eingeblendet und liegen nicht mehr dauerhaft als lange Kartenliste unter dem Formular.
|
||||||
|
- Beim Anlegen und Bearbeiten von Mahlzeiten lassen sich Lebensmittel jetzt über eine schnelle Kachelsuche zusammenklicken.
|
||||||
|
- Ausgewählte Lebensmittel erscheinen sofort direkt unter dem Suchfeld.
|
||||||
|
- Nicht vorrätige Lebensmittel sind in der Mahlzeiten-Auswahl mit einem Einkaufswagen-Icon markiert.
|
||||||
|
|
||||||
|
## Stabilität und Darstellung
|
||||||
|
|
||||||
|
- Backup-Downloads werden jetzt erst nach dem vollständigen Response-Streaming aufgeräumt.
|
||||||
|
- Dadurch sollten heruntergeladene Backup-Zips nicht mehr mit inkonsistenten Zip-Größen abbrechen.
|
||||||
|
- Karten auf der Einkaufsliste und ähnliche schnelle Auswahlkarten haben im Dark Theme stabilere Kontraste.
|
||||||
|
- Die Mahlzeiten-Auswahl zeigt Zutaten jetzt mit Bild oder Baustein-Icon statt als reine Checkbox-Zeilen.
|
||||||
|
|
||||||
|
## Technisch
|
||||||
|
|
||||||
|
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.1`.
|
||||||
|
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.1` angehoben.
|
||||||
|
- Der Service Worker nutzt einen neuen Cache-Namen für `1.3.1`.
|
||||||
|
- Die Datenbank ergänzt `shopping_note` für Einkaufseinträge.
|
||||||
|
- Die offene Einkaufsliste ist jetzt pro Lebensmittel und Hinweis eindeutig, nicht mehr nur pro Lebensmittel.
|
||||||
|
|
||||||
|
## Hinweis zum Update
|
||||||
|
|
||||||
|
- Bestehende Einkaufslisteneinträge bleiben erhalten und bekommen automatisch einen leeren Hinweis.
|
||||||
|
- Bestehende SQLite-Daten werden beim Start um das neue Feld und den angepassten Index ergänzt.
|
||||||
|
- Wie immer empfiehlt sich vor produktiven Cloudron-Updates ein reguläres Backup.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Nouri 1.3.2
|
||||||
|
|
||||||
|
Nouri 1.3.2 ist ein kleiner Bedienungs-Release für mobile Einkaufssuche und schnelleres Zusammenklicken von Mahlzeiten.
|
||||||
|
|
||||||
|
## Neu in 1.3.2
|
||||||
|
|
||||||
|
- Die Einkaufssuche nutzt jetzt echte auswählbare Trefferkarten statt einer Browser-`datalist`.
|
||||||
|
- Damit lassen sich Lebensmittel auf iOS direkt nach der Suche antippen und auf die Einkaufsliste setzen.
|
||||||
|
- Beim Anlegen und Bearbeiten von Mahlzeiten werden Lebensmittel als Kacheln angezeigt.
|
||||||
|
- Ausgewählte Bestandteile erscheinen sofort direkt unter dem Suchfeld.
|
||||||
|
- Nicht vorrätige Lebensmittel werden ebenfalls angezeigt und mit einem Einkaufswagen-Icon markiert.
|
||||||
|
- Ein Einkaufswagen-Icon wurde aus `heinz.marketing` in die Nouri-Icons übernommen.
|
||||||
|
|
||||||
|
## Technisch
|
||||||
|
|
||||||
|
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.2`.
|
||||||
|
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.2` angehoben.
|
||||||
|
- Der Service Worker nutzt einen neuen Cache-Namen für `1.3.2`.
|
||||||
|
|
||||||
|
## Hinweis zum Update
|
||||||
|
|
||||||
|
- Es ist keine manuelle Datenmigration nötig.
|
||||||
|
- Bestehende Daten bleiben erhalten.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Nouri 1.3.3
|
||||||
|
|
||||||
|
Nouri 1.3.3 erweitert die Einkaufsliste um freie Einkaufsartikel, ohne die bestehende Lebensmittel- und Rezeptlogik umzubauen. Der Fokus liegt darauf, Alltagsdinge wie Drogerie, Haushalt oder Garten genauso schnell auf die Liste setzen zu können wie Lebensmittel.
|
||||||
|
|
||||||
|
## Neu in 1.3.3
|
||||||
|
|
||||||
|
- Die Einkaufssuche kann jetzt Lebensmittel und allgemeine Artikel finden.
|
||||||
|
- Neue Artikel können direkt aus dem Suchbegriff angelegt werden:
|
||||||
|
- `Als Lebensmittel anlegen`
|
||||||
|
- `Als Einkaufsartikel anlegen`
|
||||||
|
- Einkaufsartikel wie `Blumenerde`, `Deo`, `Insektenschutz` oder `Sonnencreme` werden intern gespeichert.
|
||||||
|
- Reine Einkaufsartikel bleiben aus Mahlzeiten, Rezeptvorschlägen und Lebensmittel-Details heraus.
|
||||||
|
- Nicht vorhandene Lebensmittel können aus der Einkaufsliste heraus schnell als unsortiertes Lebensmittel angelegt werden.
|
||||||
|
|
||||||
|
## Einkaufsliste
|
||||||
|
|
||||||
|
- Bereits angelegte Einkaufsartikel erscheinen bei späteren Suchen wieder als Treffer.
|
||||||
|
- Einkaufshinweise wie `TK`, `Dose`, `frisch` oder andere kurze Notizen funktionieren weiterhin.
|
||||||
|
- Derselbe Artikel kann mit unterschiedlichen Einkaufshinweisen mehrfach auf der Liste stehen.
|
||||||
|
- Einkaufsartikel werden auf der Liste als `Einkaufsartikel` markiert und nutzen ein Einkaufswagen-Symbol.
|
||||||
|
- Beim Abhaken eines Einkaufsartikels wird er als eingekauft markiert, ohne ihn als zuhause vorhandenes Lebensmittel zu behandeln.
|
||||||
|
|
||||||
|
## Daten und Migration
|
||||||
|
|
||||||
|
- Das Items-Schema unterstützt jetzt zusätzlich den internen Typ `shopping`.
|
||||||
|
- Bestehende Datenbanken werden beim Start migriert, damit der neue Typ auch bei Updates funktioniert.
|
||||||
|
- Der Index für Items nach Typ und Name wird bei Schema-Upgrades sauber wieder angelegt.
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.3`.
|
||||||
|
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.3` angehoben.
|
||||||
|
- Der Service Worker nutzt einen neuen Cache-Namen für `1.3.3`.
|
||||||
|
|
||||||
|
## Upgrade-Hinweis
|
||||||
|
|
||||||
|
- Bestehende Lebensmittel, Mahlzeitenideen und Einkaufseinträge bleiben erhalten.
|
||||||
|
- Nach dem Update können freie Einkaufsartikel direkt unter `Einkauf` über das Suchfeld angelegt werden.
|
||||||
@@ -1,52 +1,244 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, send_from_directory
|
from flask import Flask, flash, g, redirect, request, send_from_directory, url_for
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
|
from .admin import admin_bp
|
||||||
from .auth import auth_bp
|
from .auth import auth_bp
|
||||||
from .constants import CATEGORIES, DAYPARTS, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS
|
from .constants import (
|
||||||
|
BUILDER_DESCRIPTIONS,
|
||||||
|
BUILDER_LABELS,
|
||||||
|
BUILDER_OPTIONS,
|
||||||
|
DAYPARTS,
|
||||||
|
DEFAULT_CATEGORIES,
|
||||||
|
ENERGY_DENSITY_LABELS,
|
||||||
|
ENERGY_DENSITY_OPTIONS,
|
||||||
|
FOOD_ROLE_DESCRIPTIONS,
|
||||||
|
FOOD_ROLE_LABELS,
|
||||||
|
FOOD_ROLE_OPTIONS,
|
||||||
|
ITEM_KIND_LABELS,
|
||||||
|
ITEM_KIND_SINGULAR_LABELS,
|
||||||
|
MEAL_STYLE_LABELS,
|
||||||
|
MEAL_STYLE_OPTIONS,
|
||||||
|
MEAL_TYPE_LABELS,
|
||||||
|
MEAL_TYPE_OPTIONS,
|
||||||
|
NOTIFICATION_CHANNEL_OPTIONS,
|
||||||
|
PROTEIN_PREFERENCE_LABELS,
|
||||||
|
PROTEIN_PREFERENCE_OPTIONS,
|
||||||
|
ROLE_LABELS,
|
||||||
|
SUGGESTION_PRIORITY_LABELS,
|
||||||
|
SUGGESTION_PRIORITY_OPTIONS,
|
||||||
|
SUGGESTION_STYLE_LABELS,
|
||||||
|
SUGGESTION_STYLE_OPTIONS,
|
||||||
|
VISIBILITY_DESCRIPTIONS,
|
||||||
|
VISIBILITY_LABELS,
|
||||||
|
WEEKDAY_OPTIONS,
|
||||||
|
)
|
||||||
|
from .images import ensure_upload_structure, image_sizes, image_srcset, image_url
|
||||||
from .main import main_bp
|
from .main import main_bp
|
||||||
|
|
||||||
|
|
||||||
|
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||||
|
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||||
|
DEFAULT_RELEASE_URL = "https://git.hnz.io/hnzio/nouri-App/releases"
|
||||||
|
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:
|
||||||
|
env_secret = os.environ.get("NOURI_SECRET_KEY")
|
||||||
|
if env_secret:
|
||||||
|
return env_secret
|
||||||
|
|
||||||
|
secret_path = data_dir / ".secret_key"
|
||||||
|
if secret_path.exists():
|
||||||
|
return secret_path.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
secret_value = secrets.token_hex(24)
|
||||||
|
try:
|
||||||
|
with secret_path.open("x", encoding="utf-8") as handle:
|
||||||
|
handle.write(secret_value)
|
||||||
|
except FileExistsError:
|
||||||
|
return secret_path.read_text(encoding="utf-8").strip()
|
||||||
|
return secret_value
|
||||||
|
|
||||||
|
|
||||||
|
def load_app_version(root_dir: Path) -> str:
|
||||||
|
env_version = os.environ.get("NOURI_APP_VERSION", "").strip()
|
||||||
|
if env_version:
|
||||||
|
return env_version
|
||||||
|
|
||||||
|
manifest_path = root_dir / "CloudronManifest.json"
|
||||||
|
if manifest_path.exists():
|
||||||
|
try:
|
||||||
|
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
manifest_data = {}
|
||||||
|
manifest_version = str(
|
||||||
|
manifest_data.get("upstreamVersion")
|
||||||
|
or manifest_data.get("version")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if manifest_version:
|
||||||
|
return manifest_version
|
||||||
|
return "1.3.3"
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
SECRET_KEY=os.environ.get("NOURI_SECRET_KEY", secrets.token_hex(24)),
|
SECRET_KEY=load_secret_key(data_dir),
|
||||||
DATABASE_PATH=str(db_path),
|
DATABASE_PATH=str(db_path),
|
||||||
DATA_DIR=str(data_dir),
|
DATA_DIR=str(data_dir),
|
||||||
UPLOAD_FOLDER=str(upload_dir),
|
UPLOAD_FOLDER=str(upload_dir),
|
||||||
MAX_CONTENT_LENGTH=int(os.environ.get("NOURI_MAX_UPLOAD_MB", "5")) * 1024 * 1024,
|
MAX_CONTENT_LENGTH=int(os.environ.get("NOURI_MAX_UPLOAD_MB", "5")) * 1024 * 1024,
|
||||||
|
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
|
SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1",
|
||||||
|
APP_VERSION=app_version,
|
||||||
|
RELEASE_URL=release_url,
|
||||||
|
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
|
||||||
|
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
|
||||||
|
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
|
||||||
|
VAPID_SUBJECT=os.environ.get("NOURI_VAPID_SUBJECT", "mailto:mail@hnz.io"),
|
||||||
)
|
)
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
db.init_db_if_needed(app)
|
db.init_db_if_needed(app)
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
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,
|
||||||
"category_suggestions": CATEGORIES,
|
"category_suggestions": DEFAULT_CATEGORIES,
|
||||||
|
"builder_labels": BUILDER_LABELS,
|
||||||
|
"builder_descriptions": BUILDER_DESCRIPTIONS,
|
||||||
|
"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_descriptions": VISIBILITY_DESCRIPTIONS,
|
||||||
|
"role_labels": ROLE_LABELS,
|
||||||
|
"weekday_options": WEEKDAY_OPTIONS,
|
||||||
|
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
|
||||||
|
"today": date.today(),
|
||||||
|
"app_version": app.config["APP_VERSION"],
|
||||||
|
"app_release_url": app.config["RELEASE_URL"],
|
||||||
|
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
|
||||||
|
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
|
||||||
|
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
||||||
|
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
|
||||||
|
"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",
|
||||||
|
"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")
|
||||||
|
def webmanifest():
|
||||||
|
response = send_from_directory(
|
||||||
|
root_dir / "nouri" / "static" / "pwa",
|
||||||
|
"app.webmanifest",
|
||||||
|
mimetype="application/manifest+json",
|
||||||
|
max_age=60 * 30,
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "public, max-age=1800"
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.get("/service-worker.js")
|
||||||
|
def service_worker():
|
||||||
|
response = send_from_directory(
|
||||||
|
root_dir / "nouri" / "static" / "pwa",
|
||||||
|
"service-worker.js",
|
||||||
|
mimetype="application/javascript",
|
||||||
|
max_age=0,
|
||||||
|
)
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
def apply_cache_policy(response):
|
||||||
|
if response.direct_passthrough:
|
||||||
|
return response
|
||||||
|
content_type = response.headers.get("Content-Type", "")
|
||||||
|
if content_type.startswith("text/html"):
|
||||||
|
response.headers.setdefault("Cache-Control", "no-store, max-age=0")
|
||||||
|
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||||
|
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||||||
|
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.errorhandler(413)
|
||||||
|
def upload_too_large(_error):
|
||||||
|
flash("Das hochgeladene Bild ist etwas zu groß. Eine kleinere Datei passt hier besser.", "error")
|
||||||
|
return redirect(request.referrer or url_for("main.dashboard"))
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
from .auth import admin_required, can_remove_last_admin, url_with_scroll_position, validate_admin_user_form, wants_to_stay_on_form
|
||||||
|
from .constants import BUILDER_DESCRIPTIONS, BUILDER_OPTIONS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS, ROLE_LABELS
|
||||||
|
from .db import get_db
|
||||||
|
|
||||||
|
|
||||||
|
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
|
|
||||||
|
def get_household_user(user_id: int):
|
||||||
|
user = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT users.*, households.name AS household_name
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN households ON households.id = users.household_id
|
||||||
|
WHERE users.id = ? AND users.household_id = ?
|
||||||
|
""",
|
||||||
|
(user_id, g.user["household_id"]),
|
||||||
|
).fetchone()
|
||||||
|
if user is None:
|
||||||
|
raise ValueError("Der Nutzer wurde nicht gefunden.")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_household_categories():
|
||||||
|
return get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM household_categories
|
||||||
|
WHERE household_id = ?
|
||||||
|
ORDER BY is_active DESC, sort_order, LOWER(name)
|
||||||
|
""",
|
||||||
|
(g.user["household_id"],),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.get("/users")
|
||||||
|
@admin_required
|
||||||
|
def user_list():
|
||||||
|
users = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM users
|
||||||
|
WHERE household_id = ?
|
||||||
|
ORDER BY is_active DESC, LOWER(COALESCE(display_name, username))
|
||||||
|
""",
|
||||||
|
(g.user["household_id"],),
|
||||||
|
).fetchall()
|
||||||
|
return render_template("admin/users_list.html", users=users, role_labels=ROLE_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/users/new", methods=("GET", "POST"))
|
||||||
|
@admin_required
|
||||||
|
def user_create():
|
||||||
|
form_data = {
|
||||||
|
"display_name": "",
|
||||||
|
"username": "",
|
||||||
|
"email": "",
|
||||||
|
"role": "member",
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
database = get_db()
|
||||||
|
form_data = {
|
||||||
|
"display_name": request.form.get("display_name", "").strip(),
|
||||||
|
"username": request.form.get("username", "").strip().lower(),
|
||||||
|
"email": request.form.get("email", "").strip().lower(),
|
||||||
|
"role": request.form.get("role", "member").strip(),
|
||||||
|
"is_active": request.form.get("is_active", "1") == "1",
|
||||||
|
}
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
password_repeat = request.form.get("password_repeat", "")
|
||||||
|
|
||||||
|
error = validate_admin_user_form(
|
||||||
|
database,
|
||||||
|
username=form_data["username"],
|
||||||
|
email=form_data["email"] or None,
|
||||||
|
role=form_data["role"],
|
||||||
|
is_active=form_data["is_active"],
|
||||||
|
password=password,
|
||||||
|
password_repeat=password_repeat,
|
||||||
|
)
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (household_id, username, email, display_name, role, is_active, password_hash)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
g.user["household_id"],
|
||||||
|
form_data["username"],
|
||||||
|
form_data["email"] or None,
|
||||||
|
form_data["display_name"],
|
||||||
|
form_data["role"],
|
||||||
|
1 if form_data["is_active"] else 0,
|
||||||
|
generate_password_hash(password),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
database.commit()
|
||||||
|
flash("Der Nutzer wurde angelegt.", "success")
|
||||||
|
if wants_to_stay_on_form():
|
||||||
|
new_user = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
WHERE household_id = ? AND username = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(g.user["household_id"], form_data["username"]),
|
||||||
|
).fetchone()
|
||||||
|
if new_user is not None:
|
||||||
|
return redirect(url_with_scroll_position(url_for("admin.user_edit", user_id=int(new_user["id"]))))
|
||||||
|
return redirect(url_for("admin.user_list"))
|
||||||
|
|
||||||
|
flash(error, "error")
|
||||||
|
|
||||||
|
return render_template("admin/user_form.html", user=None, form_data=form_data, role_labels=ROLE_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/users/<int:user_id>/edit", methods=("GET", "POST"))
|
||||||
|
@admin_required
|
||||||
|
def user_edit(user_id: int):
|
||||||
|
try:
|
||||||
|
user = get_household_user(user_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(str(exc), "error")
|
||||||
|
return redirect(url_for("admin.user_list"))
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
"display_name": user["display_name"] or "",
|
||||||
|
"username": user["username"],
|
||||||
|
"email": user["email"] or "",
|
||||||
|
"role": user["role"],
|
||||||
|
"is_active": bool(user["is_active"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
database = get_db()
|
||||||
|
form_data = {
|
||||||
|
"display_name": request.form.get("display_name", "").strip(),
|
||||||
|
"username": request.form.get("username", "").strip().lower(),
|
||||||
|
"email": request.form.get("email", "").strip().lower(),
|
||||||
|
"role": request.form.get("role", "member").strip(),
|
||||||
|
"is_active": request.form.get("is_active", "0") == "1",
|
||||||
|
}
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
password_repeat = request.form.get("password_repeat", "")
|
||||||
|
|
||||||
|
error = validate_admin_user_form(
|
||||||
|
database,
|
||||||
|
username=form_data["username"],
|
||||||
|
email=form_data["email"] or None,
|
||||||
|
role=form_data["role"],
|
||||||
|
is_active=form_data["is_active"],
|
||||||
|
password=password,
|
||||||
|
password_repeat=password_repeat,
|
||||||
|
current_user_id=user_id,
|
||||||
|
)
|
||||||
|
if error is None and can_remove_last_admin(user_id, form_data["role"], form_data["is_active"]):
|
||||||
|
error = "Mindestens ein aktiver Admin sollte im Haushalt bleiben."
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET username = ?,
|
||||||
|
email = ?,
|
||||||
|
display_name = ?,
|
||||||
|
role = ?,
|
||||||
|
is_active = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
form_data["username"],
|
||||||
|
form_data["email"] or None,
|
||||||
|
form_data["display_name"],
|
||||||
|
form_data["role"],
|
||||||
|
1 if form_data["is_active"] else 0,
|
||||||
|
user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if password:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(generate_password_hash(password), user_id),
|
||||||
|
)
|
||||||
|
database.commit()
|
||||||
|
flash("Der Nutzer wurde aktualisiert.", "success")
|
||||||
|
if wants_to_stay_on_form():
|
||||||
|
return redirect(url_with_scroll_position(url_for("admin.user_edit", user_id=user_id)))
|
||||||
|
return redirect(url_for("admin.user_list"))
|
||||||
|
|
||||||
|
flash(error, "error")
|
||||||
|
|
||||||
|
return render_template("admin/user_form.html", user=user, form_data=form_data, role_labels=ROLE_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/categories", methods=("GET", "POST"))
|
||||||
|
@admin_required
|
||||||
|
def category_settings():
|
||||||
|
if request.method == "POST":
|
||||||
|
name = request.form.get("name", "").strip()
|
||||||
|
builder_key = request.form.get("builder_key", "neutral").strip()
|
||||||
|
if not name:
|
||||||
|
flash("Bitte einen Kategorienamen eintragen.", "error")
|
||||||
|
else:
|
||||||
|
existing = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM household_categories
|
||||||
|
WHERE household_id = ? AND LOWER(name) = LOWER(?)
|
||||||
|
""",
|
||||||
|
(g.user["household_id"], name),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
get_db().execute(
|
||||||
|
"UPDATE household_categories SET is_active = 1, builder_key = ? WHERE id = ?",
|
||||||
|
(builder_key, existing["id"]),
|
||||||
|
)
|
||||||
|
flash("Die Kategorie ist wieder aktiv.", "success")
|
||||||
|
else:
|
||||||
|
sort_row = get_db().execute(
|
||||||
|
"SELECT COALESCE(MAX(sort_order), 0) AS max_sort FROM household_categories WHERE household_id = ?",
|
||||||
|
(g.user["household_id"],),
|
||||||
|
).fetchone()
|
||||||
|
get_db().execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO household_categories (household_id, name, builder_key, sort_order, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, 1)
|
||||||
|
""",
|
||||||
|
(g.user["household_id"], name, builder_key, int(sort_row["max_sort"]) + 10),
|
||||||
|
)
|
||||||
|
flash("Die Kategorie wurde ergänzt.", "success")
|
||||||
|
get_db().commit()
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/categories.html",
|
||||||
|
categories=fetch_household_categories(),
|
||||||
|
default_categories=DEFAULT_CATEGORIES,
|
||||||
|
default_category_builders=DEFAULT_CATEGORY_BUILDERS,
|
||||||
|
builder_options=BUILDER_OPTIONS,
|
||||||
|
builder_descriptions=BUILDER_DESCRIPTIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.post("/categories/<int:category_id>/toggle")
|
||||||
|
@admin_required
|
||||||
|
def category_toggle(category_id: int):
|
||||||
|
category = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM household_categories
|
||||||
|
WHERE id = ? AND household_id = ?
|
||||||
|
""",
|
||||||
|
(category_id, g.user["household_id"]),
|
||||||
|
).fetchone()
|
||||||
|
if category is None:
|
||||||
|
flash("Die Kategorie wurde nicht gefunden.", "error")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
new_state = 0 if category["is_active"] else 1
|
||||||
|
get_db().execute(
|
||||||
|
"UPDATE household_categories SET is_active = ? WHERE id = ?",
|
||||||
|
(new_state, category_id),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
flash("Die Kategorie wurde aktualisiert.", "success")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.post("/categories/<int:category_id>/update")
|
||||||
|
@admin_required
|
||||||
|
def category_update(category_id: int):
|
||||||
|
category = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM household_categories
|
||||||
|
WHERE id = ? AND household_id = ?
|
||||||
|
""",
|
||||||
|
(category_id, g.user["household_id"]),
|
||||||
|
).fetchone()
|
||||||
|
if category is None:
|
||||||
|
flash("Die Kategorie wurde nicht gefunden.", "error")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
builder_key = request.form.get("builder_key", "neutral").strip()
|
||||||
|
get_db().execute(
|
||||||
|
"UPDATE household_categories SET builder_key = ? WHERE id = ?",
|
||||||
|
(builder_key, category_id),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
flash("Die Zuordnung wurde aktualisiert.", "success")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.post("/categories/<int:category_id>/delete")
|
||||||
|
@admin_required
|
||||||
|
def category_delete(category_id: int):
|
||||||
|
category = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM household_categories
|
||||||
|
WHERE id = ? AND household_id = ?
|
||||||
|
""",
|
||||||
|
(category_id, g.user["household_id"]),
|
||||||
|
).fetchone()
|
||||||
|
if category is None:
|
||||||
|
flash("Die Kategorie wurde nicht gefunden.", "error")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
if category["name"] in DEFAULT_CATEGORIES:
|
||||||
|
flash("Standardkategorien bleiben erhalten. Du kannst sie bei Bedarf pausieren.", "info")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
get_db().execute(
|
||||||
|
"DELETE FROM household_categories WHERE id = ? AND household_id = ?",
|
||||||
|
(category_id, g.user["household_id"]),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
flash("Die Kategorie wurde entfernt.", "success")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
import secrets
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -15,7 +17,8 @@ from flask import (
|
|||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
from .db import get_db, user_count
|
from .constants import ROLE_LABELS
|
||||||
|
from .db import active_admin_count, ensure_default_household, get_db, user_count
|
||||||
|
|
||||||
|
|
||||||
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
@@ -23,10 +26,23 @@ auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
|||||||
|
|
||||||
def login_required(view):
|
def login_required(view):
|
||||||
@functools.wraps(view)
|
@functools.wraps(view)
|
||||||
def wrapped_view(**kwargs):
|
def wrapped_view(*args, **kwargs):
|
||||||
if g.user is None:
|
if g.user is None:
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
return view(**kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(view):
|
||||||
|
@functools.wraps(view)
|
||||||
|
def wrapped_view(*args, **kwargs):
|
||||||
|
if g.user is None:
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
if g.user["role"] != "admin":
|
||||||
|
flash("Dieser Bereich ist für Admins gedacht.", "error")
|
||||||
|
return redirect(url_for("main.dashboard"))
|
||||||
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
return wrapped_view
|
return wrapped_view
|
||||||
|
|
||||||
@@ -34,16 +50,68 @@ def login_required(view):
|
|||||||
def ensure_csrf_token() -> str:
|
def ensure_csrf_token() -> str:
|
||||||
token = session.get("_csrf_token")
|
token = session.get("_csrf_token")
|
||||||
if not token:
|
if not token:
|
||||||
token = session["_csrf_token"] = __import__("secrets").token_hex(24)
|
token = session["_csrf_token"] = secrets.token_hex(24)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def wants_to_stay_on_form() -> bool:
|
||||||
|
return request.form.get("save_mode", "").strip() == "stay"
|
||||||
|
|
||||||
|
|
||||||
|
def url_with_scroll_position(url: str) -> str:
|
||||||
|
raw_scroll = request.form.get("_scroll", "").strip()
|
||||||
|
if not raw_scroll:
|
||||||
|
return url
|
||||||
|
try:
|
||||||
|
scroll_value = max(0, int(float(raw_scroll)))
|
||||||
|
except ValueError:
|
||||||
|
return url
|
||||||
|
|
||||||
|
parts = urlsplit(url)
|
||||||
|
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||||
|
query["_scroll"] = str(scroll_value)
|
||||||
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_login_value(raw: str) -> str:
|
||||||
|
return raw.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password_strength(password: str) -> str | None:
|
||||||
|
if len(password or "") < 10:
|
||||||
|
return "Bitte ein etwas längeres Passwort wählen."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate_identity_fields(database, username: str, email: str | None, current_user_id: int | None = None) -> str | None:
|
||||||
|
if not username:
|
||||||
|
return "Bitte einen Benutzernamen eintragen."
|
||||||
|
|
||||||
|
existing_user = database.execute(
|
||||||
|
"SELECT id FROM users WHERE LOWER(username) = LOWER(?)",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
if existing_user and int(existing_user["id"]) != current_user_id:
|
||||||
|
return "Dieser Benutzername ist bereits vergeben."
|
||||||
|
|
||||||
|
if email:
|
||||||
|
existing_email = database.execute(
|
||||||
|
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
if existing_email and int(existing_email["id"]) != current_user_id:
|
||||||
|
return "Diese E-Mail-Adresse wird bereits verwendet."
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.app_context_processor
|
@auth_bp.app_context_processor
|
||||||
def inject_csrf_input():
|
def inject_csrf_input():
|
||||||
return {
|
return {
|
||||||
"csrf_input": lambda: Markup(
|
"csrf_input": lambda: Markup(
|
||||||
f'<input type="hidden" name="csrf_token" value="{ensure_csrf_token()}">'
|
f'<input type="hidden" name="csrf_token" value="{ensure_csrf_token()}">'
|
||||||
)
|
),
|
||||||
|
"csrf_token_value": ensure_csrf_token(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -54,9 +122,18 @@ def load_logged_in_user():
|
|||||||
g.user = None
|
g.user = None
|
||||||
else:
|
else:
|
||||||
g.user = get_db().execute(
|
g.user = get_db().execute(
|
||||||
"SELECT * FROM users WHERE id = ?",
|
"""
|
||||||
|
SELECT users.*,
|
||||||
|
households.name AS household_name
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN households ON households.id = users.household_id
|
||||||
|
WHERE users.id = ?
|
||||||
|
""",
|
||||||
(user_id,),
|
(user_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
if g.user is not None and not g.user["is_active"]:
|
||||||
|
session.clear()
|
||||||
|
g.user = None
|
||||||
|
|
||||||
endpoint = request.endpoint or ""
|
endpoint = request.endpoint or ""
|
||||||
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}:
|
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}:
|
||||||
@@ -76,27 +153,34 @@ def setup():
|
|||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip().lower()
|
household_name = request.form.get("household_name", "").strip() or "Unser Haushalt"
|
||||||
|
username = normalize_login_value(request.form.get("username", ""))
|
||||||
|
email = normalize_login_value(request.form.get("email", "")) or None
|
||||||
display_name = request.form.get("display_name", "").strip()
|
display_name = request.form.get("display_name", "").strip()
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
password_repeat = request.form.get("password_repeat", "")
|
password_repeat = request.form.get("password_repeat", "")
|
||||||
|
database = get_db()
|
||||||
|
|
||||||
error = None
|
error = validate_identity_fields(database, username, email)
|
||||||
if not username:
|
if error is None and not password:
|
||||||
error = "Bitte einen Benutzernamen eintragen."
|
|
||||||
elif not password:
|
|
||||||
error = "Bitte ein Passwort vergeben."
|
error = "Bitte ein Passwort vergeben."
|
||||||
elif password != password_repeat:
|
elif error is None and password != password_repeat:
|
||||||
error = "Die Passwoerter stimmen nicht ueberein."
|
error = "Die Passwörter stimmen nicht überein."
|
||||||
|
elif error is None:
|
||||||
|
error = validate_password_strength(password)
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
database = get_db()
|
database.execute(
|
||||||
|
"INSERT INTO households (name) VALUES (?)",
|
||||||
|
(household_name,),
|
||||||
|
)
|
||||||
|
household_id = ensure_default_household(database)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users (username, display_name, password_hash)
|
INSERT INTO users (household_id, username, email, display_name, role, password_hash)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?, ?, 'admin', ?)
|
||||||
""",
|
""",
|
||||||
(username, display_name, generate_password_hash(password)),
|
(household_id, username, email, display_name, generate_password_hash(password)),
|
||||||
)
|
)
|
||||||
database.commit()
|
database.commit()
|
||||||
flash("Der erste Haushalt-Zugang ist angelegt. Du kannst dich jetzt anmelden.", "success")
|
flash("Der erste Haushalt-Zugang ist angelegt. Du kannst dich jetzt anmelden.", "success")
|
||||||
@@ -113,20 +197,29 @@ def login():
|
|||||||
return redirect(url_for("auth.setup"))
|
return redirect(url_for("auth.setup"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip().lower()
|
identity = normalize_login_value(request.form.get("username", ""))
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
|
remember_me = request.form.get("remember_me") == "1"
|
||||||
database = get_db()
|
database = get_db()
|
||||||
user = database.execute(
|
user = database.execute(
|
||||||
"SELECT * FROM users WHERE username = ?",
|
"""
|
||||||
(username,),
|
SELECT users.*, households.name AS household_name
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN households ON households.id = users.household_id
|
||||||
|
WHERE LOWER(users.username) = LOWER(?) OR LOWER(COALESCE(users.email, '')) = LOWER(?)
|
||||||
|
""",
|
||||||
|
(identity, identity),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
if user is None or not check_password_hash(user["password_hash"], password):
|
if user is None or not check_password_hash(user["password_hash"], password):
|
||||||
error = "Benutzername oder Passwort passen nicht zusammen."
|
error = "Login oder Passwort passen nicht zusammen."
|
||||||
|
elif not user["is_active"]:
|
||||||
|
error = "Dieser Zugang ist derzeit nicht aktiv."
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
session.clear()
|
session.clear()
|
||||||
|
session.permanent = remember_me
|
||||||
session["user_id"] = user["id"]
|
session["user_id"] = user["id"]
|
||||||
ensure_csrf_token()
|
ensure_csrf_token()
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
@@ -136,8 +229,117 @@ def login():
|
|||||||
return render_template("auth/login.html")
|
return render_template("auth/login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/profile", methods=("GET", "POST"))
|
||||||
|
@login_required
|
||||||
|
def profile():
|
||||||
|
database = get_db()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
username = normalize_login_value(request.form.get("username", ""))
|
||||||
|
email = normalize_login_value(request.form.get("email", "")) or None
|
||||||
|
display_name = request.form.get("display_name", "").strip()
|
||||||
|
|
||||||
|
error = validate_identity_fields(database, username, email, current_user_id=g.user["id"])
|
||||||
|
if error is None:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET username = ?, email = ?, display_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(username, email, display_name, g.user["id"]),
|
||||||
|
)
|
||||||
|
database.commit()
|
||||||
|
flash("Dein Profil wurde aktualisiert.", "success")
|
||||||
|
if wants_to_stay_on_form():
|
||||||
|
return redirect(url_with_scroll_position(url_for("auth.profile")))
|
||||||
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
|
flash(error, "error")
|
||||||
|
|
||||||
|
return render_template("auth/profile.html", role_labels=ROLE_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.post("/password")
|
||||||
|
@login_required
|
||||||
|
def change_password():
|
||||||
|
current_password = request.form.get("current_password", "")
|
||||||
|
new_password = request.form.get("new_password", "")
|
||||||
|
new_password_repeat = request.form.get("new_password_repeat", "")
|
||||||
|
|
||||||
|
error = None
|
||||||
|
if not check_password_hash(g.user["password_hash"], current_password):
|
||||||
|
error = "Das aktuelle Passwort stimmt nicht."
|
||||||
|
elif not new_password:
|
||||||
|
error = "Bitte ein neues Passwort eintragen."
|
||||||
|
elif new_password != new_password_repeat:
|
||||||
|
error = "Die neuen Passwörter stimmen nicht überein."
|
||||||
|
else:
|
||||||
|
error = validate_password_strength(new_password)
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
get_db().execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(generate_password_hash(new_password), g.user["id"]),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
flash("Dein Passwort wurde geändert.", "success")
|
||||||
|
else:
|
||||||
|
flash(error, "error")
|
||||||
|
|
||||||
|
return redirect(url_for("auth.profile"))
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.post("/logout")
|
@auth_bp.post("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
session.clear()
|
session.clear()
|
||||||
flash("Du bist abgemeldet.", "info")
|
flash("Du bist abgemeldet.", "info")
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_admin_user_form(
|
||||||
|
database,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
email: str | None,
|
||||||
|
role: str,
|
||||||
|
is_active: bool,
|
||||||
|
password: str,
|
||||||
|
password_repeat: str,
|
||||||
|
current_user_id: int | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
error = validate_identity_fields(database, username, email, current_user_id=current_user_id)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
if role not in ROLE_LABELS:
|
||||||
|
return "Bitte eine gültige Rolle auswählen."
|
||||||
|
if current_user_id is None and not password:
|
||||||
|
return "Bitte ein Passwort vergeben."
|
||||||
|
if password and password != password_repeat:
|
||||||
|
return "Die Passwörter stimmen nicht überein."
|
||||||
|
if password:
|
||||||
|
password_error = validate_password_strength(password)
|
||||||
|
if password_error:
|
||||||
|
return password_error
|
||||||
|
if current_user_id == g.user["id"] and not is_active:
|
||||||
|
return "Du kannst deinen eigenen Zugang hier nicht deaktivieren."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def can_remove_last_admin(target_user_id: int, new_role: str, is_active: bool) -> bool:
|
||||||
|
if g.user is None:
|
||||||
|
return False
|
||||||
|
if target_user_id != g.user["id"] and g.user["role"] == "admin":
|
||||||
|
target = get_db().execute("SELECT * FROM users WHERE id = ?", (target_user_id,)).fetchone()
|
||||||
|
if target is None:
|
||||||
|
return False
|
||||||
|
if target["role"] != "admin" or not target["is_active"]:
|
||||||
|
return False
|
||||||
|
if new_role == "admin" and is_active:
|
||||||
|
return False
|
||||||
|
return active_admin_count(g.user["household_id"]) <= 1
|
||||||
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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)
|
||||||
|
uploads_snapshot_dir = Path(tempfile.mkdtemp(prefix="nouri-backup-uploads-"))
|
||||||
|
try:
|
||||||
|
if uploads_root.exists():
|
||||||
|
for file_path in uploads_root.rglob("*"):
|
||||||
|
if not file_path.is_file():
|
||||||
|
continue
|
||||||
|
relative_path = file_path.relative_to(uploads_root)
|
||||||
|
snapshot_path = uploads_snapshot_dir / relative_path
|
||||||
|
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(file_path, snapshot_path)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||||
|
archive.writestr("backup.json", json.dumps(payload, ensure_ascii=False, indent=2))
|
||||||
|
for file_path in uploads_snapshot_dir.rglob("*"):
|
||||||
|
if file_path.is_file():
|
||||||
|
relative_path = file_path.relative_to(uploads_snapshot_dir)
|
||||||
|
archive.write(file_path, f"uploads/{relative_path.as_posix()}")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(uploads_snapshot_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
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", {})
|
||||||
@@ -1,25 +1,222 @@
|
|||||||
DAYPARTS = [
|
DAYPARTS = [
|
||||||
{"slug": "breakfast", "name": "Fruehstueck", "sort_order": 10},
|
{"slug": "breakfast", "name": "Frühstück", "sort_order": 10},
|
||||||
{"slug": "morning-snack", "name": "Vormittagssnack", "sort_order": 20},
|
{"slug": "morning-snack", "name": "Vormittagssnack", "sort_order": 20},
|
||||||
{"slug": "lunch", "name": "Mittagessen", "sort_order": 30},
|
{"slug": "lunch", "name": "Mittagessen", "sort_order": 30},
|
||||||
{"slug": "afternoon-snack", "name": "Nachmittagssnack", "sort_order": 40},
|
{"slug": "afternoon-snack", "name": "Nachmittagssnack", "sort_order": 40},
|
||||||
{"slug": "dinner", "name": "Abendessen", "sort_order": 50},
|
{"slug": "dinner", "name": "Abendessen", "sort_order": 50},
|
||||||
{"slug": "late-snack", "name": "Spaeter Snack", "sort_order": 60},
|
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
|
||||||
]
|
]
|
||||||
|
|
||||||
CATEGORIES = [
|
DAYPART_SLUG_TO_MEAL_TYPE = {
|
||||||
"Brot & Getreide",
|
"breakfast": "breakfast",
|
||||||
|
"morning-snack": "snack",
|
||||||
|
"lunch": "lunch",
|
||||||
|
"afternoon-snack": "snack",
|
||||||
|
"dinner": "dinner",
|
||||||
|
"late-snack": "snack",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_CATEGORIES = [
|
||||||
|
"Unsortiert",
|
||||||
|
"Kohlenhydrate",
|
||||||
"Milchprodukt",
|
"Milchprodukt",
|
||||||
"Obst",
|
"Obst",
|
||||||
"Gemuese",
|
"Gemüse",
|
||||||
"Eiweissquelle",
|
"Eiweißquelle",
|
||||||
"Snack",
|
"Snack",
|
||||||
"Getraenk",
|
"Getränk",
|
||||||
"Vorrat & Basics",
|
"Vorrat & Basics",
|
||||||
"Warmes",
|
"Warmes",
|
||||||
"Kleines Essen",
|
"Kleines Essen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
DEFAULT_CATEGORY_BUILDERS = {
|
||||||
|
"Unsortiert": "neutral",
|
||||||
|
"Kohlenhydrate": "carb",
|
||||||
|
"Brot & Getreide": "carb",
|
||||||
|
"Milchprodukt": "dairy",
|
||||||
|
"Obst": "fruit",
|
||||||
|
"Gemüse": "veg",
|
||||||
|
"Eiweißquelle": "protein",
|
||||||
|
"Snack": "neutral",
|
||||||
|
"Getränk": "neutral",
|
||||||
|
"Vorrat & Basics": "neutral",
|
||||||
|
"Warmes": "carb",
|
||||||
|
"Kleines Essen": "neutral",
|
||||||
|
}
|
||||||
|
|
||||||
|
BUILDER_LABELS = {
|
||||||
|
"protein": "Proteinquelle",
|
||||||
|
"carb": "Kohlenhydrate",
|
||||||
|
"veg": "Gemüse / Ballaststoffquelle",
|
||||||
|
"nuts": "Nüsse",
|
||||||
|
"seeds": "Saaten",
|
||||||
|
"fruit": "Obst",
|
||||||
|
"dairy": "Milchprodukt",
|
||||||
|
"neutral": "Neutral / sonstiges",
|
||||||
|
}
|
||||||
|
|
||||||
|
BUILDER_DESCRIPTIONS = {
|
||||||
|
"protein": "Passt eher zu sättigenden Eiweißquellen.",
|
||||||
|
"carb": "Passt eher zu Brot, Getreide, Reis, Kartoffeln oder ähnlichem.",
|
||||||
|
"veg": "Passt eher zu Gemüse oder ballaststoffreichen Begleitern.",
|
||||||
|
"nuts": "Passt eher zu Nüssen.",
|
||||||
|
"seeds": "Passt eher zu Saaten.",
|
||||||
|
"fruit": "Passt eher zu Obst.",
|
||||||
|
"dairy": "Passt eher zu Joghurt, Milch, Käse oder ähnlichem.",
|
||||||
|
"neutral": "Ohne feste Zuordnung, aber weiterhin gut nutzbar.",
|
||||||
|
}
|
||||||
|
|
||||||
|
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
(0, "Montag"),
|
||||||
|
(1, "Dienstag"),
|
||||||
|
(2, "Mittwoch"),
|
||||||
|
(3, "Donnerstag"),
|
||||||
|
(4, "Freitag"),
|
||||||
|
(5, "Samstag"),
|
||||||
|
(6, "Sonntag"),
|
||||||
|
]
|
||||||
|
|
||||||
|
NOTIFICATION_CHANNEL_OPTIONS = [
|
||||||
|
("in_app", "Nur in der App"),
|
||||||
|
("push", "Nur Push"),
|
||||||
|
("both", "App und Push"),
|
||||||
|
]
|
||||||
|
|
||||||
ITEM_KIND_LABELS = {
|
ITEM_KIND_LABELS = {
|
||||||
"food": "Lebensmittel",
|
"food": "Lebensmittel",
|
||||||
"meal": "Mahlzeitenideen",
|
"meal": "Mahlzeitenideen",
|
||||||
@@ -31,7 +228,46 @@ ITEM_KIND_SINGULAR_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AVAILABILITY_LABELS = {
|
AVAILABILITY_LABELS = {
|
||||||
"idea": "Merkliste",
|
"idea": "Gerade nicht da",
|
||||||
"home": "Zuhause",
|
"home": "Zuhause",
|
||||||
"archived": "Archiv",
|
"unsorted": "Unsortiert",
|
||||||
|
"archived": "Archiviert",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ROLE_LABELS = {
|
||||||
|
"admin": "Admin",
|
||||||
|
"member": "Mitglied",
|
||||||
|
}
|
||||||
|
|
||||||
|
VISIBILITY_LABELS = {
|
||||||
|
"shared": "Gemeinsam",
|
||||||
|
"personal": "Persönlich",
|
||||||
|
}
|
||||||
|
|
||||||
|
VISIBILITY_DESCRIPTIONS = {
|
||||||
|
"shared": "Gemeinsam im Haushalt sichtbar und nutzbar.",
|
||||||
|
"personal": "Nur für dich sichtbar und planbar.",
|
||||||
|
}
|
||||||
|
|
||||||
|
DAY_TEMPLATE_NAME_SUGGESTIONS = [
|
||||||
|
"Ruhiger Tag",
|
||||||
|
"Einfacher Bürotag",
|
||||||
|
"Schwieriger Tag",
|
||||||
|
"Standard-Frühstückstag",
|
||||||
|
"Tag mit wenig Energie",
|
||||||
|
]
|
||||||
|
|
||||||
|
WEEK_TEMPLATE_NAME_SUGGESTIONS = [
|
||||||
|
"Standardwoche",
|
||||||
|
"Büro-Woche",
|
||||||
|
"Leichte Woche",
|
||||||
|
"Woche mit wenig Energie",
|
||||||
|
"Frühstücks-Woche",
|
||||||
|
]
|
||||||
|
|
||||||
|
ITEM_SET_NAME_SUGGESTIONS = [
|
||||||
|
"Schnelles Frühstück",
|
||||||
|
"Sicherer Snack",
|
||||||
|
"Einfaches Abendessen",
|
||||||
|
"Einkauf für zwei Tage",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from werkzeug.datastructures import FileStorage
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||||
|
PILLOW_AVAILABLE = True
|
||||||
|
except ImportError: # pragma: no cover - local fallback when Pillow is unavailable
|
||||||
|
Image = None
|
||||||
|
ImageOps = None
|
||||||
|
UnidentifiedImageError = OSError
|
||||||
|
PILLOW_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
||||||
|
IMAGE_VARIANTS = {
|
||||||
|
"sm": {"width": 320, "quality": 76},
|
||||||
|
"md": {"width": 720, "quality": 82},
|
||||||
|
"lg": {"width": 1280, "quality": 86},
|
||||||
|
}
|
||||||
|
DEFAULT_RENDERED_FORMAT = "webp"
|
||||||
|
ORIGINAL_MAX_WIDTH = 1600
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_image_file(filename: str) -> bool:
|
||||||
|
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_upload_structure(upload_folder: str | Path) -> None:
|
||||||
|
upload_root = Path(upload_folder)
|
||||||
|
upload_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
(upload_root / "variants").mkdir(parents=True, exist_ok=True)
|
||||||
|
for variant_name in IMAGE_VARIANTS:
|
||||||
|
(upload_root / "variants" / variant_name).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def build_variant_filename(filename: str, variant_name: str) -> str:
|
||||||
|
source = Path(filename)
|
||||||
|
return f"{source.stem}__{variant_name}.webp"
|
||||||
|
|
||||||
|
|
||||||
|
def build_variant_relative_path(filename: str, variant_name: str) -> str:
|
||||||
|
return f"variants/{variant_name}/{build_variant_filename(filename, variant_name)}"
|
||||||
|
|
||||||
|
|
||||||
|
def remove_photo_assets(upload_folder: str | Path, filename: str | None) -> None:
|
||||||
|
if not filename:
|
||||||
|
return
|
||||||
|
upload_root = Path(upload_folder)
|
||||||
|
original_path = upload_root / filename
|
||||||
|
if original_path.exists():
|
||||||
|
original_path.unlink()
|
||||||
|
for variant_name in IMAGE_VARIANTS:
|
||||||
|
variant_path = upload_root / build_variant_relative_path(filename, variant_name)
|
||||||
|
if variant_path.exists():
|
||||||
|
variant_path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_image(upload: FileStorage) -> Image.Image:
|
||||||
|
if not PILLOW_AVAILABLE or Image is None or ImageOps is None:
|
||||||
|
raise OSError("Pillow ist nicht verfügbar.")
|
||||||
|
upload.stream.seek(0)
|
||||||
|
image = Image.open(upload.stream)
|
||||||
|
image.load()
|
||||||
|
return ImageOps.exif_transpose(image)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_image(image: Image.Image) -> Image.Image:
|
||||||
|
if not PILLOW_AVAILABLE:
|
||||||
|
return image
|
||||||
|
if image.mode not in {"RGB", "RGBA"}:
|
||||||
|
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def _resize_copy(image: Image.Image, width: int) -> Image.Image:
|
||||||
|
resized = image.copy()
|
||||||
|
resized.thumbnail((width, width * 3), Image.Resampling.LANCZOS)
|
||||||
|
return resized
|
||||||
|
|
||||||
|
|
||||||
|
def _save_image(image: Image.Image, destination: Path, quality: int) -> None:
|
||||||
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
image.save(
|
||||||
|
destination,
|
||||||
|
format=DEFAULT_RENDERED_FORMAT.upper(),
|
||||||
|
quality=quality,
|
||||||
|
method=6,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def save_photo_with_variants(
|
||||||
|
upload: FileStorage | None,
|
||||||
|
upload_folder: str | Path,
|
||||||
|
current_filename: str | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
if not upload or not upload.filename:
|
||||||
|
return current_filename
|
||||||
|
|
||||||
|
if not allowed_image_file(upload.filename):
|
||||||
|
raise ValueError("Bitte ein Bild als PNG, JPG, GIF oder WEBP hochladen.")
|
||||||
|
|
||||||
|
ensure_upload_structure(upload_folder)
|
||||||
|
|
||||||
|
original_name = secure_filename(upload.filename)
|
||||||
|
extension = original_name.rsplit(".", 1)[1].lower()
|
||||||
|
|
||||||
|
try:
|
||||||
|
image = _prepare_image(_open_image(upload))
|
||||||
|
filename = f"{uuid.uuid4().hex}.webp"
|
||||||
|
original_path = Path(upload_folder) / filename
|
||||||
|
optimized = _resize_copy(image, ORIGINAL_MAX_WIDTH)
|
||||||
|
_save_image(optimized, original_path, quality=88)
|
||||||
|
for variant_name, config in IMAGE_VARIANTS.items():
|
||||||
|
variant_image = _resize_copy(image, int(config["width"]))
|
||||||
|
variant_path = Path(upload_folder) / build_variant_relative_path(filename, variant_name)
|
||||||
|
_save_image(variant_image, variant_path, quality=int(config["quality"]))
|
||||||
|
except (UnidentifiedImageError, OSError, ValueError):
|
||||||
|
filename = f"{uuid.uuid4().hex}.{extension}"
|
||||||
|
original_path = Path(upload_folder) / filename
|
||||||
|
upload.stream.seek(0)
|
||||||
|
upload.save(original_path)
|
||||||
|
|
||||||
|
if current_filename and current_filename != filename:
|
||||||
|
remove_photo_assets(upload_folder, current_filename)
|
||||||
|
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
def image_url(filename: str | None, url_builder, variant: str = "md", upload_folder: str | Path | None = None) -> str | None:
|
||||||
|
if not filename:
|
||||||
|
return None
|
||||||
|
if variant in IMAGE_VARIANTS:
|
||||||
|
if upload_folder is not None:
|
||||||
|
variant_path = Path(upload_folder) / build_variant_relative_path(filename, variant)
|
||||||
|
if variant_path.exists():
|
||||||
|
return url_builder("uploaded_file", filename=build_variant_relative_path(filename, variant))
|
||||||
|
else:
|
||||||
|
return url_builder("uploaded_file", filename=build_variant_relative_path(filename, variant))
|
||||||
|
return url_builder("uploaded_file", filename=filename)
|
||||||
|
|
||||||
|
|
||||||
|
def image_srcset(filename: str | None, url_builder, upload_folder: str | Path | None = None) -> str:
|
||||||
|
if not filename:
|
||||||
|
return ""
|
||||||
|
parts = []
|
||||||
|
for variant_name, config in IMAGE_VARIANTS.items():
|
||||||
|
variant_url = image_url(filename, url_builder, variant_name, upload_folder=upload_folder)
|
||||||
|
if variant_url and (not upload_folder or variant_url != url_builder("uploaded_file", filename=filename)):
|
||||||
|
parts.append(f"{variant_url} {config['width']}w")
|
||||||
|
original_width = max(config["width"] for config in IMAGE_VARIANTS.values()) + 320
|
||||||
|
parts.append(f"{url_builder('uploaded_file', filename=filename)} {original_width}w")
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def image_sizes(card: str = "grid") -> str:
|
||||||
|
if card == "detail":
|
||||||
|
return "(max-width: 720px) 100vw, 720px"
|
||||||
|
return "(max-width: 720px) 42vw, (max-width: 1080px) 28vw, 180px"
|
||||||
|
|
||||||
|
|
||||||
|
def upload_file_size_ok(upload: FileStorage | None, max_bytes: int) -> bool:
|
||||||
|
if not upload or not upload.filename:
|
||||||
|
return True
|
||||||
|
stream = upload.stream
|
||||||
|
current_position = stream.tell()
|
||||||
|
stream.seek(0, os.SEEK_END)
|
||||||
|
size = stream.tell()
|
||||||
|
stream.seek(current_position)
|
||||||
|
return size <= max_bytes
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
|
def push_is_configured() -> bool:
|
||||||
|
return bool(
|
||||||
|
current_app.config.get("VAPID_PUBLIC_KEY")
|
||||||
|
and current_app.config.get("VAPID_PRIVATE_KEY")
|
||||||
|
and current_app.config.get("VAPID_SUBJECT")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def push_public_key() -> str | None:
|
||||||
|
return current_app.config.get("VAPID_PUBLIC_KEY") or None
|
||||||
|
|
||||||
|
|
||||||
|
def send_push_message(subscription: dict[str, Any], *, title: str, body: str, url: str) -> tuple[bool, str | None]:
|
||||||
|
if not push_is_configured():
|
||||||
|
return False, "Push ist noch nicht konfiguriert."
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pywebpush import WebPushException, webpush
|
||||||
|
except Exception:
|
||||||
|
return False, "Die Push-Bibliothek ist noch nicht installiert."
|
||||||
|
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"url": url,
|
||||||
|
"icon": "/static/brand/pwa-192.png",
|
||||||
|
"badge": "/static/brand/pwa-badge.png",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription_info=subscription,
|
||||||
|
data=payload,
|
||||||
|
vapid_private_key=current_app.config["VAPID_PRIVATE_KEY"],
|
||||||
|
vapid_claims={"sub": current_app.config["VAPID_SUBJECT"]},
|
||||||
|
)
|
||||||
|
except WebPushException as exc: # pragma: no cover - depends on live push endpoint
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
return True, None
|
||||||
@@ -0,0 +1,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,11 +1,108 @@
|
|||||||
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 (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
shopping_weekday INTEGER NOT NULL DEFAULT 5,
|
||||||
|
shopping_prep_days INTEGER NOT NULL DEFAULT 1,
|
||||||
|
shopping_reminder_time TEXT NOT NULL DEFAULT '18:00',
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
|
||||||
|
ON users (email)
|
||||||
|
WHERE email IS NOT NULL AND email != '';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS household_categories (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
builder_key TEXT NOT NULL DEFAULT 'neutral',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (household_id, name),
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
reminders_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
push_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
notification_channel TEXT NOT NULL DEFAULT 'in_app',
|
||||||
|
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_on_shopping_day INTEGER NOT NULL DEFAULT 1,
|
||||||
|
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
|
||||||
|
show_planned_not_shopped INTEGER NOT NULL DEFAULT 1,
|
||||||
|
remind_tomorrow_if_sparse INTEGER NOT NULL DEFAULT 1,
|
||||||
|
remind_week_if_sparse INTEGER NOT NULL DEFAULT 1,
|
||||||
|
push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
|
||||||
|
push_missing_lunch INTEGER NOT NULL DEFAULT 0,
|
||||||
|
push_missing_dinner INTEGER NOT NULL DEFAULT 0,
|
||||||
|
push_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||||
|
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
||||||
|
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||||
|
remind_nuts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
show_meal_balancing INTEGER NOT NULL DEFAULT 1,
|
||||||
|
suggest_templates INTEGER NOT NULL DEFAULT 1,
|
||||||
|
suggest_patterns INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL UNIQUE,
|
||||||
|
p256dh TEXT NOT NULL,
|
||||||
|
auth TEXT NOT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_test_at TEXT,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reminder_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
event_key TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, event_key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hidden_generated_suggestions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
suggestion_key TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, suggestion_key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dayparts (
|
CREATE TABLE IF NOT EXISTS dayparts (
|
||||||
@@ -17,16 +114,33 @@ CREATE TABLE IF NOT EXISTS dayparts (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS items (
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
|
household_id INTEGER,
|
||||||
|
owner_user_id INTEGER,
|
||||||
|
target_user_id INTEGER,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal', 'shopping')),
|
||||||
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')),
|
||||||
|
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_quick_added INTEGER NOT NULL DEFAULT 0,
|
||||||
created_by INTEGER,
|
created_by INTEGER,
|
||||||
updated_by INTEGER,
|
updated_by INTEGER,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
@@ -49,30 +163,176 @@ CREATE TABLE IF NOT EXISTS meal_components (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS shopping_entries (
|
CREATE TABLE IF NOT EXISTS shopping_entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER,
|
||||||
|
owner_user_id INTEGER,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||||
item_id INTEGER NOT NULL,
|
item_id INTEGER NOT NULL,
|
||||||
|
shopping_note TEXT NOT NULL DEFAULT '',
|
||||||
added_by INTEGER,
|
added_by INTEGER,
|
||||||
checked_by INTEGER,
|
checked_by INTEGER,
|
||||||
|
needed_for_date TEXT,
|
||||||
|
needed_for_daypart_id INTEGER,
|
||||||
is_checked INTEGER NOT NULL DEFAULT 0,
|
is_checked INTEGER NOT NULL DEFAULT 0,
|
||||||
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
checked_at TEXT,
|
checked_at TEXT,
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL,
|
FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL
|
FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item
|
||||||
ON shopping_entries (item_id)
|
ON shopping_entries (item_id, COALESCE(shopping_note, ''))
|
||||||
WHERE is_checked = 0;
|
WHERE is_checked = 0;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shopping_needs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER NOT NULL,
|
||||||
|
owner_user_id INTEGER,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
source_item_id INTEGER,
|
||||||
|
needed_for_date TEXT NOT NULL,
|
||||||
|
needed_for_daypart_id INTEGER,
|
||||||
|
activation_date TEXT NOT NULL,
|
||||||
|
is_activated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
activated_at TEXT,
|
||||||
|
created_by INTEGER,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (item_id, source_item_id, needed_for_date, needed_for_daypart_id, visibility),
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (source_item_id) REFERENCES items(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (needed_for_daypart_id) REFERENCES dayparts(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS plan_entries (
|
CREATE TABLE IF NOT EXISTS plan_entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER,
|
||||||
|
owner_user_id INTEGER,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||||
plan_date TEXT NOT NULL,
|
plan_date TEXT NOT NULL,
|
||||||
daypart_id INTEGER NOT NULL,
|
daypart_id INTEGER NOT NULL,
|
||||||
item_id INTEGER NOT NULL,
|
item_id INTEGER NOT NULL,
|
||||||
note TEXT,
|
note TEXT,
|
||||||
created_by INTEGER,
|
created_by INTEGER,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE,
|
FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS day_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER NOT NULL,
|
||||||
|
owner_user_id INTEGER,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
last_used_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS day_template_entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
day_template_id INTEGER NOT NULL,
|
||||||
|
daypart_id INTEGER NOT NULL,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||||
|
FOREIGN KEY (day_template_id) REFERENCES day_templates(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS week_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER NOT NULL,
|
||||||
|
owner_user_id INTEGER,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
last_used_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS week_template_days (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
week_template_id INTEGER NOT NULL,
|
||||||
|
weekday_index INTEGER NOT NULL,
|
||||||
|
day_template_id INTEGER NOT NULL,
|
||||||
|
UNIQUE (week_template_id, weekday_index),
|
||||||
|
FOREIGN KEY (week_template_id) REFERENCES week_templates(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (day_template_id) REFERENCES day_templates(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_sets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER NOT NULL,
|
||||||
|
owner_user_id INTEGER,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
last_used_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS item_set_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
item_set_id INTEGER NOT NULL,
|
||||||
|
item_id INTEGER NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 100,
|
||||||
|
UNIQUE (item_set_id, item_id),
|
||||||
|
FOREIGN KEY (item_set_id) REFERENCES item_sets(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_kind_name
|
||||||
|
ON items (kind, name);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
||||||
|
ON items (household_id, visibility, availability_state);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility_archived
|
||||||
|
ON items (household_id, visibility, is_archived, availability_state);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_target_user
|
||||||
|
ON items (target_user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item
|
||||||
|
ON item_dayparts (daypart_id, item_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_entries_plan_date_daypart
|
||||||
|
ON plan_entries (plan_date, daypart_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility
|
||||||
|
ON plan_entries (household_id, visibility, plan_date);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility
|
||||||
|
ON shopping_entries (household_id, visibility, is_checked);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation
|
||||||
|
ON shopping_needs (household_id, activation_date, is_activated);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_day_templates_household_visibility
|
||||||
|
ON day_templates (household_id, visibility, name);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_week_templates_household_visibility
|
||||||
|
ON week_templates (household_id, visibility, name);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_item_sets_household_visibility
|
||||||
|
ON item_sets (household_id, visibility, name);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="3" y="3" width="58" height="58" rx="18" fill="#E3A06B"/>
|
||||||
|
<path d="M16 36H48C46.9 44.2 40.0727 50 31.8 50H32.2C23.9273 50 17.1 44.2 16 36Z" fill="#FFF5EC"/>
|
||||||
|
<path d="M31.9997 19C36.9702 19 40.9997 23.0781 40.9997 28.1081V29.3848C40.9997 32.7262 39.6098 35.9158 37.1535 38.1417L29.9949 44.6282H24.5605L32.4863 37.4468C34.0025 36.0726 34.8571 34.1103 34.8571 32.0653V28.1081C34.8571 26.551 33.5671 25.2162 31.9997 25.2162C30.4324 25.2162 29.1424 26.551 29.1424 28.1081V29.3848H23C23 23.0781 27.0295 19 31.9997 19Z" fill="#8C533B"/>
|
||||||
|
<rect x="24" y="39" width="16" height="6" rx="3" fill="#8C533B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 715 B |
@@ -0,0 +1,20 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="nouriBg" x1="88" y1="72" x2="420" y2="440" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#F6C394"/>
|
||||||
|
<stop offset="1" stop-color="#DE9862"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="nouriBowl" x1="161" y1="176" x2="349" y2="339" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#FFF8F0"/>
|
||||||
|
<stop offset="1" stop-color="#FDE7D5"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="24" y="24" width="464" height="464" rx="122" fill="url(#nouriBg)"/>
|
||||||
|
<rect x="48" y="48" width="416" height="416" rx="104" fill="white" fill-opacity="0.12"/>
|
||||||
|
<path d="M152 232C152 175.667 197.667 130 254 130H258C315.438 130 362 176.562 362 234V242C362 299.438 315.438 346 258 346H254C197.667 346 152 300.333 152 244V232Z" fill="url(#nouriBowl)"/>
|
||||||
|
<path d="M175 244C175 201.474 209.474 167 252 167H258C300.526 167 335 201.474 335 244V244C335 286.526 300.526 321 258 321H252C209.474 321 175 286.526 175 244V244Z" fill="#EFC39F" fill-opacity="0.22"/>
|
||||||
|
<path d="M164 287H348C344.6 332.419 307.445 368 261.2 368H250.8C204.555 368 167.4 332.419 164 287Z" fill="#F7B37D"/>
|
||||||
|
<path d="M198 287H314C311.53 314.638 288.347 336 260.2 336H251.8C223.653 336 200.47 314.638 198 287Z" fill="#A86244" fill-opacity="0.2"/>
|
||||||
|
<path d="M255.999 171C271.513 171 284.246 183.574 284.246 199.27V205.573C284.246 216.441 279.591 226.794 271.487 233.994L246.435 256.256C241.017 261.07 237.906 268.003 237.906 275.283V295.246H212.906V275.283C212.906 260.722 219.129 246.854 229.965 237.227L255.016 214.966C257.705 212.578 259.246 209.146 259.246 205.573V199.27C259.246 197.28 257.651 195.6 255.999 195.6C254.347 195.6 252.752 197.28 252.752 199.27V205.091H227.752V199.27C227.752 183.574 240.485 171 255.999 171Z" fill="#8C533B"/>
|
||||||
|
<rect x="226" y="280" width="63" height="25" rx="12.5" fill="#8C533B"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 805 B |
|
After Width: | Height: | Size: 7.9 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M240 112L304 112L304 144L240 144L240 112zM256 176L304 176L304 208L240 208L240 176L256 176zM112 240L160 240L160 272L96 272L96 240L112 240zM192 495.8L192 512L448 512L448 475.6L466.3 466.9C510 446.1 540.9 402.8 543.8 352L96.2 352C99.1 402.8 130 446.2 173.7 466.9L192 475.6L192 495.8zM160 495.8C105.5 469.9 67.2 415.6 64.2 352C64.1 349.4 64 346.7 64 344L64 320L576 320L576 344C576 346.7 575.9 349.4 575.8 352C572.8 415.6 534.5 469.9 480 495.8L480 544L160 544L160 495.8zM288 240L352 240L352 272L288 272L288 240zM192 240L256 240L256 272L192 272L192 240zM160 176L208 176L208 208L144 208L144 176L160 176zM384 240L448 240L448 272L384 272L384 240zM352 176L400 176L400 208L336 208L336 176L352 176zM480 240L544 240L544 272L480 272L480 240zM448 176L496 176L496 208L432 208L432 176L448 176zM352 112L400 112L400 144L336 144L336 112L352 112z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M96 512L544 512L544 352L96 352L96 512zM116.1 320L544 320C544 217.5 463.7 133.8 362.6 128.3L116.1 320zM64 320L352 96C475.7 96 576 196.3 576 320L576 544L64 544L64 320z"/></svg>
|
||||||
|
After Width: | Height: | Size: 433 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M306.3 192L288 192L288 144C288 99.8 323.8 64 368 64L416 64L416 112C416 156.2 380.2 192 336 192L306.3 192zM368 96C341.5 96 320 117.5 320 144L320 160L336 160C362.5 160 384 138.5 384 112L384 96L368 96zM208 192L320 224L432 192C508.3 192 544 275.7 544 352C544 480 464 576 384 576L320 560L256 576C176 576 96 480 96 352C96 275.7 131.7 192 208 192zM328.8 254.8L320 257.3L311.2 254.8L203.9 224.2C181.6 225.5 164.1 237.9 150.7 260.1C136 284.4 127.9 318.3 127.9 352.1C127.9 409.6 145.9 458.7 171.5 492.9C196.4 526 226.2 542.6 252.3 544L312.1 529.1L319.9 527.2L327.7 529.1L387.5 544C413.6 542.6 443.5 526 468.3 492.9C493.9 458.7 511.9 409.6 511.9 352.1C511.9 318.3 503.9 284.4 489.1 260.1C475.7 238 458.2 225.6 435.9 224.2L328.8 254.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 991 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M267.8 146.2L260 162.3L244.9 152.7C236.1 147.1 226 144 215.1 144C187.3 144 163 165.2 157.3 195L154.8 208.4L141.2 208C140.8 208 140.5 208 140.1 208C116.8 208 96.1 228.5 96.1 256L96.1 272L64.1 272L64.1 256C64 216.6 91.5 182.5 128.9 176.8C141 139.6 174.5 112 215 112C226.4 112 237.3 114.2 247.3 118.2C263.8 95.2 290 80 320 80C350 80 376.2 95.2 392.7 118.2C402.7 114.2 413.6 112 425 112C465.5 112 499 139.6 511.1 176.8C548.5 182.5 576 216.6 576 256L576 272L544 272L544 256C544 228.5 523.3 208 500 208C499.6 208 499.3 208 498.9 208L485.3 208.4L482.8 195C477.1 165.2 452.8 144 425 144C414.2 144 404 147.2 395.2 152.7L380.1 162.3L372.3 146.2C362.2 125.5 342.2 112 320 112C297.8 112 277.9 125.5 267.8 146.2zM192 495.8L192 512L448 512L448 475.6L466.3 466.9C510 446.1 540.9 402.8 543.8 352L96.2 352C99.1 402.8 130 446.2 173.7 466.9L192 475.6L192 495.8zM160 495.8C105.5 469.9 67.2 415.6 64.2 352C64.1 349.4 64 346.7 64 344L64 320L576 320L576 344C576 346.7 575.9 349.4 575.8 352C572.8 415.6 534.5 469.9 480 495.8L480 544L160 544L160 495.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M256 256L202.8 264.9C174.8 269.6 148.5 281.6 126.7 299.8L121.6 304C85.1 334.4 64 379.5 64 426.9L64 576L205.7 576C248.1 576 288.8 559.1 318.8 529.1L331.7 516.2C355.3 492.6 370.9 462.3 376.4 429.4L384 384L424.4 377.3C460.6 371.3 494 354.1 519.9 328.1L524.5 323.5C557.5 290.5 576 245.7 576 199L576 64L440.9 64C394.2 64 349.5 82.5 316.4 115.5L311.8 120.1C285.9 146 268.7 179.4 262.6 215.6L256 256zM544 96L544 199.1C544 237.3 528.8 273.9 501.8 300.9L497.2 305.5C476 326.7 448.7 340.8 419 345.7C389.1 350.7 368.1 354.2 356.1 356.2C354.2 367.4 350.5 390.1 344.8 424.2C340.4 450.5 327.9 474.8 309.1 493.7L296.2 506.6C272.2 530.6 239.6 544.1 205.7 544.1L96 544L96 426.9C96 388.9 112.9 352.9 142.1 328.6L147.2 324.4C164.7 309.8 185.7 300.2 208.1 296.5C248.7 289.7 273.9 285.5 283.9 283.9C285.9 271.8 289.4 250.9 294.4 221C299.3 191.4 313.4 164.1 334.6 142.8L339.2 138.2C366.2 111.2 402.8 96 441 96L544 96zM211.2 480C211.2 469.4 202.6 460.8 192 460.8C181.4 460.8 172.8 469.4 172.8 480C172.8 490.6 181.4 499.2 192 499.2C202.6 499.2 211.2 490.6 211.2 480zM416 275.2C426.6 275.2 435.2 266.6 435.2 256C435.2 245.4 426.6 236.8 416 236.8C405.4 236.8 396.8 245.4 396.8 256C396.8 266.6 405.4 275.2 416 275.2zM275.2 480C275.2 469.4 266.6 460.8 256 460.8C245.4 460.8 236.8 469.4 236.8 480C236.8 490.6 245.4 499.2 256 499.2C266.6 499.2 275.2 490.6 275.2 480zM480 275.2C490.6 275.2 499.2 266.6 499.2 256C499.2 245.4 490.6 236.8 480 236.8C469.4 236.8 460.8 245.4 460.8 256C460.8 266.6 469.4 275.2 480 275.2zM275.2 416C275.2 405.4 266.6 396.8 256 396.8C245.4 396.8 236.8 405.4 236.8 416C236.8 426.6 245.4 435.2 256 435.2C266.6 435.2 275.2 426.6 275.2 416zM480 211.2C490.6 211.2 499.2 202.6 499.2 192C499.2 181.4 490.6 172.8 480 172.8C469.4 172.8 460.8 181.4 460.8 192C460.8 202.6 469.4 211.2 480 211.2z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M301.3 384L400 384C479.5 384 544 319.5 544 240C544 160.5 479.5 96 400 96C320.5 96 256 160.5 256 240L256 338.7L301.3 384zM224 345.4L224 240C224 142.8 302.8 64 400 64C497.2 64 576 142.8 576 240C576 337.2 497.2 416 400 416L294.6 416L254.1 456.5C265.2 469.2 272 485.8 272 504C272 543.8 239.8 576 200 576C162.8 576 132.2 547.8 128.4 511.6C92.2 507.8 64 477.2 64 440C64 400.2 96.2 368 136 368C154.2 368 170.8 374.8 183.5 385.9L224 345.4zM243.3 371.3C204.6 410 183.6 431 180.2 434.4L169.4 418C162.2 407.1 149.9 400 136 400C113.9 400 96 417.9 96 440C96 462.1 113.9 480 136 480L160 480L160 504C160 526.1 177.9 544 200 544C222.1 544 240 526.1 240 504C240 490 232.9 477.8 222 470.6L205.6 459.8C209 456.4 230 435.4 268.7 396.7L243.3 371.3z"/></svg>
|
||||||
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M576 64L576 112L575.7 123.5C569.9 238 478 329.9 363.5 335.7L352 336L352 576L320 576L320 384L288 384L276.5 383.7C158.1 377.7 64 279.8 64 160L64 128L128 128L139.5 128.3C219.3 132.3 287.9 178.1 324.3 244.2C344.7 141.4 435.3 64 544 64L576 64zM96 160C96 266 182 352 288 352L320 352C320 246 234 160 128 160L96 160zM544 96C438 96 352 182 352 288L352 304C458 304 544 218 544 112L544 96z"/></svg>
|
||||||
|
After Width: | Height: | Size: 646 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M435.4 44.7L424.1 33.4C418.6 38.9 407 50.5 389.4 68.1C363.5 94 355.5 130.9 365.3 163.7C354.7 161.3 343.7 160 332.4 160C273.3 160 219.9 195.2 196.6 249.5L82.7 515.4C74.7 534 64.2 558.5 51.2 588.9C79.9 576.6 175.4 535.7 390.6 443.4C444.9 420.1 480.1 366.7 480.1 307.6C480.1 296.3 478.8 285.3 476.4 274.7C509.1 284.5 546.1 276.5 572 250.6C589.6 233 601.2 221.4 606.7 215.9C601.2 210.4 589.6 198.8 572 181.2C548.4 157.6 515.5 148.8 485.1 155C491.2 124.5 482.5 91.7 458.9 68.1L435.4 44.7zM458.8 203.9L458.8 203.9C483.8 178.9 524.3 178.9 549.3 203.9L561.4 216L549.3 228.1C524.3 253.1 483.8 253.1 458.8 228.1L446.7 216L458.8 203.9zM436.5 181L424.1 193.4L412 181.3C387 156.3 387 115.8 412 90.8L424.1 78.7L436.2 90.8C461.1 115.7 461.2 156 436.5 181zM332.4 192C396.3 192 448.1 243.8 448.1 307.7C448.1 354 420.5 395.8 378 414.1L284.7 454.1C284.3 453.6 283.9 453.2 283.5 452.7C258.7 427.9 244.3 413.5 240.2 409.4L217.5 432C222.9 437.4 234.7 449.2 253.1 467.6L112.1 528L226.1 262.1C227.4 259 228.9 255.9 230.5 253C267.2 289.7 286.4 308.9 288.2 310.7L310.8 288.1L251.5 228.8C250.9 228.2 250.2 227.6 249.5 227.1C270.9 205.1 300.6 192.1 332.4 192.1z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 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 448 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M224 112c-8.8 0-16-7.2-16-16l0-16c0-44.2 35.8-80 80-80l16 0c8.8 0 16 7.2 16 16l0 16c0 44.2-35.8 80-80 80l-16 0zM0 288c0-76.3 35.7-160 112-160 27.3 0 59.7 10.3 82.7 19.3 18.8 7.3 39.9 7.3 58.7 0 22.9-8.9 55.4-19.3 82.7-19.3 76.3 0 112 83.7 112 160 0 128-80 224-160 224-16.5 0-38.1-6.6-51.5-11.3-8.1-2.8-16.9-2.8-25 0-13.4 4.7-35 11.3-51.5 11.3-80 0-160-96-160-224z"/></svg>
|
||||||
|
After Width: | Height: | Size: 631 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M112.2 272.6C120.4 277.3 129.9 280 140 280L532 280C542.1 280 551.6 277.3 559.8 272.6C560 275 560 277.5 560 280L560 476C560 537.9 509.9 588 448 588L224 588C162.1 588 112 537.9 112 476L112 280C112 277.5 112.1 275.1 112.2 272.6zM118.6 242C134.1 198.8 175.5 168 224 168L448 168C496.5 168 537.9 198.9 553.4 242C548.3 248.1 540.6 252 532 252L140 252C131.4 252 123.7 248.1 118.6 242zM196 350C196 357.7 202.3 364 210 364L462 364C469.7 364 476 357.7 476 350C476 342.3 469.7 336 462 336L210 336C202.3 336 196 342.3 196 350z"/><path fill="currentColor" d="M140 112C124.5 112 112 124.5 112 140L112 224C112 239.5 124.5 252 140 252L532 252C547.5 252 560 239.5 560 224L560 140C560 124.5 547.5 112 532 112L140 112zM84 140C84 109.1 109.1 84 140 84L532 84C562.9 84 588 109.1 588 140L588 224C588 254.9 562.9 280 532 280L140 280C109.1 280 84 254.9 84 224L84 140zM210 336L462 336C469.7 336 476 342.3 476 350C476 357.7 469.7 364 462 364L210 364C202.3 364 196 357.7 196 350C196 342.3 202.3 336 210 336z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M224 0c-17.7 0-32 14.3-32 32l0 3.2C119 50 64 114.6 64 192l0 21.7c0 48.1-16.4 94.8-46.4 132.4L7.8 358.3C2.7 364.6 0 372.4 0 380.5 0 400.1 15.9 416 35.5 416l376.9 0c19.6 0 35.5-15.9 35.5-35.5 0-8.1-2.7-15.9-7.8-22.2l-9.8-12.2C400.4 308.5 384 261.8 384 213.7l0-21.7c0-77.4-55-142-128-156.8l0-3.2c0-17.7-14.3-32-32-32zM162 464c7.1 27.6 32.2 48 62 48s54.9-20.4 62-48l-124 0z"/></svg>
|
||||||
|
After Width: | Height: | Size: 637 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M96.1 352L305.7 352L189.9 481.4C186.5 475.9 181.5 471.6 175.5 468.9C130 449 97.9 404.3 96.1 352z"/><path fill="currentColor" d="M267.8 146.2C277.9 125.5 297.8 112 320 112C342.2 112 362.2 125.5 372.2 146.2C374.2 150.3 377.8 153.4 382.2 154.6C386.6 155.8 391.3 155.2 395.1 152.7C403.9 147.1 414 144 424.9 144C452.7 144 477 165.2 482.7 195C484.2 202.7 491 208.2 498.8 208C499.2 208 499.5 208 499.9 208C523.2 208 543.9 228.5 543.9 256C543.9 264.8 551.1 272 559.9 272C568.7 272 575.9 264.8 575.9 256C575.9 216.6 548.4 182.5 511 176.8C498.9 139.6 465.4 112 424.9 112C413.5 112 402.6 114.2 392.6 118.2C376.2 95.2 349.9 80 319.9 80C289.9 80 263.7 95.2 247.2 118.2C237.2 114.2 226.3 112 214.9 112C174.4 112 140.9 139.6 128.8 176.8C91.4 182.5 63.9 216.6 63.9 256C63.9 264.8 71.1 272 79.9 272C88.7 272 95.9 264.8 95.9 256C95.9 228.5 116.6 208 139.9 208C140.3 208 140.6 208 141 208C148.8 208.2 155.7 202.7 157.1 195C162.8 165.2 187.1 144 214.9 144C225.7 144 235.9 147.2 244.7 152.7C248.5 155.1 253.3 155.8 257.6 154.6C261.9 153.4 265.6 150.3 267.6 146.2zM193.3 489.1C190.6 480.1 184.1 472.7 175.5 468.9C130 449 97.9 404.3 96.1 352L543.9 352C542.1 404.3 510 449 464.5 468.9C455.9 472.7 449.3 480.1 446.7 489.1C442.8 502.3 430.5 512 416 512L224 512C209.5 512 197.3 502.4 193.3 489.1zM91.4 320C76.3 320 64 332.3 64 347.4C64 414.9 104.6 472.8 162.6 498.3C170.5 524.7 195 544 224 544L416 544C445 544 469.5 524.7 477.4 498.3C535.5 472.9 576 414.9 576 347.5C576 332.4 563.7 320.1 548.6 320.1L91.4 320z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M128 256L128 320L224 320L224 256L128 256zM128 352L128 416L224 416L224 352L128 352zM128 448L128 480C128 497.7 142.3 512 160 512L224 512L224 448L128 448zM256 256L256 320L384 320L384 256L256 256zM256 352L256 416L384 416L384 352L256 352zM256 448L256 512L384 512L384 448L256 448zM416 256L416 320L512 320L512 256L416 256zM416 352L416 416L512 416L512 352L416 352zM416 448L416 512L480 512C497.7 512 512 497.7 512 480L512 448L416 448z"/><path fill="currentColor" d="M208 64C216.8 64 224 71.2 224 80L224 128L416 128L416 80C416 71.2 423.2 64 432 64C440.8 64 448 71.2 448 80L448 128L480 128C515.3 128 544 156.7 544 192L544 480C544 515.3 515.3 544 480 544L160 544C124.7 544 96 515.3 96 480L96 192C96 156.7 124.7 128 160 128L192 128L192 80C192 71.2 199.2 64 208 64zM480 160L160 160C142.3 160 128 174.3 128 192L128 224L512 224L512 192C512 174.3 497.7 160 480 160zM512 256L416 256L416 320L512 320L512 256zM512 352L416 352L416 416L512 416L512 352zM512 448L416 448L416 512L480 512C497.7 512 512 497.7 512 480L512 448zM384 416L384 352L256 352L256 416L384 416zM256 448L256 512L384 512L384 448L256 448zM224 416L224 352L128 352L128 416L224 416zM128 448L128 480C128 497.7 142.3 512 160 512L224 512L224 448L128 448zM128 320L224 320L224 256L128 256L128 320zM256 320L384 320L384 256L256 256L256 320z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M84 224L84 252L588 252L588 224C588 162.1 537.9 112 476 112L469 112L469 140C469 147.7 462.7 154 455 154C447.3 154 441 147.7 441 140L441 112L224 112L224 140C224 147.7 217.7 154 210 154C202.3 154 196 147.7 196 140L196 112C134.1 112 84 162.1 84 224zM84 280L84 476C84 537.9 134.1 588 196 588L476 588C537.9 588 588 537.9 588 476L588 280L84 280z"/><path fill="currentColor" d="M210 56C202.3 56 196 62.3 196 70L196 140C196 147.7 202.3 154 210 154C217.7 154 224 147.7 224 140L224 70C224 62.3 217.7 56 210 56zM455 56C447.3 56 441 62.3 441 70L441 140C441 147.7 447.3 154 455 154C462.7 154 469 147.7 469 140L469 70C469 62.3 462.7 56 455 56zM84 252C76.3 252 70 258.3 70 266C70 273.7 76.3 280 84 280L588 280C595.7 280 602 273.7 602 266C602 258.3 595.7 252 588 252L84 252z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M160.7 140.2L202.6 412.4C203 415 203.6 417.6 204.5 420L499.9 420C535.2 420 566 396 574.6 361.7L614.5 202C615.5 198 616 194 616 189.9C616 162.4 593.7 140 566.1 140L162.1 140C161.6 140 161.1 140.1 160.6 140.2z"/><path fill="currentColor" d="M56 98C56 90.3 62.3 84 70 84L110 84C134.2 84 154.8 101.6 158.4 125.5L202.5 412.3C205.7 432.8 223.3 447.9 244 447.9L546 447.9C553.7 447.9 560 454.2 560 461.9C560 469.6 553.7 475.9 546 475.9L244 475.9C209.4 475.9 180.1 450.7 174.8 416.5L130.7 129.8C129.2 119.6 120.3 112 110 112L70 112C62.3 112 56 105.7 56 98zM252 588C267.5 588 280 575.5 280 560C280 544.5 267.5 532 252 532C236.5 532 224 544.5 224 560C224 575.5 236.5 588 252 588zM252 504C282.9 504 308 529.1 308 560C308 590.9 282.9 616 252 616C221.1 616 196 590.9 196 560C196 529.1 221.1 504 252 504zM532 560C532 544.5 519.5 532 504 532C488.5 532 476 544.5 476 560C476 575.5 488.5 588 504 588C519.5 588 532 575.5 532 560zM448 560C448 529.1 473.1 504 504 504C534.9 504 560 529.1 560 560C560 590.9 534.9 616 504 616C473.1 616 448 590.9 448 560z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<circle cx="5" cy="12" r="2"/>
|
||||||
|
<circle cx="12" cy="12" r="2"/>
|
||||||
|
<circle cx="19" cy="12" r="2"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M241 87.1l15 20.7 15-20.7C296 52.5 336.2 32 378.9 32 452.4 32 512 91.6 512 165.1l0 2.6c0 112.2-139.9 242.5-212.9 298.2-12.4 9.4-27.6 14.1-43.1 14.1s-30.8-4.6-43.1-14.1C139.9 410.2 0 279.9 0 167.7l0-2.6C0 91.6 59.6 32 133.1 32 175.8 32 216 52.5 241 87.1z"/></svg>
|
||||||
|
After Width: | Height: | Size: 521 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M272 70.1C281.1 61.9 294.9 61.9 304 70.1L533.2 275.6C521.2 273.2 508.7 272 496 272C484 272 472.2 273.1 460.9 275.2L288 120.2L128 263.7L128 512C128 520.8 135.2 528 144 528L192 528L192 424C192 384.2 224.2 352 264 352L312 352C320.8 352 329.2 353.6 336.9 356.4C327.8 369.9 320.3 384.5 314.8 400.1C313.9 400 312.9 399.9 311.9 399.9L263.9 399.9C250.6 399.9 239.9 410.6 239.9 423.9L239.9 527.9L314.8 527.9C320.9 545.2 329.4 561.3 339.9 575.9L143.9 575.9C108.6 575.9 79.9 547.2 79.9 511.9L79.9 306.6L71.9 313.8C62 322.6 46.9 321.8 38 312C29.1 302.2 30 287 39.8 278.1L272 70.1zM496 320C575.5 320 640 384.5 640 464C640 543.5 575.5 608 496 608C416.5 608 352 543.5 352 464C352 384.5 416.5 320 496 320zM555.3 427.3C561.5 421.1 561.5 410.9 555.3 404.7C549.1 398.5 538.9 398.5 532.7 404.7L496 441.4L459.3 404.7C453.1 398.5 442.9 398.5 436.7 404.7C430.5 410.9 430.5 421.1 436.7 427.3L473.4 464L436.7 500.7C430.5 506.9 430.5 517.1 436.7 523.3C442.9 529.5 453.1 529.5 459.3 523.3L496 486.6L532.7 523.3C538.9 529.5 549.1 529.5 555.3 523.3C561.5 517.1 561.5 506.9 555.3 500.7L518.6 464L555.3 427.3z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M84 303.5L336 101.9L588 303.5L588 476C588 537.9 537.9 588 476 588L448 588L448 434C448 395.3 416.7 364 378 364L294 364C255.3 364 224 395.3 224 434L224 588L196 588C134.1 588 84 537.9 84 476L84 303.5zM252 434C252 410.8 270.8 392 294 392L378 392C401.2 392 420 410.8 420 434L420 588L252 588L252 434z"/><path fill="currentColor" d="M344.7 73.1C339.6 69 332.3 69 327.2 73.1L47.2 297.1C41.2 301.9 40.2 310.7 45 316.8C49.8 322.9 58.6 323.8 64.7 319L336 102L607.3 319C613.3 323.8 622.1 322.9 627 316.8C631.9 310.7 630.9 302 624.8 297.1L344.8 73.1zM252 434C252 410.8 270.8 392 294 392L378 392C401.2 392 420 410.8 420 434L420 588C420 595.7 426.3 602 434 602C441.7 602 448 595.7 448 588L448 434C448 395.3 416.7 364 378 364L294 364C255.3 364 224 395.3 224 434L224 588C224 595.7 230.3 602 238 602C245.7 602 252 595.7 252 588L252 434z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 2.6 2 8l10 5.4L22 8 12 2.6Zm-7.9 8.6L2 12.4l10 5.4 10-5.4-2.1-1.2L12 15.4 4.1 11.2Zm0 4.4L2 16.8l10 5.4 10-5.4-2.1-1.2L12 19.8 4.1 15.6Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 243 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M471.3 6.7C477.7 .6 487-1.6 495.6 1.2 505.4 4.5 512 13.7 512 24l0 186.9c0 131.2-108.1 237.1-238.8 237.1-77 0-143.4-49.5-167.5-118.7-35.4 30.8-57.7 76.1-57.7 126.7 0 13.3-10.7 24-24 24S0 469.3 0 456C0 381.1 38.2 315.1 96.1 276.3 131.4 252.7 173.5 240 216 240l80 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-80 0c-39.7 0-77.3 8.8-111 24.5 23.3-70 89.2-120.5 167-120.5 66.4 0 115.8-22.1 148.7-44 19.2-12.8 35.5-28.1 50.7-45.3z"/></svg>
|
||||||
|
After Width: | Height: | Size: 685 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M16 64C16 28.7 44.7 0 80 0L304 0c35.3 0 64 28.7 64 64l0 384c0 35.3-28.7 64-64 64L80 512c-35.3 0-64-28.7-64-64L16 64zm64 0l0 304 224 0 0-304-224 0zM192 472c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32z"/></svg>
|
||||||
|
After Width: | Height: | Size: 487 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M512 32C512 140.1 435.4 230.3 333.6 251.4 325.7 193.3 299.6 141 261.1 100.5 301.2 40 369.9 0 448 0l32 0c17.7 0 32 14.3 32 32zM0 96C0 78.3 14.3 64 32 64l32 0c123.7 0 224 100.3 224 224l0 192c0 17.7-14.3 32-32 32s-32-14.3-32-32l0-160C100.3 320 0 219.7 0 96z"/></svg>
|
||||||
|
After Width: | Height: | Size: 522 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 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="M0 0L72 0C125 0 168 43 168 96L168 120L576 120L516.9 359.3C506.2 402 467.8 432 423.8 432L168 432C115 432 72 389 72 336L72 96C72 69.5 50.5 48 24 48L0 48L0 0zM168 168L168 336C168 362.5 189.5 384 216 384L375.8 384C397.8 384 417 369 422.4 347.6L467.3 168L168 168zM240 528C240 554.5 225.6 576 192 576C158.4 576 144 554.5 144 528C144 501.5 158.4 480 192 480C225.6 480 240 501.5 240 528zM384 576C417.6 576 432 554.5 432 528C432 501.5 417.6 480 384 480C350.4 480 336 501.5 336 528C336 554.5 350.4 576 384 576z"/></svg>
|
||||||
|
After Width: | Height: | Size: 768 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M32 64C14.3 64 0 78.3 0 96s14.3 32 32 32l86.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 128c17.7 0 32-14.3 32-32s-14.3-32-32-32L265.3 64C253 35.7 224.8 16 192 16s-61 19.7-73.3 48L32 64zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l246.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48l54.7 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-54.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 224zm0 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l54.7 0c12.3 28.3 40.5 48 73.3 48s61-19.7 73.3-48L480 448c17.7 0 32-14.3 32-32s-14.3-32-32-32l-246.7 0c-12.3-28.3-40.5-48-73.3-48s-61 19.7-73.3 48L32 384z"/></svg>
|
||||||
|
After Width: | Height: | Size: 850 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M86.2 320L175.1 361.1C178.6 362.7 181.3 365.5 182.9 368.9L224 457.8L265.1 368.9C266.7 365.5 269.5 362.7 272.9 361.1L361.8 320L272.9 278.9C269.4 277.3 266.7 274.5 265.1 271.1L224 182.2L182.9 271.1C181.3 274.6 178.5 277.3 175.1 278.9L86.2 320z"/><path fill="currentColor" d="M512 48C520.8 48 528 55.2 528 64L528 112L576 112C584.8 112 592 119.2 592 128C592 136.8 584.8 144 576 144L528 144L528 192C528 200.8 520.8 208 512 208C503.2 208 496 200.8 496 192L496 144L448 144C439.2 144 432 136.8 432 128C432 119.2 439.2 112 448 112L496 112L496 64C496 55.2 503.2 48 512 48zM224 128C230.2 128 235.9 131.6 238.5 137.3L291.6 252.3L406.6 305.4C412.3 308 415.9 313.7 415.9 319.9C415.9 326.1 412.3 331.8 406.6 334.4L291.6 387.5L238.5 502.5C235.9 508.2 230.2 511.8 224 511.8C217.8 511.8 212.1 508.2 209.5 502.5L156.4 387.5L41.4 334.4C35.6 331.9 32 326.2 32 320C32 313.8 35.6 308.1 41.3 305.5L156.3 252.4L209.4 137.4C212 131.7 217.7 128.1 223.9 128.1zM224 182.2L182.9 271.1C181.3 274.6 178.5 277.3 175.1 278.9L86.2 320L175.1 361.1C178.6 362.7 181.3 365.5 182.9 368.9L224 457.8L265.1 368.9C266.7 365.5 269.5 362.7 272.9 361.1L361.8 320L272.9 278.9C269.4 277.3 266.7 274.5 265.1 271.1L224 182.2zM496 448L496 496L544 496C552.8 496 560 503.2 560 512C560 520.8 552.8 528 544 528L496 528L496 576C496 584.8 488.8 592 480 592C471.2 592 464 584.8 464 576L464 528L416 528C407.2 528 400 520.8 400 512C400 503.2 407.2 496 416 496L464 496L464 448C464 439.2 471.2 432 480 432C488.8 432 496 439.2 496 448z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M112 112L112 252C112 313.9 162.1 364 224 364L224 350C224 342.3 230.3 336 238 336C245.7 336 252 342.3 252 350L252 364C313.9 364 364 313.9 364 252L364 112C364 81.1 338.9 56 308 56L294 56L294 238C294 245.7 287.7 252 280 252C272.3 252 266 245.7 266 238L266 56L210 56L210 238C210 245.7 203.7 252 196 252C188.3 252 182 245.7 182 238L182 56L168 56C137.1 56 112 81.1 112 112zM420 182L420 392C420 422.9 445.1 448 476 448L532 448L532 70C532 62.4 538.1 56.2 545.7 56C476.3 56.2 420 112.5 420 182z"/><path fill="currentColor" d="M210 56C210 48.3 203.7 42 196 42C188.3 42 182 48.3 182 56L182 238C182 245.7 188.3 252 196 252C203.7 252 210 245.7 210 238L210 56zM294 56C294 48.3 287.7 42 280 42C272.3 42 266 48.3 266 56L266 238C266 245.7 272.3 252 280 252C287.7 252 294 245.7 294 238L294 56zM560 70C560 62.3 553.7 56 546 56C538.3 56 532 62.3 532 70L532 602C532 609.7 538.3 616 546 616C553.7 616 560 609.7 560 602L560 70zM238 336C230.3 336 224 342.3 224 350L224 602C224 609.7 230.3 616 238 616C245.7 616 252 609.7 252 602L252 350C252 342.3 245.7 336 238 336z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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 |
@@ -0,0 +1,360 @@
|
|||||||
|
(() => {
|
||||||
|
const getCsrfToken = () => {
|
||||||
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
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 board = document.querySelector(".week-board");
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
let draggedEntry = null;
|
||||||
|
|
||||||
|
board.querySelectorAll(".draggable-plan-entry").forEach((entry) => {
|
||||||
|
if (entry.getAttribute("draggable") !== "true") return;
|
||||||
|
entry.addEventListener("dragstart", () => {
|
||||||
|
draggedEntry = entry;
|
||||||
|
entry.classList.add("is-dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.addEventListener("dragend", () => {
|
||||||
|
entry.classList.remove("is-dragging");
|
||||||
|
draggedEntry = null;
|
||||||
|
board.querySelectorAll(".drop-slot").forEach((slot) => slot.classList.remove("is-drag-over"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
board.querySelectorAll(".drop-slot").forEach((slot) => {
|
||||||
|
slot.addEventListener("dragover", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!draggedEntry) return;
|
||||||
|
slot.classList.add("is-drag-over");
|
||||||
|
});
|
||||||
|
|
||||||
|
slot.addEventListener("dragleave", () => {
|
||||||
|
slot.classList.remove("is-drag-over");
|
||||||
|
});
|
||||||
|
|
||||||
|
slot.addEventListener("drop", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
slot.classList.remove("is-drag-over");
|
||||||
|
if (!draggedEntry) return;
|
||||||
|
|
||||||
|
// Keep DnD lightweight: move on the server, then refresh into the canonical rendered state.
|
||||||
|
const moveUrl = draggedEntry.dataset.moveUrl;
|
||||||
|
const payload = new URLSearchParams({
|
||||||
|
csrf_token: getCsrfToken(),
|
||||||
|
target_date: slot.dataset.targetDate,
|
||||||
|
target_daypart_id: slot.dataset.targetDaypartId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(moveUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
body: payload.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("move failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
rememberScroll();
|
||||||
|
if (result.redirect_url) {
|
||||||
|
window.location.href = result.redirect_url;
|
||||||
|
} else {
|
||||||
|
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) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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", () => {
|
||||||
|
restoreScroll();
|
||||||
|
initWeekDragAndDrop();
|
||||||
|
initWeekCopyForward();
|
||||||
|
initWeekSlotPicker();
|
||||||
|
initWeekEntryDialogs();
|
||||||
|
initDaySnackReveal();
|
||||||
|
initWeekSnackReveal();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
(() => {
|
||||||
|
const getCsrfToken = () => {
|
||||||
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
return meta ? meta.getAttribute("content") : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPushPublicKey = () => {
|
||||||
|
const meta = document.querySelector('meta[name="nouri-push-public-key"]');
|
||||||
|
return meta ? meta.getAttribute("content") : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlBase64ToUint8Array = (base64String) => {
|
||||||
|
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const rawData = window.atob(base64);
|
||||||
|
return Uint8Array.from([...rawData].map((character) => character.charCodeAt(0)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerServiceWorker = async () => {
|
||||||
|
if (!("serviceWorker" in navigator)) return null;
|
||||||
|
return navigator.serviceWorker.register("/service-worker.js");
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribeToPush = async () => {
|
||||||
|
const publicKey = getPushPublicKey();
|
||||||
|
if (!publicKey || !("serviceWorker" in navigator) || !("PushManager" in window)) return;
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== "granted") return;
|
||||||
|
|
||||||
|
const existing = await registration.pushManager.getSubscription();
|
||||||
|
const subscription = existing || await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||||
|
});
|
||||||
|
const subscriptionJson = subscription.toJSON();
|
||||||
|
const payload = new URLSearchParams({
|
||||||
|
csrf_token: getCsrfToken(),
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
p256dh: subscriptionJson.keys && subscriptionJson.keys.p256dh ? subscriptionJson.keys.p256dh : "",
|
||||||
|
auth: subscriptionJson.keys && subscriptionJson.keys.auth ? subscriptionJson.keys.auth : "",
|
||||||
|
});
|
||||||
|
await fetch("/push/subscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
body: payload.toString(),
|
||||||
|
});
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsubscribeFromPush = async () => {
|
||||||
|
if (!("serviceWorker" in navigator)) return;
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
const payload = new URLSearchParams({ csrf_token: getCsrfToken() });
|
||||||
|
if (subscription) {
|
||||||
|
payload.set("endpoint", subscription.endpoint);
|
||||||
|
await subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
await fetch("/push/unsubscribe", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
body: payload.toString(),
|
||||||
|
});
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
registerServiceWorker();
|
||||||
|
|
||||||
|
const enableButton = document.querySelector("[data-push-enable]");
|
||||||
|
const disableButton = document.querySelector("[data-push-disable]");
|
||||||
|
|
||||||
|
if (enableButton) {
|
||||||
|
enableButton.addEventListener("click", () => {
|
||||||
|
subscribeToPush().catch(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disableButton) {
|
||||||
|
disableButton.addEventListener("click", () => {
|
||||||
|
unsubscribeFromPush().catch(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const storageKey = "nouri-theme";
|
const storageKey = "nouri-theme";
|
||||||
const toggle = () => document.querySelector("[data-theme-toggle]");
|
const toggles = () => Array.from(document.querySelectorAll("[data-theme-toggle]"));
|
||||||
|
|
||||||
const applyTheme = (theme) => {
|
const applyTheme = (theme) => {
|
||||||
const resolved = theme || localStorage.getItem(storageKey) || "auto";
|
const resolved = theme || localStorage.getItem(storageKey) || "auto";
|
||||||
@@ -11,17 +11,29 @@
|
|||||||
: resolved;
|
: resolved;
|
||||||
root.dataset.theme = finalTheme;
|
root.dataset.theme = finalTheme;
|
||||||
|
|
||||||
const button = toggle();
|
toggles().forEach((button) => {
|
||||||
if (button) {
|
const nextModeLabel = finalTheme === "dark" ? "Hell" : "Dunkel";
|
||||||
button.textContent = 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`);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
applyTheme();
|
applyTheme();
|
||||||
const button = toggle();
|
toggles().forEach((button) => {
|
||||||
if (!button) return;
|
|
||||||
|
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const current = root.dataset.theme === "dark" ? "dark" : "light";
|
const current = root.dataset.theme === "dark" ? "dark" : "light";
|
||||||
const next = current === "dark" ? "light" : "dark";
|
const next = current === "dark" ? "light" : "dark";
|
||||||
@@ -29,4 +41,5 @@
|
|||||||
applyTheme(next);
|
applyTheme(next);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,349 @@
|
|||||||
|
(() => {
|
||||||
|
const scrollStorageKey = () => `nouri-scroll:${window.location.pathname}${window.location.search}`;
|
||||||
|
|
||||||
|
const initPostFormScrollMemory = () => {
|
||||||
|
const restoreFromUrl = () => {
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
const rawScroll = currentUrl.searchParams.get("_scroll");
|
||||||
|
if (!rawScroll) return false;
|
||||||
|
|
||||||
|
const scrollValue = Number.parseInt(rawScroll, 10);
|
||||||
|
currentUrl.searchParams.delete("_scroll");
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (Number.isFinite(scrollValue)) {
|
||||||
|
window.scrollTo({ top: scrollValue, left: 0, behavior: "auto" });
|
||||||
|
}
|
||||||
|
window.history.replaceState({}, "", currentUrl.toString());
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreFromStorage = () => {
|
||||||
|
const savedScroll = sessionStorage.getItem(scrollStorageKey());
|
||||||
|
if (!savedScroll) return;
|
||||||
|
|
||||||
|
sessionStorage.removeItem(scrollStorageKey());
|
||||||
|
const scrollValue = Number.parseInt(savedScroll, 10);
|
||||||
|
if (!Number.isFinite(scrollValue)) return;
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({ top: scrollValue, left: 0, behavior: "auto" });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!restoreFromUrl()) {
|
||||||
|
restoreFromStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("submit", (event) => {
|
||||||
|
const form = event.target;
|
||||||
|
if (!(form instanceof HTMLFormElement)) return;
|
||||||
|
|
||||||
|
const method = (form.getAttribute("method") || "get").toLowerCase();
|
||||||
|
if (method !== "post") return;
|
||||||
|
|
||||||
|
const scrollValue = String(Math.round(window.scrollY));
|
||||||
|
sessionStorage.setItem(scrollStorageKey(), scrollValue);
|
||||||
|
|
||||||
|
let scrollInput = form.querySelector('input[name="_scroll"]');
|
||||||
|
if (!(scrollInput instanceof HTMLInputElement)) {
|
||||||
|
scrollInput = document.createElement("input");
|
||||||
|
scrollInput.type = "hidden";
|
||||||
|
scrollInput.name = "_scroll";
|
||||||
|
form.appendChild(scrollInput);
|
||||||
|
}
|
||||||
|
scrollInput.value = scrollValue;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initMobileSheet = () => {
|
||||||
|
const sheet = document.querySelector("[data-mobile-sheet]");
|
||||||
|
const navStack = document.querySelector("[data-mobile-nav-stack]");
|
||||||
|
const openButtons = document.querySelectorAll("[data-mobile-sheet-open]");
|
||||||
|
if (!sheet || !navStack || !openButtons.length) return;
|
||||||
|
|
||||||
|
const closeSheet = () => {
|
||||||
|
sheet.hidden = true;
|
||||||
|
navStack.classList.remove("is-open");
|
||||||
|
openButtons.forEach((button) => button.classList.remove("is-open"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSheet = () => {
|
||||||
|
sheet.hidden = false;
|
||||||
|
navStack.classList.add("is-open");
|
||||||
|
openButtons.forEach((button) => button.classList.add("is-open"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSheet = () => {
|
||||||
|
if (sheet.hidden) {
|
||||||
|
openSheet();
|
||||||
|
} else {
|
||||||
|
closeSheet();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
openButtons.forEach((button) => {
|
||||||
|
button.addEventListener("click", toggleSheet);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeSheet();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sheet.querySelectorAll("a").forEach((link) => {
|
||||||
|
link.addEventListener("click", closeSheet);
|
||||||
|
});
|
||||||
|
sheet.querySelectorAll("button[data-theme-toggle]").forEach((button) => {
|
||||||
|
button.addEventListener("click", closeSheet);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initFilterInputs = () => {
|
||||||
|
document.querySelectorAll("[data-filter-input]").forEach((input) => {
|
||||||
|
const listSelector = input.getAttribute("data-filter-target");
|
||||||
|
if (!listSelector) return;
|
||||||
|
const container = document.querySelector(listSelector);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const items = Array.from(container.querySelectorAll("[data-filter-label]"));
|
||||||
|
const filterGroups = Array.from(container.querySelectorAll("[data-filter-group]"));
|
||||||
|
const resultLimit = Number.parseInt(input.getAttribute("data-filter-limit") || "", 10);
|
||||||
|
const hasLimit = Number.isFinite(resultLimit) && resultLimit > 0;
|
||||||
|
const hideWhenEmpty = input.hasAttribute("data-filter-hide-empty");
|
||||||
|
|
||||||
|
const scoreItem = (label, term) => {
|
||||||
|
if (label === term) return 0;
|
||||||
|
if (label.startsWith(term)) return 1;
|
||||||
|
if (label.split(/\s+/).some((part) => part.startsWith(term))) return 2;
|
||||||
|
if (label.includes(term)) return 3;
|
||||||
|
return 99;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncGroups = () => {
|
||||||
|
filterGroups.forEach((group) => {
|
||||||
|
const visibleChildren = Array.from(group.querySelectorAll("[data-filter-label]")).some((item) => !item.hidden);
|
||||||
|
const card = group.closest(".component-group, .template-list-card, .panel, .planner-subsection");
|
||||||
|
if (card) {
|
||||||
|
card.hidden = !visibleChildren;
|
||||||
|
} else {
|
||||||
|
group.hidden = !visibleChildren;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFilter = () => {
|
||||||
|
const term = input.value.trim().toLowerCase();
|
||||||
|
if (!term) {
|
||||||
|
items.forEach((item, index) => {
|
||||||
|
item.hidden = hideWhenEmpty || (hasLimit ? index >= resultLimit : false);
|
||||||
|
});
|
||||||
|
container.hidden = hideWhenEmpty;
|
||||||
|
syncGroups();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.hidden = false;
|
||||||
|
|
||||||
|
const rankedMatches = items
|
||||||
|
.map((item, index) => {
|
||||||
|
const haystack = (item.getAttribute("data-filter-label") || "").toLowerCase();
|
||||||
|
const score = scoreItem(haystack, term);
|
||||||
|
return { item, index, score, matches: score < 99 };
|
||||||
|
})
|
||||||
|
.filter((entry) => entry.matches)
|
||||||
|
.sort((left, right) => left.score - right.score || left.index - right.index);
|
||||||
|
|
||||||
|
const allowedItems = new Set(
|
||||||
|
(hasLimit ? rankedMatches.slice(0, resultLimit) : rankedMatches).map((entry) => entry.item)
|
||||||
|
);
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
item.hidden = !allowedItems.has(item);
|
||||||
|
});
|
||||||
|
syncGroups();
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("input", applyFilter);
|
||||||
|
applyFilter();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initSelectedPreviews = () => {
|
||||||
|
document.querySelectorAll("[data-selected-preview]").forEach((preview) => {
|
||||||
|
const form = preview.closest("form");
|
||||||
|
const sourceSelector = preview.getAttribute("data-selected-preview");
|
||||||
|
if (!form || !sourceSelector) return;
|
||||||
|
|
||||||
|
const source = document.querySelector(sourceSelector);
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
const emptyText = preview.querySelector("[data-selected-preview-empty]");
|
||||||
|
const cards = Array.from(preview.querySelectorAll("[data-selected-preview-card]"));
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
let visibleCount = 0;
|
||||||
|
cards.forEach((card) => {
|
||||||
|
const value = card.getAttribute("data-selected-preview-card");
|
||||||
|
const input = value
|
||||||
|
? Array.from(form.querySelectorAll('input[name="component_ids"]')).find((candidate) => candidate.value === value)
|
||||||
|
: null;
|
||||||
|
const checked = input instanceof HTMLInputElement && input.checked;
|
||||||
|
card.hidden = !checked;
|
||||||
|
if (checked) visibleCount += 1;
|
||||||
|
});
|
||||||
|
if (emptyText) {
|
||||||
|
emptyText.hidden = visibleCount > 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
source.addEventListener("change", (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof HTMLInputElement && target.name === "component_ids") {
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
preview.addEventListener("click", (event) => {
|
||||||
|
const button = event.target.closest("[data-uncheck-component]");
|
||||||
|
if (!(button instanceof HTMLElement)) return;
|
||||||
|
const value = button.getAttribute("data-uncheck-component");
|
||||||
|
if (!value) return;
|
||||||
|
const input = Array.from(form.querySelectorAll('input[name="component_ids"]')).find((candidate) => candidate.value === value);
|
||||||
|
if (input instanceof HTMLInputElement) {
|
||||||
|
input.checked = false;
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initCreateFromSearch = () => {
|
||||||
|
document.querySelectorAll("[data-create-from]").forEach((container) => {
|
||||||
|
const inputSelector = container.getAttribute("data-create-from");
|
||||||
|
if (!inputSelector) return;
|
||||||
|
const input = document.querySelector(inputSelector);
|
||||||
|
const hiddenName = container.querySelector("[data-create-name]");
|
||||||
|
if (!(input instanceof HTMLInputElement) || !(hiddenName instanceof HTMLInputElement)) return;
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
const value = input.value.trim().replace(/\s+/g, " ");
|
||||||
|
hiddenName.value = value;
|
||||||
|
container.hidden = value.length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("input", sync);
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const initDialogs = () => {
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const openButton = event.target.closest("[data-dialog-open]");
|
||||||
|
if (openButton instanceof HTMLElement) {
|
||||||
|
const dialogId = openButton.getAttribute("data-dialog-open");
|
||||||
|
if (!dialogId) return;
|
||||||
|
const dialog = document.getElementById(dialogId);
|
||||||
|
if (dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeButton = event.target.closest("[data-dialog-close]");
|
||||||
|
if (closeButton instanceof HTMLElement) {
|
||||||
|
const dialog = closeButton.closest("dialog");
|
||||||
|
if (dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
const openButton = target.closest("[data-dialog-open]");
|
||||||
|
if (!(openButton instanceof HTMLElement)) return;
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
const dialogId = openButton.getAttribute("data-dialog-open");
|
||||||
|
if (!dialogId) return;
|
||||||
|
const dialog = document.getElementById(dialogId);
|
||||||
|
if (dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("dialog").forEach((dialog) => {
|
||||||
|
dialog.addEventListener("click", (event) => {
|
||||||
|
if (event.target === dialog && dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
document.querySelectorAll("dialog[open]").forEach((dialog) => {
|
||||||
|
if (dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
initPostFormScrollMemory();
|
||||||
|
initMobileSheet();
|
||||||
|
initFilterInputs();
|
||||||
|
initSelectedPreviews();
|
||||||
|
initCreateFromSearch();
|
||||||
|
initIosPullToRefresh();
|
||||||
|
initDialogs();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "Nouri",
|
||||||
|
"short_name": "Nouri",
|
||||||
|
"description": "einfach essen planen",
|
||||||
|
"lang": "de",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"display_override": ["standalone", "minimal-ui"],
|
||||||
|
"background_color": "#fff6ef",
|
||||||
|
"theme_color": "#de9862",
|
||||||
|
"categories": ["food", "lifestyle", "productivity"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/brand/pwa-180.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/brand/pwa-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/brand/pwa-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/brand/pwa-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Nouri offline</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #fff6ef;
|
||||||
|
--surface: rgba(255, 255, 255, 0.92);
|
||||||
|
--line: rgba(126, 104, 85, 0.14);
|
||||||
|
--text: #352d2b;
|
||||||
|
--muted: #7d7069;
|
||||||
|
--accent: #de9862;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(255, 205, 174, 0.42), transparent 24rem),
|
||||||
|
radial-gradient(circle at 90% 8%, rgba(190, 226, 203, 0.34), transparent 24rem),
|
||||||
|
linear-gradient(180deg, var(--bg), #fdf0e6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: min(28rem, 100%);
|
||||||
|
padding: 1.4rem;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: 0 22px 48px rgba(125, 92, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
font-family: "Iowan Old Style", "Palatino Linotype", serif;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.82rem 1.1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="card">
|
||||||
|
<h1>Nouri ist gerade kurz offline</h1>
|
||||||
|
<p>Die App bleibt da und versucht es gleich wieder. Sobald die Verbindung zurück ist, kannst du normal weitermachen.</p>
|
||||||
|
<p class="muted">Ein Teil der Oberfläche ist schon lokal verfügbar. Für aktuelle Haushaltsdaten braucht Nouri aber wieder eine Verbindung.</p>
|
||||||
|
<a href="/">Erneut versuchen</a>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
const CACHE_NAME = "nouri-v1-3-3";
|
||||||
|
const OFFLINE_URL = "/static/pwa/offline.html";
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
"/static/css/styles.css",
|
||||||
|
"/static/js/theme.js",
|
||||||
|
"/static/js/ui.js",
|
||||||
|
"/static/js/planner.js",
|
||||||
|
"/static/js/pwa.js",
|
||||||
|
"/static/brand/pwa-180.png",
|
||||||
|
"/static/brand/pwa-192.png",
|
||||||
|
"/static/brand/pwa-512.png",
|
||||||
|
"/static/brand/pwa-maskable-512.png",
|
||||||
|
"/static/brand/pwa-badge.png",
|
||||||
|
"/static/brand/favicon.svg",
|
||||||
|
"/app.webmanifest",
|
||||||
|
OFFLINE_URL,
|
||||||
|
];
|
||||||
|
|
||||||
|
const cacheFirst = async (request) => {
|
||||||
|
const cached = await caches.match(request);
|
||||||
|
if (cached) return cached;
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response && response.ok && response.type === "basic") {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)).then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))
|
||||||
|
).then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.method !== "GET") return;
|
||||||
|
|
||||||
|
const requestUrl = new URL(event.request.url);
|
||||||
|
const isSameOrigin = requestUrl.origin === self.location.origin;
|
||||||
|
const isStaticAsset = isSameOrigin && (
|
||||||
|
requestUrl.pathname.startsWith("/static/")
|
||||||
|
|| requestUrl.pathname === "/app.webmanifest"
|
||||||
|
|| requestUrl.pathname === "/service-worker.js"
|
||||||
|
);
|
||||||
|
const isUpload = isSameOrigin && requestUrl.pathname.startsWith("/uploads/");
|
||||||
|
|
||||||
|
if (event.request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const copy = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, copy));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
return (await caches.match(event.request)) || caches.match(OFFLINE_URL);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isStaticAsset || isUpload) {
|
||||||
|
event.respondWith(cacheFirst(event.request));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("push", (event) => {
|
||||||
|
if (!event.data) return;
|
||||||
|
const data = event.data.json();
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(data.title || "Nouri", {
|
||||||
|
body: data.body || "",
|
||||||
|
icon: data.icon || "/static/brand/pwa-192.png",
|
||||||
|
badge: data.badge || "/static/brand/pwa-badge.png",
|
||||||
|
data: { url: data.url || "/" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (event) => {
|
||||||
|
event.notification.close();
|
||||||
|
const targetUrl = event.notification.data && event.notification.data.url ? event.notification.data.url : "/";
|
||||||
|
event.waitUntil(
|
||||||
|
clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.includes(targetUrl) && "focus" in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (clients.openWindow) {
|
||||||
|
return clients.openWindow(targetUrl);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Kategorien | Nouri{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Kategorien</p>
|
||||||
|
<h1>Kategorien global anpassen</h1>
|
||||||
|
<p class="lead">Hier pflegt ihr die Auswahl für Lebensmittel und Mahlzeiten. Bestehende Einträge bleiben auch dann erhalten, wenn eine Kategorie später pausiert wird.</p>
|
||||||
|
</div>
|
||||||
|
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Zur Nutzerverwaltung</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel compact-form-panel">
|
||||||
|
<form method="post" class="inline-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<label class="wide">
|
||||||
|
Neue Kategorie
|
||||||
|
<input type="text" name="name" placeholder="z. B. Süßes, Vorrat, Unterwegs">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Passt eher zu
|
||||||
|
<select name="builder_key">
|
||||||
|
{% for value, label in builder_options %}
|
||||||
|
<option value="{{ value }}">{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Kategorie ergänzen</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stack-list">
|
||||||
|
{% for category in categories %}
|
||||||
|
<article class="list-row stacked-mobile">
|
||||||
|
<div>
|
||||||
|
<strong>{{ category.name }}</strong>
|
||||||
|
<p class="muted">
|
||||||
|
{% if category.name in default_categories %}Teil der ruhigen Standardauswahl{% else %}Eigene Haushaltskategorie{% endif %}
|
||||||
|
· {{ builder_descriptions[category.builder_key] }}
|
||||||
|
</p>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ builder_descriptions[category.builder_key].split('.')[0] }}</span>
|
||||||
|
{% if category.is_active %}
|
||||||
|
<span class="chip status-home">Aktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="chip status-archived">Pausiert</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<form method="post" action="{{ url_for('admin.category_update', category_id=category.id) }}" class="inline-form inline-form-tight">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<label>
|
||||||
|
<span class="sr-only">Baustein</span>
|
||||||
|
<select name="builder_key">
|
||||||
|
{% for value, label in builder_options %}
|
||||||
|
<option value="{{ value }}" {% if category.builder_key == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="ghost-button" type="submit">Zuordnung speichern</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('admin.category_toggle', category_id=category.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="ghost-button" type="submit">
|
||||||
|
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% if category.name not in default_categories %}
|
||||||
|
<form method="post" action="{{ url_for('admin.category_delete', category_id=category.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="ghost-button" type="submit">Löschen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{% if user %}Nutzer bearbeiten{% else %}Nutzer anlegen{% endif %} | Nouri{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Nutzer verwalten</p>
|
||||||
|
<h1>{% if user %}{{ user.display_name or user.username }} bearbeiten{% else %}Neuen Nutzer anlegen{% endif %}</h1>
|
||||||
|
<p class="lead">Wenig Felder, klare Rollen und ein ruhiger Zugang für den gemeinsamen Haushalt.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<form method="post" class="stack-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<label>
|
||||||
|
Anzeigename
|
||||||
|
<input type="text" name="display_name" value="{{ form_data.display_name }}" autocomplete="name">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Benutzername
|
||||||
|
<input type="text" name="username" value="{{ form_data.username }}" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
E-Mail
|
||||||
|
<input type="email" name="email" value="{{ form_data.email }}" autocomplete="email">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Rolle
|
||||||
|
<select name="role">
|
||||||
|
{% for value, label in role_labels.items() %}
|
||||||
|
<option value="{{ value }}" {% if form_data.role == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inline-check">
|
||||||
|
<input type="checkbox" name="is_active" value="1" {% if form_data.is_active %}checked{% endif %}>
|
||||||
|
<span>Zugang aktiv</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{% if user %}Neues Passwort{% else %}Passwort{% endif %}
|
||||||
|
<input type="password" name="password" autocomplete="new-password" {% if not user %}required{% endif %}>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Passwort wiederholen
|
||||||
|
<input type="password" name="password_repeat" autocomplete="new-password" {% if not user %}required{% endif %}>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" name="save_mode" value="stay">Speichern</button>
|
||||||
|
<button class="secondary" type="submit" name="save_mode" value="close">Speichern und schließen</button>
|
||||||
|
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Zurück</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Nutzer verwalten | Nouri{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Nutzer verwalten</p>
|
||||||
|
<h1>Haushaltszugänge ruhig pflegen</h1>
|
||||||
|
<p class="lead">Admins können hier weitere Mitglieder anlegen, Rollen anpassen, Zugänge pausieren und die gemeinsamen Kategorien pflegen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<a class="button" href="{{ url_for('admin.user_create') }}">Neuen Nutzer anlegen</a>
|
||||||
|
<a class="button secondary" href="{{ url_for('admin.category_settings') }}">Kategorien</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stack-list">
|
||||||
|
{% for user in users %}
|
||||||
|
<article class="list-row stacked-mobile">
|
||||||
|
<div>
|
||||||
|
<strong>{{ user.display_name or user.username }}</strong>
|
||||||
|
<p class="muted">
|
||||||
|
{{ user.username }}
|
||||||
|
{% if user.email %} · {{ user.email }}{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ role_labels[user.role] }}</span>
|
||||||
|
{% if user.is_active %}
|
||||||
|
<span class="chip status-home">Aktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="chip status-archived">Pausiert</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if user.id == g.user.id %}
|
||||||
|
<span class="chip status-soft">Du</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<a class="ghost-button" href="{{ url_for('admin.user_edit', user_id=user.id) }}">Bearbeiten</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,25 +4,78 @@
|
|||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Archiv</p>
|
<p class="eyebrow">Archiv</p>
|
||||||
<h1>Fruehere Ideen bleiben greifbar</h1>
|
<h1>Frühere Ideen bleiben greifbar</h1>
|
||||||
<p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder auf die Einkaufsliste setzen.</p>
|
<p class="lead">Archiv bedeutet bewusst ausgeblendet, nicht verbraucht. Von hier aus lassen sich Dinge jederzeit wieder aktivieren.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel compact-form-panel">
|
||||||
|
<form method="get" class="filter-form">
|
||||||
|
<label class="wide">
|
||||||
|
Suche
|
||||||
|
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Bereich
|
||||||
|
<select name="kind">
|
||||||
|
{% for value, label in kind_options %}
|
||||||
|
<option value="{{ value }}" {% if selected_kind == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Sichtbarkeit
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button type="submit">Filtern</button>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.archive_view') }}">Zurücksetzen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
{% if items %}
|
{% if items %}
|
||||||
<section class="card-grid">
|
<section class="card-grid">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<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 %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-body">
|
<div class="item-body">
|
||||||
<h2>{{ item.name }}</h2>
|
<h2>{{ item.name }}</h2>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
@@ -40,20 +93,25 @@
|
|||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button type="submit">Wieder einkaufen</button>
|
<button type="submit">Auf Einkaufsliste</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if item.can_edit %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.can_edit %}
|
||||||
<form method="post" action="{{ url_for('main.item_restore', item_id=item.id) }}">
|
<form method="post" action="{{ url_for('main.item_restore', item_id=item.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Zur aktiven Liste</button>
|
<button class="ghost-button" type="submit">Wieder aktivieren</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="panel empty-panel">
|
<section class="panel empty-panel">
|
||||||
<h2>Das Archiv ist noch leer</h2>
|
<h2>Keine passenden Archiv-Einträge</h2>
|
||||||
<p>Sobald etwas als verbraucht markiert wird, bleibt es hier als spaetere Erinnerung erhalten.</p>
|
<p>Mit einer kurzen Suche findest du vertraute Dinge meist schnell wieder.</p>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,20 +3,24 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="auth-shell">
|
<section class="auth-shell">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<p class="eyebrow">Willkommen zurueck</p>
|
<p class="eyebrow">Willkommen zurück</p>
|
||||||
<h1>Ruhig wieder einsteigen</h1>
|
<h1>Ruhig wieder einsteigen</h1>
|
||||||
<p class="lead">Nouri hilft beim Erinnern, Sichtbar-Machen und Planen. Ohne Zahlen, ohne Druck.</p>
|
<p class="lead">Nouri bleibt ein kleiner, freundlicher Ort für euren Alltag rund um Essen, Einkauf und Planung.</p>
|
||||||
|
|
||||||
<form method="post" class="stack-form">
|
<form method="post" class="stack-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<label>
|
<label>
|
||||||
Benutzername
|
Benutzername oder E-Mail
|
||||||
<input type="text" name="username" autocomplete="username" required>
|
<input type="text" name="username" autocomplete="username" required>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Passwort
|
Passwort
|
||||||
<input type="password" name="password" autocomplete="current-password" required>
|
<input type="password" name="password" autocomplete="current-password" required>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="inline-check">
|
||||||
|
<input type="checkbox" name="remember_me" value="1">
|
||||||
|
<span>Angemeldet bleiben</span>
|
||||||
|
</label>
|
||||||
<button type="submit">Anmelden</button>
|
<button type="submit">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Mein Profil | Nouri{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Mein Profil</p>
|
||||||
|
<h1>{{ g.user.display_name or g.user.username }}</h1>
|
||||||
|
<p class="lead">Dein Zugang bleibt bewusst schlicht. Hier kannst du Namen, Login-Daten und Passwort pflegen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="intro-pills">
|
||||||
|
<span class="status-pill">{{ role_labels[g.user.role] }}</span>
|
||||||
|
{% if g.user.household_name %}
|
||||||
|
<span class="status-pill status-soft">{{ g.user.household_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel compact-form-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Optionen</h2>
|
||||||
|
<a href="{{ url_for('main.settings_view') }}">Zu den Einstellungen</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="two-column">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Basisdaten</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="stack-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<label>
|
||||||
|
Anzeigename
|
||||||
|
<input type="text" name="display_name" value="{{ g.user.display_name or '' }}" autocomplete="name">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Benutzername
|
||||||
|
<input type="text" name="username" value="{{ g.user.username }}" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
E-Mail
|
||||||
|
<input type="email" name="email" value="{{ g.user.email or '' }}" autocomplete="email">
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" name="save_mode" value="stay">Speichern</button>
|
||||||
|
<button class="secondary" type="submit" name="save_mode" value="close">Speichern und schließen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Passwort ändern</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('auth.change_password') }}" class="stack-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<label>
|
||||||
|
Aktuelles Passwort
|
||||||
|
<input type="password" name="current_password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Neues Passwort
|
||||||
|
<input type="password" name="new_password" autocomplete="new-password" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Neues Passwort wiederholen
|
||||||
|
<input type="password" name="new_password_repeat" autocomplete="new-password" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Passwort ändern</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,14 +5,37 @@
|
|||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<p class="eyebrow">Erster Start</p>
|
<p class="eyebrow">Erster Start</p>
|
||||||
<h1>Den ersten Haushalt-Zugang anlegen</h1>
|
<h1>Den ersten Haushalt-Zugang anlegen</h1>
|
||||||
<p class="lead">Danach koennt ihr die App gemeinsam nutzen. Die Daten bleiben lokal in dieser Installation.</p>
|
<p class="lead">Danach könnt ihr Nouri gemeinsam nutzen. 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() }}
|
||||||
|
<label>
|
||||||
|
Haushaltsname
|
||||||
|
<input type="text" name="household_name" autocomplete="organization" placeholder="z. B. Zuhause">
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Benutzername
|
Benutzername
|
||||||
<input type="text" name="username" autocomplete="username" required>
|
<input type="text" name="username" autocomplete="username" required>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
E-Mail
|
||||||
|
<input type="email" name="email" autocomplete="email" placeholder="Optional">
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Anzeigename
|
Anzeigename
|
||||||
<input type="text" name="display_name" autocomplete="name" placeholder="z. B. Heinz">
|
<input type="text" name="display_name" autocomplete="name" placeholder="z. B. Heinz">
|
||||||
|
|||||||
@@ -2,38 +2,79 @@
|
|||||||
<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>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
<meta name="theme-color" content="#de9862">
|
||||||
<script defer src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Nouri">
|
||||||
|
<meta name="application-name" content="Nouri">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token_value }}">
|
||||||
|
<meta name="nouri-push-public-key" content="{{ push_public_key }}">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ asset_url('brand/favicon.svg') }}">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ asset_url('brand/pwa-180.png') }}">
|
||||||
|
<link rel="manifest" href="{{ url_for('webmanifest') }}">
|
||||||
|
<link rel="stylesheet" href="{{ asset_url('css/styles.css') }}">
|
||||||
|
<script defer src="{{ asset_url('js/theme.js') }}"></script>
|
||||||
|
<script defer src="{{ asset_url('js/planner.js') }}"></script>
|
||||||
|
<script defer src="{{ asset_url('js/ui.js') }}"></script>
|
||||||
|
<script defer src="{{ asset_url('js/pwa.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="{% if g.user %}has-mobile-nav{% endif %}">
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
|
<div class="desktop-header-main">
|
||||||
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
||||||
<span class="brand-mark">N</span>
|
<span class="brand-mark">
|
||||||
<span>
|
<img src="{{ asset_url('brand/nouri-icon.svg') }}" alt="">
|
||||||
|
</span>
|
||||||
|
<span class="brand-copy">
|
||||||
<strong>Nouri</strong>
|
<strong>Nouri</strong>
|
||||||
<small>freundliches Essensgedaechtnis</small>
|
<small>einfach essen planen</small>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if g.user %}
|
{% if g.user %}
|
||||||
<nav class="site-nav">
|
<nav class="site-nav desktop-nav">
|
||||||
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">Heute</a>
|
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-sparkles"></span><span>Heute</span></span></a>
|
||||||
<a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}">Lebensmittel</a>
|
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-cart-shopping"></span><span>Einkauf</span></span></a>
|
||||||
<a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}">Mahlzeiten</a>
|
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar"></span><span>Plan</span></span></a>
|
||||||
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}">Einkaufsliste</a>
|
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar-days"></span><span>Woche</span></span></a>
|
||||||
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}">Zuhause</a>
|
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a>
|
||||||
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">Wochenplan</a>
|
<a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></a>
|
||||||
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}">Archiv</a>
|
<a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a>
|
||||||
|
<a href="{{ url_for('main.template_library') }}" class="{{ 'active' if (request.endpoint or '').startswith('main.day_template') or (request.endpoint or '').startswith('main.week_template') or (request.endpoint or '').startswith('main.item_set') or request.endpoint == 'main.template_library' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></span></a>
|
||||||
|
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="header-actions">
|
{% endif %}
|
||||||
<button class="theme-toggle" type="button" data-theme-toggle>Modus</button>
|
</div>
|
||||||
|
|
||||||
|
{% if g.user %}
|
||||||
|
<div class="desktop-header-sub">
|
||||||
|
<div class="header-actions desktop-actions">
|
||||||
|
<button class="theme-toggle ghost-button" type="button" data-theme-toggle aria-label="Darstellung wechseln">
|
||||||
|
<span class="ui-icon icon-sun-theme" data-theme-icon></span>
|
||||||
|
<span data-theme-label>Hell</span>
|
||||||
|
</button>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.settings_view') }}">Optionen</a>
|
||||||
|
<a class="user-chip" href="{{ url_for('auth.profile') }}">
|
||||||
|
<span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span>
|
||||||
|
<small>{{ role_labels[g.user.role] }}</small>
|
||||||
|
</a>
|
||||||
|
{% if g.user.role == 'admin' %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Nutzer</a>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Abmelden</button>
|
<button class="ghost-button" type="submit">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="mobile-profile-link ghost-button" type="button" data-mobile-sheet-open aria-label="Mehr öffnen">
|
||||||
|
<span class="mobile-profile-avatar">{{ (g.user.display_name or g.user.username or 'N')[:1]|upper }}</span>
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -50,6 +91,67 @@
|
|||||||
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="footer-copy">
|
||||||
|
<a href="{{ app_release_url }}" target="_blank" rel="noreferrer">Version {{ app_version }}</a>
|
||||||
|
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="footer-copy">
|
||||||
|
<span>© 2026 <a href="https://hnz.io" target="_blank" rel="noreferrer">@ hnz.io</a></span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if g.user %}
|
||||||
|
<div class="mobile-nav-stack" data-mobile-nav-stack>
|
||||||
|
<nav class="mobile-nav-extension" data-mobile-sheet hidden aria-label="Mehr Navigation">
|
||||||
|
<a class="mobile-extra-link" href="{{ url_for('main.item_list', kind='food') }}"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></a>
|
||||||
|
<a class="mobile-extra-link" href="{{ url_for('main.item_list', kind='meal') }}"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></a>
|
||||||
|
<a class="mobile-extra-link" href="{{ url_for('main.home_view') }}"><span class="ui-icon icon-house"></span><span>Zuhause</span></a>
|
||||||
|
<a class="mobile-extra-link" href="{{ url_for('main.archive_view') }}"><span class="ui-icon icon-archive"></span><span>Archiv</span></a>
|
||||||
|
<a class="mobile-extra-link" href="{{ url_for('main.template_library') }}"><span class="ui-icon icon-leaf"></span><span>Vorlagen</span></a>
|
||||||
|
<a class="mobile-extra-link" href="{{ url_for('main.settings_view') }}"><span class="ui-icon icon-sliders"></span><span>Optionen</span></a>
|
||||||
|
<a class="mobile-extra-link" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</span></a>
|
||||||
|
{% if g.user.role == 'admin' %}
|
||||||
|
<a class="mobile-extra-link" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
|
||||||
|
{% endif %}
|
||||||
|
<button class="mobile-extra-link mobile-extra-button" type="button" data-theme-toggle aria-label="Darstellung wechseln">
|
||||||
|
<span class="ui-icon icon-sun-theme" data-theme-icon></span>
|
||||||
|
<span data-theme-label>Hell</span>
|
||||||
|
</button>
|
||||||
|
<form method="post" action="{{ url_for('auth.logout') }}" class="mobile-extra-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="mobile-extra-link mobile-extra-button" type="submit">
|
||||||
|
<span class="ui-icon icon-ellipsis"></span>
|
||||||
|
<span>Abmelden</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<nav class="mobile-bottom-nav" aria-label="Mobile Navigation">
|
||||||
|
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">
|
||||||
|
<span class="ui-icon icon-sparkles"></span>
|
||||||
|
<span>Heute</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}">
|
||||||
|
<span class="ui-icon icon-cart-shopping"></span>
|
||||||
|
<span>Einkauf</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}">
|
||||||
|
<span class="ui-icon icon-calendar"></span>
|
||||||
|
<span>Plan</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">
|
||||||
|
<span class="ui-icon icon-calendar-days"></span>
|
||||||
|
<span>Woche</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="mobile-nav-button" data-mobile-sheet-open>
|
||||||
|
<span class="ui-icon icon-ellipsis"></span>
|
||||||
|
<span>Mehr</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,20 +4,37 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Heute</p>
|
<p class="eyebrow">Heute</p>
|
||||||
<h1>Ein ruhiger Blick auf das, was gerade hilft</h1>
|
<h1>Ein guter Blick auf euren Alltag</h1>
|
||||||
<p class="lead">Du siehst auf einen Blick, was zuhause da ist, was noch eingekauft werden soll und was heute schon eingeplant ist.</p>
|
<p class="lead">Du siehst, 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 secondary" href="{{ url_for('main.item_create', kind='food') }}">Lebensmittel anlegen</a>
|
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
|
||||||
<a class="button secondary" href="{{ url_for('main.item_create', kind='meal') }}">Mahlzeitenidee anlegen</a>
|
<a class="button secondary" href="{{ url_for('main.template_library') }}">Vorlagen öffnen</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% 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>
|
||||||
<strong>{{ home_count }}</strong>
|
<strong>{{ home_count }}</strong>
|
||||||
<small>sichtbare Eintraege</small>
|
<small>sichtbare Einträge</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<span>Einkaufsliste</span>
|
<span>Einkaufsliste</span>
|
||||||
@@ -31,27 +48,50 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{% if dashboard_hints %}
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Heute passend</h2>
|
||||||
|
</div>
|
||||||
|
<div class="hint-list">
|
||||||
|
{% for hint in dashboard_hints %}
|
||||||
|
<p class="hint-chip">{{ hint }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="two-column">
|
<section class="two-column">
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Heute im Plan</h2>
|
<h2>Heute im Plan</h2>
|
||||||
<a href="{{ url_for('main.planner') }}">Wochenplan oeffnen</a>
|
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Zum Tagesplan</a>
|
||||||
</div>
|
</div>
|
||||||
{% if today_entries %}
|
{% if today_entries %}
|
||||||
<ul class="simple-list">
|
<ul class="simple-list">
|
||||||
{% for entry in today_entries %}
|
{% for entry in today_entries %}
|
||||||
<li>
|
<li class="stacked-mobile">
|
||||||
|
<div>
|
||||||
<strong>{{ entry.daypart_name }}</strong>
|
<strong>{{ entry.daypart_name }}</strong>
|
||||||
<span>{{ entry.item_name }}</span>
|
<span>{{ entry.item_name }}</span>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ entry.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||||
|
<span class="chip">{{ entry.for_label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if entry.is_home %}
|
||||||
|
<span class="status-pill status-home">zuhause</span>
|
||||||
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty-state">Fuer heute ist noch nichts fest eingeplant. Das ist vollkommen okay.</p>
|
<p class="empty-state">Für heute ist noch nichts fest eingeplant. Ein kleiner Anfang reicht völlig.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<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>
|
||||||
@@ -62,7 +102,8 @@
|
|||||||
<article class="mini-card">
|
<article class="mini-card">
|
||||||
<div class="mini-card-body">
|
<div class="mini-card-body">
|
||||||
<strong>{{ item.name }}</strong>
|
<strong>{{ item.name }}</strong>
|
||||||
<small>{{ item_kind_labels[item.kind] }}</small>
|
<small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}</small>
|
||||||
|
<small>{{ item.for_label }}</small>
|
||||||
{% if item.dayparts %}
|
{% if item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
{% for daypart in item.dayparts %}
|
{% for daypart in item.dayparts %}
|
||||||
@@ -79,4 +120,115 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="two-column">
|
||||||
|
<article class="panel dashboard-spaced-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Was zuhause gut zusammenpasst</h2>
|
||||||
|
<a href="{{ url_for('main.home_view') }}">Zuhause öffnen</a>
|
||||||
|
</div>
|
||||||
|
{% if recipe_suggestions %}
|
||||||
|
<div class="stack-sections">
|
||||||
|
{% for suggestion in recipe_suggestions %}
|
||||||
|
<article class="template-list-card">
|
||||||
|
<div>
|
||||||
|
<strong>{{ suggestion.title }}</strong>
|
||||||
|
<small>{{ suggestion.reason }}</small>
|
||||||
|
{% if suggestion.needs_shopping and suggestion.missing_components %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip status-idea">Es fehlt noch: {{ suggestion.missing_components|join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="template-list-card-actions">
|
||||||
|
{% if suggestion.existing_item_id %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=suggestion.existing_item_id, daypart_id=suggestion.daypart_id or 1) }}">Im Tagesplan öffnen</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
|
||||||
|
<form method="post" action="{{ url_for('main.suggestion_hide') }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
{% for component_id in suggestion.component_ids %}
|
||||||
|
<input type="hidden" name="component_ids" value="{{ component_id }}">
|
||||||
|
{% endfor %}
|
||||||
|
<button class="ghost-button" type="submit">Dauerhaft ausblenden</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">Sobald ein paar Dinge unter Zuhause liegen, zeigt Nouri hier kleine Kombinationsideen.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Vorlagen für später</h2>
|
||||||
|
<a href="{{ url_for('main.template_library') }}">Alles ansehen</a>
|
||||||
|
</div>
|
||||||
|
{% if day_templates or week_templates %}
|
||||||
|
<div class="stack-sections">
|
||||||
|
{% for template in day_templates %}
|
||||||
|
<a class="mini-card" href="{{ url_for('main.day_template_edit', template_id=template.id) }}">
|
||||||
|
<strong>{{ template.name }}</strong>
|
||||||
|
<small>Tagesvorlage · {{ template.visibility_label }}</small>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% for template in week_templates %}
|
||||||
|
<a class="mini-card" href="{{ url_for('main.week_template_edit', template_id=template.id) }}">
|
||||||
|
<strong>{{ template.name }}</strong>
|
||||||
|
<small>Wochenvorlage · {{ template.visibility_label }}</small>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">Vorlagen helfen später beim Wiederverwenden. Du kannst sie direkt aus einem Tag oder einer Woche heraus anlegen.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="two-column">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Nächster Einkauf</h2>
|
||||||
|
<a href="{{ url_for('main.shopping_list') }}">Zur Einkaufsliste</a>
|
||||||
|
</div>
|
||||||
|
{% if upcoming_entries %}
|
||||||
|
<div class="stack-sections">
|
||||||
|
{% for entry in upcoming_entries %}
|
||||||
|
<article class="template-list-card">
|
||||||
|
<div>
|
||||||
|
<strong>{{ entry.item_name }}</strong>
|
||||||
|
<small>Wird ab {{ entry.activation_label }} sichtbar · {{ entry.needed_for_label }}</small>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">Gerade ist nichts für spätere Einkäufe vorgemerkt.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Nächste Tage</h2>
|
||||||
|
<a href="{{ url_for('main.planner') }}">Wochenansicht öffnen</a>
|
||||||
|
</div>
|
||||||
|
<div class="week-mini-grid">
|
||||||
|
{% for card in week_cards %}
|
||||||
|
<a class="week-mini-card" href="{{ url_for('main.planner_day', date=card.date.isoformat()) }}">
|
||||||
|
<strong>{{ weekday_short_name(card.date) }} {{ card.date.strftime('%d.%m.') }}</strong>
|
||||||
|
{% if card.filled_dayparts %}
|
||||||
|
<span>{{ card.planned_count }} Einträge</span>
|
||||||
|
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
|
||||||
|
{% else %}
|
||||||
|
<span>Noch offen</span>
|
||||||
|
<small>Du kannst mit einem Eintrag anfangen.</small>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,44 +5,146 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Zuhause</p>
|
<p class="eyebrow">Zuhause</p>
|
||||||
<h1>Was aktuell da ist</h1>
|
<h1>Was aktuell da ist</h1>
|
||||||
<p class="lead">Sichtbar, ruhig und nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, wandert es nicht weg, sondern ins Archiv.</p>
|
<p class="lead">Hier erscheinen aktive Lebensmittel und Mahlzeitenideen, die gerade wirklich da sind. Wenn etwas leer ist, wird es einfach als gerade nicht da markiert.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if grouped %}
|
<section class="panel compact-form-panel">
|
||||||
|
<form method="get" class="filter-form">
|
||||||
|
<label class="wide">
|
||||||
|
Suche
|
||||||
|
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Sichtbarkeit
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Tageszeit
|
||||||
|
<select name="daypart_id">
|
||||||
|
<option value="">Alle Tageszeiten</option>
|
||||||
|
{% for daypart in dayparts %}
|
||||||
|
<option value="{{ daypart.id }}" {% if selected_daypart_id == daypart.id %}selected{% endif %}>{{ daypart.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button type="submit">Filtern</button>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.home_view') }}">Zurücksetzen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if recipe_suggestions %}
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Passt gut dazu</h2>
|
||||||
|
<a href="{{ url_for('main.item_create', kind='meal') }}">Neue Mahlzeit</a>
|
||||||
|
</div>
|
||||||
|
<div class="stack-sections">
|
||||||
|
{% for suggestion in recipe_suggestions %}
|
||||||
|
<article class="template-list-card">
|
||||||
|
<div>
|
||||||
|
<strong>{{ suggestion.title }}</strong>
|
||||||
|
<small>{{ suggestion.reason }}</small>
|
||||||
|
{% if suggestion.needs_shopping and suggestion.missing_components %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip status-idea">Es fehlt noch: {{ suggestion.missing_components|join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="template-list-card-actions">
|
||||||
|
{% if suggestion.existing_item_id %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=suggestion.existing_item_id, daypart_id=suggestion.daypart_id or 1) }}">Im Tagesplan öffnen</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_create', kind='meal', name=suggestion.title, component_ids=suggestion.component_ids) }}">Als Mahlzeit anlegen</a>
|
||||||
|
<form method="post" action="{{ url_for('main.suggestion_hide') }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
{% for component_id in suggestion.component_ids %}
|
||||||
|
<input type="hidden" name="component_ids" value="{{ component_id }}">
|
||||||
|
{% endfor %}
|
||||||
|
<button class="ghost-button" type="submit">Dauerhaft ausblenden</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if sections %}
|
||||||
<section class="stack-sections">
|
<section class="stack-sections">
|
||||||
{% for title, items in grouped.items() %}
|
{% for section in sections if section["items"] %}
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>{{ title }}</h2>
|
<h2>{{ section["title"] }}</h2>
|
||||||
<span>{{ items|length }} Eintraege</span>
|
<span>{{ section["items"]|length }} Einträge</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
{% for item in items %}
|
{% for item in section["items"] %}
|
||||||
<article class="item-card compact">
|
<article class="item-card compact">
|
||||||
<div class="item-media">
|
<div class="item-media">
|
||||||
{% if item.photo_filename %}
|
{% if item.photo_filename %}
|
||||||
<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 %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-body">
|
<div class="item-body">
|
||||||
<h3>{{ item.name }}</h3>
|
<h3>{{ item.name }}</h3>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
|
</div>
|
||||||
|
{% 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 %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
{% if item.kind == 'meal' %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
||||||
|
{% else %}
|
||||||
|
{% if item.can_edit %}
|
||||||
|
<form method="post" action="{{ url_for('main.item_set_not_home', item_id=item.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Verbraucht / nicht mehr da</button>
|
<button class="secondary" type="submit">Nicht mehr da</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button type="submit">Erneut einkaufen</button>
|
<button type="submit">Auf Einkaufsliste</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if item.can_edit %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
||||||
|
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="ghost-button" type="submit">Archivieren</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -4,9 +4,30 @@
|
|||||||
<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, Bild, Tageszeiten und eine kleine Notiz, wenn sie hilft.</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 %}
|
||||||
|
<div class="intro-pills">
|
||||||
|
<span class="status-pill">{{ item.visibility_label }}</span>
|
||||||
|
<span class="status-pill status-soft">{{ item.owner_label }}</span>
|
||||||
|
<span class="status-pill">{{ item.for_label }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel form-panel">
|
<section class="panel form-panel">
|
||||||
@@ -17,16 +38,129 @@
|
|||||||
<input type="text" name="name" value="{{ form_data.name }}" required>
|
<input type="text" name="name" value="{{ form_data.name }}" required>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div class="dual-grid">
|
||||||
<label>
|
<label>
|
||||||
Kategorie
|
Sichtbarkeit
|
||||||
<input type="text" name="category" list="category-list" value="{{ form_data.category }}" placeholder="z. B. Obst, Warmes, Snack">
|
<select name="visibility">
|
||||||
<datalist id="category-list">
|
{% for value, label in visibility_options %}
|
||||||
{% for category in categories %}
|
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
<option value="{{ category }}"></option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</datalist>
|
</select>
|
||||||
|
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Für wen?
|
||||||
|
<select name="target_user_id">
|
||||||
|
{% for option in target_user_options %}
|
||||||
|
<option value="{{ option.value }}" {% if form_data.target_user_raw == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if kind == 'food' %}
|
||||||
|
<div class="dual-grid">
|
||||||
|
<label>
|
||||||
|
Baustein
|
||||||
|
<select name="base_type">
|
||||||
|
{% for value, label in builder_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</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
|
||||||
<textarea name="note" rows="4" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.note }}</textarea>
|
<textarea name="note" rows="4" placeholder="Optional, wenn eine kleine Erinnerung hilfreich ist.">{{ form_data.note }}</textarea>
|
||||||
@@ -39,39 +173,223 @@
|
|||||||
|
|
||||||
{% 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 %}
|
||||||
|
|
||||||
|
{% if kind == 'food' %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Passende Tageszeiten</legend>
|
<legend>Passende Tageszeiten</legend>
|
||||||
<div class="checkbox-grid">
|
<div class="checkbox-grid daypart-option-grid">
|
||||||
{% for daypart in dayparts %}
|
{% for daypart in dayparts %}
|
||||||
<label class="check-option">
|
<label class="daypart-option">
|
||||||
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
||||||
<span>{{ daypart.name }}</span>
|
<span class="daypart-option-card">
|
||||||
|
<span class="daypart-option-icon">
|
||||||
|
<span class="ui-icon {{ daypart_icon_class(daypart.slug) }}"></span>
|
||||||
|
</span>
|
||||||
|
<span class="daypart-option-label">{{ daypart.name }}</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if kind == 'meal' %}
|
{% if kind == 'meal' %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Bestandteile der Mahlzeitenidee</legend>
|
<legend>Bestandteile der Mahlzeitenidee</legend>
|
||||||
<div class="checkbox-grid">
|
<p class="muted">Du kannst eine 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>
|
||||||
{% for food in foods %}
|
<div class="meal-component-search">
|
||||||
<label class="check-option">
|
<label class="wide">
|
||||||
|
Lebensmittel suchen
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="food_search"
|
||||||
|
value="{{ form_data.food_search }}"
|
||||||
|
placeholder="z. B. Reis, Banane, Joghurt"
|
||||||
|
data-filter-input
|
||||||
|
data-filter-target="#meal-components-list"
|
||||||
|
data-filter-limit="8"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="selected-component-stack is-live" data-selected-preview="#meal-components-list">
|
||||||
|
<p class="helper-text">Ausgewählt</p>
|
||||||
|
<p class="helper-text" data-selected-preview-empty>Noch nichts ausgewählt.</p>
|
||||||
|
<div class="selected-components-grid">
|
||||||
|
{% for group in food_groups %}
|
||||||
|
{% for component in group["items"] %}
|
||||||
|
{% set component_icon_class = {
|
||||||
|
'protein': 'icon-component-protein',
|
||||||
|
'carb': 'icon-component-carb',
|
||||||
|
'veg': 'icon-component-veg',
|
||||||
|
'fruit': 'icon-component-fruit',
|
||||||
|
'dairy': 'icon-component-dairy',
|
||||||
|
'nuts': 'icon-component-nuts',
|
||||||
|
'seeds': 'icon-component-seeds',
|
||||||
|
'neutral': 'icon-component-neutral',
|
||||||
|
}.get(component.primary_builder_key or component.base_type, 'icon-component-neutral') %}
|
||||||
|
<article
|
||||||
|
class="selected-component-card {% if not component.is_home %}is-needed{% endif %}"
|
||||||
|
data-selected-preview-card="{{ component.id }}"
|
||||||
|
>
|
||||||
|
<button class="selected-component-remove" type="button" data-uncheck-component="{{ component.id }}">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
<span class="sr-only">{{ component.name }} entfernen</span>
|
||||||
|
</button>
|
||||||
|
<div class="selected-component-visual">
|
||||||
|
{% if component.photo_filename %}
|
||||||
|
<img
|
||||||
|
src="{{ image_url(component.photo_filename, 'md') }}"
|
||||||
|
srcset="{{ image_srcset(component.photo_filename) }}"
|
||||||
|
sizes="{{ image_sizes('grid') }}"
|
||||||
|
alt="{{ component.name }}"
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<span class="selected-component-fallback">
|
||||||
|
<span class="ui-icon {{ component_icon_class }}"></span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="selected-component-main">
|
||||||
|
<strong>{{ component.name }}</strong>
|
||||||
|
<small class="food-status-badge {% if not component.is_home %}is-needed{% endif %}">
|
||||||
|
<span class="ui-icon {% if component.is_home %}icon-house{% else %}icon-shopping-cart{% endif %}"></span>
|
||||||
|
<span>{{ component.availability_label }}</span>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="helper-text">Während der Suche zeigt Nouri die passendsten Lebensmittel. Nicht vorrätige Lebensmittel sind mit Einkaufswagen markiert.</p>
|
||||||
|
{% if food_groups %}
|
||||||
|
<div class="meal-component-results" id="meal-components-list">
|
||||||
|
{% for group in food_groups %}
|
||||||
|
<div class="component-group">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>{{ group["title"] }}</h3>
|
||||||
|
<span>{{ group["items"]|length }} Einträge</span>
|
||||||
|
</div>
|
||||||
|
<div class="meal-component-option-grid" data-filter-group>
|
||||||
|
{% for food in group["items"] %}
|
||||||
|
{% set food_icon_class = {
|
||||||
|
'protein': 'icon-component-protein',
|
||||||
|
'carb': 'icon-component-carb',
|
||||||
|
'veg': 'icon-component-veg',
|
||||||
|
'fruit': 'icon-component-fruit',
|
||||||
|
'dairy': 'icon-component-dairy',
|
||||||
|
'nuts': 'icon-component-nuts',
|
||||||
|
'seeds': 'icon-component-seeds',
|
||||||
|
'neutral': 'icon-component-neutral',
|
||||||
|
}.get(food.primary_builder_key or food.base_type, 'icon-component-neutral') %}
|
||||||
|
<label class="meal-component-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }} {{ food.base_type_label|lower }} {{ food.suggestion_role_label|lower }} {{ food.availability_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 }}</span>
|
<span class="meal-component-option-card">
|
||||||
|
<span class="meal-component-option-visual">
|
||||||
|
{% if food.photo_filename %}
|
||||||
|
<img
|
||||||
|
src="{{ image_url(food.photo_filename, 'md') }}"
|
||||||
|
srcset="{{ image_srcset(food.photo_filename) }}"
|
||||||
|
sizes="{{ image_sizes('grid') }}"
|
||||||
|
alt=""
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<span class="ui-icon {{ food_icon_class }}"></span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="meal-component-option-copy">
|
||||||
|
<strong>{{ food.name }}</strong>
|
||||||
|
<small class="food-status-badge {% if not food.is_home %}is-needed{% endif %}">
|
||||||
|
<span class="ui-icon {% if food.is_home %}icon-house{% else %}icon-shopping-cart{% endif %}"></span>
|
||||||
|
<span>{{ food.availability_label }}</span>
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">Lege zuerst ein paar Lebensmittel an, damit du daraus Mahlzeitenideen bauen kannst.</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="quick-food-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>Neues Lebensmittel direkt anlegen</h3>
|
||||||
|
<span>ohne die Seite zu verlassen</span>
|
||||||
|
</div>
|
||||||
|
<div class="quick-food-grid">
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input type="text" name="quick_food_name" value="{{ form_data.quick_food_name }}" placeholder="z. B. Hüttenkäse">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Baustein
|
||||||
|
<select name="quick_food_base_type">
|
||||||
|
{% for value, label in builder_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.quick_food_base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</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">
|
||||||
|
Notiz
|
||||||
|
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
|
||||||
|
</label>
|
||||||
|
<button type="submit" name="form_action" value="quick_add_food" class="secondary">Lebensmittel anlegen und übernehmen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit" name="save_mode" value="stay">Speichern</button>
|
||||||
<a class="ghost-button" href="{{ url_for('main.item_list', kind=kind) }}">Zurueck</a>
|
<button class="secondary" type="submit" name="save_mode" value="close">Speichern und schließen</button>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_list', kind=kind) }}">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||