Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4babf93179 | |||
| 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 |
@@ -4,8 +4,8 @@
|
|||||||
"author": "Florian Heinz",
|
"author": "Florian Heinz",
|
||||||
"description": "Private Flask app for meals, shopping and gentle food planning",
|
"description": "Private Flask app for meals, shopping and gentle food planning",
|
||||||
"tagline": "einfach essen planen",
|
"tagline": "einfach essen planen",
|
||||||
"version": "1.0.0",
|
"version": "1.3.4",
|
||||||
"upstreamVersion": "1.0.0",
|
"upstreamVersion": "1.3.4",
|
||||||
"healthCheckPath": "/",
|
"healthCheckPath": "/",
|
||||||
"httpPort": 8000,
|
"httpPort": 8000,
|
||||||
"manifestVersion": 2,
|
"manifestVersion": 2,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Nouri 1.3.4
|
||||||
|
|
||||||
|
Nouri 1.3.4 räumt die Mahlzeiten-Übersicht auf und bringt sie optisch näher an die ruhigeren Lebensmittel-Karten. Der Fokus liegt auf weniger sichtbaren Details, klarerem Scannen und dem Namen direkt unter dem Bild.
|
||||||
|
|
||||||
|
## Neu in 1.3.4
|
||||||
|
|
||||||
|
- Mahlzeitenkarten zeigen auf der Übersichtsseite jetzt zuerst Bild oder Platzhalter und darunter den Namen.
|
||||||
|
- Details wie Sichtbarkeit, Zielperson, Mahlzeitentyp, Energie und Tags liegen in einer Hover-Ansicht.
|
||||||
|
- Komponenten und Notizen werden ebenfalls erst beim Hover bzw. Fokus eingeblendet.
|
||||||
|
- Die sichtbaren Text-Buttons auf Mahlzeitenkarten wurden durch kompakte Icon-Aktionen ersetzt.
|
||||||
|
- Archivieren bleibt als kleiner Kreis oben rechts erreichbar.
|
||||||
|
- Tagesplan und Einkaufsliste sind als schnelle Icon-Aktionen oben links erreichbar.
|
||||||
|
|
||||||
|
## Bedienung
|
||||||
|
|
||||||
|
- Auf Desktop erscheinen Zusatzinfos beim Mouseover.
|
||||||
|
- Per Tastatur erscheinen Zusatzinfos, sobald eine Aktion auf der Karte fokussiert ist.
|
||||||
|
- Auf kleinen Bildschirmen bleibt die Übersicht bewusst reduziert, wie bei den Lebensmittelkarten.
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
- Cloudron-Version und Upstream-Version stehen jetzt auf `1.3.4`.
|
||||||
|
- Die interne Schema-Version und der App-Version-Fallback wurden auf `1.3.4` angehoben.
|
||||||
|
- Der Service Worker nutzt einen neuen Cache-Namen für `1.3.4`.
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
@@ -18,10 +19,21 @@ from .constants import (
|
|||||||
DEFAULT_CATEGORIES,
|
DEFAULT_CATEGORIES,
|
||||||
ENERGY_DENSITY_LABELS,
|
ENERGY_DENSITY_LABELS,
|
||||||
ENERGY_DENSITY_OPTIONS,
|
ENERGY_DENSITY_OPTIONS,
|
||||||
|
FOOD_ROLE_DESCRIPTIONS,
|
||||||
|
FOOD_ROLE_LABELS,
|
||||||
|
FOOD_ROLE_OPTIONS,
|
||||||
ITEM_KIND_LABELS,
|
ITEM_KIND_LABELS,
|
||||||
ITEM_KIND_SINGULAR_LABELS,
|
ITEM_KIND_SINGULAR_LABELS,
|
||||||
|
MEAL_STYLE_LABELS,
|
||||||
|
MEAL_STYLE_OPTIONS,
|
||||||
|
MEAL_TYPE_LABELS,
|
||||||
|
MEAL_TYPE_OPTIONS,
|
||||||
NOTIFICATION_CHANNEL_OPTIONS,
|
NOTIFICATION_CHANNEL_OPTIONS,
|
||||||
|
PROTEIN_PREFERENCE_LABELS,
|
||||||
|
PROTEIN_PREFERENCE_OPTIONS,
|
||||||
ROLE_LABELS,
|
ROLE_LABELS,
|
||||||
|
SUGGESTION_PRIORITY_LABELS,
|
||||||
|
SUGGESTION_PRIORITY_OPTIONS,
|
||||||
SUGGESTION_STYLE_LABELS,
|
SUGGESTION_STYLE_LABELS,
|
||||||
SUGGESTION_STYLE_OPTIONS,
|
SUGGESTION_STYLE_OPTIONS,
|
||||||
VISIBILITY_DESCRIPTIONS,
|
VISIBILITY_DESCRIPTIONS,
|
||||||
@@ -34,6 +46,15 @@ from .main import main_bp
|
|||||||
|
|
||||||
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||||
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||||
|
DEFAULT_RELEASE_URL = "https://git.hnz.io/hnzio/nouri-App/releases"
|
||||||
|
DAYPART_ICON_CLASSES = {
|
||||||
|
"breakfast": "icon-daypart-breakfast",
|
||||||
|
"morning-snack": "icon-daypart-morning-snack",
|
||||||
|
"lunch": "icon-daypart-lunch",
|
||||||
|
"afternoon-snack": "icon-daypart-afternoon-snack",
|
||||||
|
"dinner": "icon-daypart-dinner",
|
||||||
|
"late-snack": "icon-daypart-late-snack",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_secret_key(data_dir: Path) -> str:
|
def load_secret_key(data_dir: Path) -> str:
|
||||||
@@ -54,11 +75,38 @@ def load_secret_key(data_dir: Path) -> str:
|
|||||||
return secret_value
|
return secret_value
|
||||||
|
|
||||||
|
|
||||||
|
def load_app_version(root_dir: Path) -> str:
|
||||||
|
env_version = os.environ.get("NOURI_APP_VERSION", "").strip()
|
||||||
|
if env_version:
|
||||||
|
return env_version
|
||||||
|
|
||||||
|
manifest_path = root_dir / "CloudronManifest.json"
|
||||||
|
if manifest_path.exists():
|
||||||
|
try:
|
||||||
|
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
manifest_data = {}
|
||||||
|
manifest_version = str(
|
||||||
|
manifest_data.get("upstreamVersion")
|
||||||
|
or manifest_data.get("version")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if manifest_version:
|
||||||
|
return manifest_version
|
||||||
|
return "1.3.4"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
ensure_upload_structure(upload_dir)
|
ensure_upload_structure(upload_dir)
|
||||||
@@ -74,7 +122,8 @@ def create_app() -> Flask:
|
|||||||
SESSION_COOKIE_HTTPONLY=True,
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
SESSION_COOKIE_SAMESITE="Lax",
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1",
|
SESSION_COOKIE_SECURE=os.environ.get("NOURI_SECURE_COOKIES", "0") == "1",
|
||||||
APP_VERSION="1.0.0",
|
APP_VERSION=app_version,
|
||||||
|
RELEASE_URL=release_url,
|
||||||
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
|
TIMEZONE=os.environ.get("NOURI_TIMEZONE", "Europe/Berlin"),
|
||||||
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
|
VAPID_PUBLIC_KEY=os.environ.get("NOURI_VAPID_PUBLIC_KEY", ""),
|
||||||
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
|
VAPID_PRIVATE_KEY=os.environ.get("NOURI_VAPID_PRIVATE_KEY", ""),
|
||||||
@@ -102,11 +151,22 @@ def create_app() -> Flask:
|
|||||||
"builder_labels": BUILDER_LABELS,
|
"builder_labels": BUILDER_LABELS,
|
||||||
"builder_descriptions": BUILDER_DESCRIPTIONS,
|
"builder_descriptions": BUILDER_DESCRIPTIONS,
|
||||||
"builder_options": BUILDER_OPTIONS,
|
"builder_options": BUILDER_OPTIONS,
|
||||||
|
"food_role_labels": FOOD_ROLE_LABELS,
|
||||||
|
"food_role_descriptions": FOOD_ROLE_DESCRIPTIONS,
|
||||||
|
"food_role_options": FOOD_ROLE_OPTIONS,
|
||||||
|
"suggestion_priority_labels": SUGGESTION_PRIORITY_LABELS,
|
||||||
|
"suggestion_priority_options": SUGGESTION_PRIORITY_OPTIONS,
|
||||||
"daypart_suggestions": DAYPARTS,
|
"daypart_suggestions": DAYPARTS,
|
||||||
"energy_density_options": ENERGY_DENSITY_OPTIONS,
|
"energy_density_options": ENERGY_DENSITY_OPTIONS,
|
||||||
"energy_density_labels": ENERGY_DENSITY_LABELS,
|
"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_options": SUGGESTION_STYLE_OPTIONS,
|
||||||
"suggestion_style_labels": SUGGESTION_STYLE_LABELS,
|
"suggestion_style_labels": SUGGESTION_STYLE_LABELS,
|
||||||
|
"protein_preference_options": PROTEIN_PREFERENCE_OPTIONS,
|
||||||
|
"protein_preference_labels": PROTEIN_PREFERENCE_LABELS,
|
||||||
"visibility_labels": VISIBILITY_LABELS,
|
"visibility_labels": VISIBILITY_LABELS,
|
||||||
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
|
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
|
||||||
"role_labels": ROLE_LABELS,
|
"role_labels": ROLE_LABELS,
|
||||||
@@ -114,10 +174,12 @@ def create_app() -> Flask:
|
|||||||
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
|
"notification_channel_options": NOTIFICATION_CHANNEL_OPTIONS,
|
||||||
"today": date.today(),
|
"today": date.today(),
|
||||||
"app_version": app.config["APP_VERSION"],
|
"app_version": app.config["APP_VERSION"],
|
||||||
|
"app_release_url": app.config["RELEASE_URL"],
|
||||||
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
|
"push_public_key": app.config["VAPID_PUBLIC_KEY"],
|
||||||
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
|
"push_available": bool(app.config["VAPID_PUBLIC_KEY"] and app.config["VAPID_PRIVATE_KEY"]),
|
||||||
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
||||||
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
|
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
|
||||||
|
"daypart_icon_class": lambda slug: DAYPART_ICON_CLASSES.get(slug, "icon-calendar"),
|
||||||
"is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin",
|
"is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin",
|
||||||
"asset_url": asset_url,
|
"asset_url": asset_url,
|
||||||
"image_url": lambda filename, variant="md": image_url(
|
"image_url": lambda filename, variant="md": image_url(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
|
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
from .auth import admin_required, can_remove_last_admin, validate_admin_user_form
|
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 .constants import BUILDER_DESCRIPTIONS, BUILDER_OPTIONS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS, ROLE_LABELS
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
|
|
||||||
@@ -104,6 +104,19 @@ def user_create():
|
|||||||
)
|
)
|
||||||
database.commit()
|
database.commit()
|
||||||
flash("Der Nutzer wurde angelegt.", "success")
|
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"))
|
return redirect(url_for("admin.user_list"))
|
||||||
|
|
||||||
flash(error, "error")
|
flash(error, "error")
|
||||||
@@ -185,6 +198,8 @@ def user_edit(user_id: int):
|
|||||||
)
|
)
|
||||||
database.commit()
|
database.commit()
|
||||||
flash("Der Nutzer wurde aktualisiert.", "success")
|
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"))
|
return redirect(url_for("admin.user_list"))
|
||||||
|
|
||||||
flash(error, "error")
|
flash(error, "error")
|
||||||
@@ -289,3 +304,31 @@ def category_update(category_id: int):
|
|||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash("Die Zuordnung wurde aktualisiert.", "success")
|
flash("Die Zuordnung wurde aktualisiert.", "success")
|
||||||
return redirect(url_for("admin.category_settings"))
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.post("/categories/<int:category_id>/delete")
|
||||||
|
@admin_required
|
||||||
|
def category_delete(category_id: int):
|
||||||
|
category = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM household_categories
|
||||||
|
WHERE id = ? AND household_id = ?
|
||||||
|
""",
|
||||||
|
(category_id, g.user["household_id"]),
|
||||||
|
).fetchone()
|
||||||
|
if category is None:
|
||||||
|
flash("Die Kategorie wurde nicht gefunden.", "error")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
if category["name"] in DEFAULT_CATEGORIES:
|
||||||
|
flash("Standardkategorien bleiben erhalten. Du kannst sie bei Bedarf pausieren.", "info")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|
||||||
|
get_db().execute(
|
||||||
|
"DELETE FROM household_categories WHERE id = ? AND household_id = ?",
|
||||||
|
(category_id, g.user["household_id"]),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
flash("Die Kategorie wurde entfernt.", "success")
|
||||||
|
return redirect(url_for("admin.category_settings"))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import functools
|
import functools
|
||||||
import secrets
|
import secrets
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -25,23 +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
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
def admin_required(view):
|
def admin_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"))
|
||||||
if g.user["role"] != "admin":
|
if g.user["role"] != "admin":
|
||||||
flash("Dieser Bereich ist für Admins gedacht.", "error")
|
flash("Dieser Bereich ist für Admins gedacht.", "error")
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
return view(**kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
return wrapped_view
|
return wrapped_view
|
||||||
|
|
||||||
@@ -53,6 +54,25 @@ def ensure_csrf_token() -> str:
|
|||||||
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:
|
def normalize_login_value(raw: str) -> str:
|
||||||
return raw.strip().lower()
|
return raw.strip().lower()
|
||||||
|
|
||||||
@@ -231,7 +251,9 @@ def profile():
|
|||||||
)
|
)
|
||||||
database.commit()
|
database.commit()
|
||||||
flash("Dein Profil wurde aktualisiert.", "success")
|
flash("Dein Profil wurde aktualisiert.", "success")
|
||||||
return redirect(url_for("auth.profile"))
|
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")
|
flash(error, "error")
|
||||||
|
|
||||||
|
|||||||
@@ -52,13 +52,25 @@ def export_backup_archive(
|
|||||||
payload["tables"][table_name] = [dict(row) for row in rows]
|
payload["tables"][table_name] = [dict(row) for row in rows]
|
||||||
|
|
||||||
uploads_root = Path(upload_folder)
|
uploads_root = Path(upload_folder)
|
||||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
uploads_snapshot_dir = Path(tempfile.mkdtemp(prefix="nouri-backup-uploads-"))
|
||||||
archive.writestr("backup.json", json.dumps(payload, ensure_ascii=False, indent=2))
|
try:
|
||||||
if uploads_root.exists():
|
if uploads_root.exists():
|
||||||
for file_path in uploads_root.rglob("*"):
|
for file_path in uploads_root.rglob("*"):
|
||||||
if file_path.is_file():
|
if not file_path.is_file():
|
||||||
|
continue
|
||||||
relative_path = file_path.relative_to(uploads_root)
|
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()}")
|
archive.write(file_path, f"uploads/{relative_path.as_posix()}")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(uploads_snapshot_dir, ignore_errors=True)
|
||||||
|
|
||||||
return archive_path, backup_name
|
return archive_path, backup_name
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,17 @@ DAYPARTS = [
|
|||||||
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
|
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
DAYPART_SLUG_TO_MEAL_TYPE = {
|
||||||
|
"breakfast": "breakfast",
|
||||||
|
"morning-snack": "snack",
|
||||||
|
"lunch": "lunch",
|
||||||
|
"afternoon-snack": "snack",
|
||||||
|
"dinner": "dinner",
|
||||||
|
"late-snack": "snack",
|
||||||
|
}
|
||||||
|
|
||||||
DEFAULT_CATEGORIES = [
|
DEFAULT_CATEGORIES = [
|
||||||
|
"Unsortiert",
|
||||||
"Kohlenhydrate",
|
"Kohlenhydrate",
|
||||||
"Milchprodukt",
|
"Milchprodukt",
|
||||||
"Obst",
|
"Obst",
|
||||||
@@ -21,6 +31,7 @@ DEFAULT_CATEGORIES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
DEFAULT_CATEGORY_BUILDERS = {
|
DEFAULT_CATEGORY_BUILDERS = {
|
||||||
|
"Unsortiert": "neutral",
|
||||||
"Kohlenhydrate": "carb",
|
"Kohlenhydrate": "carb",
|
||||||
"Brot & Getreide": "carb",
|
"Brot & Getreide": "carb",
|
||||||
"Milchprodukt": "dairy",
|
"Milchprodukt": "dairy",
|
||||||
@@ -36,7 +47,7 @@ DEFAULT_CATEGORY_BUILDERS = {
|
|||||||
|
|
||||||
BUILDER_LABELS = {
|
BUILDER_LABELS = {
|
||||||
"protein": "Proteinquelle",
|
"protein": "Proteinquelle",
|
||||||
"carb": "Kohlenhydratquelle",
|
"carb": "Kohlenhydrate",
|
||||||
"veg": "Gemüse / Ballaststoffquelle",
|
"veg": "Gemüse / Ballaststoffquelle",
|
||||||
"nuts": "Nüsse",
|
"nuts": "Nüsse",
|
||||||
"seeds": "Saaten",
|
"seeds": "Saaten",
|
||||||
@@ -58,6 +69,60 @@ BUILDER_DESCRIPTIONS = {
|
|||||||
|
|
||||||
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
|
BUILDER_OPTIONS = [(key, label) for key, label in BUILDER_LABELS.items()]
|
||||||
|
|
||||||
|
FOOD_FLAVOR_OPTIONS = [
|
||||||
|
("neutral", "Neutral"),
|
||||||
|
("sweet", "Süß"),
|
||||||
|
("savory", "Herzhaft"),
|
||||||
|
]
|
||||||
|
|
||||||
|
FOOD_FLAVOR_LABELS = {
|
||||||
|
"neutral": "Neutral",
|
||||||
|
"sweet": "Süß",
|
||||||
|
"savory": "Herzhaft",
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOD_FLAVOR_DESCRIPTIONS = {
|
||||||
|
"neutral": "Passt ruhig in beide Richtungen und bleibt flexibel.",
|
||||||
|
"sweet": "Passt eher zu süßen Kombinationen, Frühstücksideen oder kleinen Snacks.",
|
||||||
|
"savory": "Passt eher zu herzhaften Kombinationen und ruhigeren Hauptmahlzeiten.",
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOD_ROLE_LABELS = {
|
||||||
|
"main": "Hauptbaustein",
|
||||||
|
"base": "Basis",
|
||||||
|
"complement": "Ergänzung",
|
||||||
|
"topping": "Topping",
|
||||||
|
"cooking": "Kochzutat",
|
||||||
|
"snack": "Snack-Baustein",
|
||||||
|
"solo": "Schnelle Einzelmahlzeit",
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOD_ROLE_DESCRIPTIONS = {
|
||||||
|
"main": "Kann einen Teller oder eine Hauptmahlzeit deutlich tragen.",
|
||||||
|
"base": "Passt gut als Grundlage und lässt sich ruhig ergänzen.",
|
||||||
|
"complement": "Hilft beim Ergänzen, steht aber selten für sich allein.",
|
||||||
|
"topping": "Passt eher oben drauf oder als kleines Extra.",
|
||||||
|
"cooking": "Hilft beim Kochen oder Abschmecken, ist aber selten selbst die Mahlzeit.",
|
||||||
|
"snack": "Passt gut für kleine Zwischenmahlzeiten oder als ruhige Ergänzung.",
|
||||||
|
"solo": "Kann auch alleine als schnelle, einfache Mahlzeit funktionieren.",
|
||||||
|
}
|
||||||
|
|
||||||
|
FOOD_ROLE_OPTIONS = [(key, label) for key, label in FOOD_ROLE_LABELS.items()]
|
||||||
|
|
||||||
|
SUGGESTION_PRIORITY_OPTIONS = [
|
||||||
|
("prefer", "Gern vorschlagen"),
|
||||||
|
("normal", "Normal vorschlagen"),
|
||||||
|
("rare", "Eher selten automatisch vorschlagen"),
|
||||||
|
("never", "Nie automatisch vorschlagen"),
|
||||||
|
]
|
||||||
|
|
||||||
|
SUGGESTION_PRIORITY_LABELS = {
|
||||||
|
"prefer": "Gern vorschlagen",
|
||||||
|
"normal": "Normal vorschlagen",
|
||||||
|
"rare": "Eher selten automatisch vorschlagen",
|
||||||
|
"never": "Nie automatisch vorschlagen",
|
||||||
|
}
|
||||||
|
|
||||||
ENERGY_DENSITY_OPTIONS = [
|
ENERGY_DENSITY_OPTIONS = [
|
||||||
("low", "Eher leicht"),
|
("low", "Eher leicht"),
|
||||||
("neutral", "Neutral"),
|
("neutral", "Neutral"),
|
||||||
@@ -74,12 +139,66 @@ SUGGESTION_STYLE_OPTIONS = [
|
|||||||
("balanced", "Eher ausgewogen"),
|
("balanced", "Eher ausgewogen"),
|
||||||
("fitness", "Fitness"),
|
("fitness", "Fitness"),
|
||||||
("protein", "Proteinbetont"),
|
("protein", "Proteinbetont"),
|
||||||
|
("easy", "Leicht und einfach"),
|
||||||
|
("snack", "Snackorientiert"),
|
||||||
]
|
]
|
||||||
|
|
||||||
SUGGESTION_STYLE_LABELS = {
|
SUGGESTION_STYLE_LABELS = {
|
||||||
"balanced": "Eher ausgewogen",
|
"balanced": "Eher ausgewogen",
|
||||||
"fitness": "Fitness",
|
"fitness": "Fitness",
|
||||||
"protein": "Proteinbetont",
|
"protein": "Proteinbetont",
|
||||||
|
"easy": "Leicht und einfach",
|
||||||
|
"snack": "Snackorientiert",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROTEIN_PREFERENCE_OPTIONS = [
|
||||||
|
("mixed", "Offen gemischt"),
|
||||||
|
("veg-friendly", "Überwiegend vegetarisch"),
|
||||||
|
("rare-animal", "Fleisch und Fisch nur selten"),
|
||||||
|
("plant-forward", "Möglichst pflanzlich"),
|
||||||
|
]
|
||||||
|
|
||||||
|
PROTEIN_PREFERENCE_LABELS = {
|
||||||
|
"mixed": "Offen gemischt",
|
||||||
|
"veg-friendly": "Überwiegend vegetarisch",
|
||||||
|
"rare-animal": "Fleisch und Fisch nur selten",
|
||||||
|
"plant-forward": "Möglichst pflanzlich",
|
||||||
|
}
|
||||||
|
|
||||||
|
MEAL_TYPE_OPTIONS = [
|
||||||
|
("breakfast", "Frühstück"),
|
||||||
|
("lunch", "Mittagessen"),
|
||||||
|
("dinner", "Abendessen"),
|
||||||
|
("snack", "Snack"),
|
||||||
|
]
|
||||||
|
|
||||||
|
MEAL_TYPE_LABELS = {
|
||||||
|
"breakfast": "Frühstück",
|
||||||
|
"lunch": "Mittagessen",
|
||||||
|
"dinner": "Abendessen",
|
||||||
|
"snack": "Snack",
|
||||||
|
}
|
||||||
|
|
||||||
|
MEAL_STYLE_OPTIONS = [
|
||||||
|
("sweet", "Süß"),
|
||||||
|
("savory", "Herzhaft"),
|
||||||
|
("warm", "Warm"),
|
||||||
|
("cold", "Kalt"),
|
||||||
|
("quick", "Schnell"),
|
||||||
|
("simple", "Ruhig und einfach"),
|
||||||
|
("prep", "Gut vorbereitbar"),
|
||||||
|
("portable", "Für unterwegs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
MEAL_STYLE_LABELS = {
|
||||||
|
"sweet": "Süß",
|
||||||
|
"savory": "Herzhaft",
|
||||||
|
"warm": "Warm",
|
||||||
|
"cold": "Kalt",
|
||||||
|
"quick": "Schnell",
|
||||||
|
"simple": "Ruhig und einfach",
|
||||||
|
"prep": "Gut vorbereitbar",
|
||||||
|
"portable": "Für unterwegs",
|
||||||
}
|
}
|
||||||
|
|
||||||
WEEKDAY_OPTIONS = [
|
WEEKDAY_OPTIONS = [
|
||||||
@@ -109,9 +228,10 @@ 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 = {
|
ROLE_LABELS = {
|
||||||
|
|||||||
@@ -8,9 +8,353 @@ from flask import Flask, current_app, g
|
|||||||
from flask.cli import with_appcontext
|
from flask.cli import with_appcontext
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
from .constants import DAYPARTS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS
|
from .constants import (
|
||||||
|
DAYPARTS,
|
||||||
|
DAYPART_SLUG_TO_MEAL_TYPE,
|
||||||
|
DEFAULT_CATEGORIES,
|
||||||
|
DEFAULT_CATEGORY_BUILDERS,
|
||||||
|
)
|
||||||
|
|
||||||
CURRENT_SCHEMA_VERSION = "1.0.0"
|
CURRENT_SCHEMA_VERSION = "1.3.4"
|
||||||
|
|
||||||
|
ANIMAL_HINTS = (
|
||||||
|
"huhn",
|
||||||
|
"hähn",
|
||||||
|
"rind",
|
||||||
|
"hack",
|
||||||
|
"schwein",
|
||||||
|
"speck",
|
||||||
|
"salami",
|
||||||
|
"wurst",
|
||||||
|
"thunfisch",
|
||||||
|
"lachs",
|
||||||
|
"fisch",
|
||||||
|
"garnelen",
|
||||||
|
"shrimp",
|
||||||
|
"sardinen",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name_for_profile(name: str | None) -> str:
|
||||||
|
return (name or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def infer_food_flavor_profile(
|
||||||
|
name: str | None,
|
||||||
|
category: str | None,
|
||||||
|
base_type: str | None = None,
|
||||||
|
suggestion_role: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
normalized_name = normalize_name_for_profile(name)
|
||||||
|
normalized_category = (category or "").strip().lower()
|
||||||
|
normalized_base_type = (base_type or "").strip().lower()
|
||||||
|
normalized_role = (suggestion_role or "").strip().lower()
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("proteinpulver", "eiweißpulver", "whey", "clear whey")):
|
||||||
|
return "neutral"
|
||||||
|
if any(token in normalized_name for token in ("schoko", "choco", "müsli", "granola", "cornflakes", "fruchtjoghurt", "vanillejoghurt", "pudding")):
|
||||||
|
return "sweet"
|
||||||
|
if any(token in normalized_name for token in ("banane", "apfel", "birne", "beeren", "himbeer", "erdbeer", "heidelbeer", "mango", "kiwi", "trauben")):
|
||||||
|
return "sweet"
|
||||||
|
if any(token in normalized_name for token in ("räucher", "tofu", "tempeh", "hack", "salami", "wurst", "thunfisch", "lachs", "fisch", "huhn", "hähn", "rind", "schwein", "aufstrich", "pesto", "humus", "hummus", "reisgericht", "chili", "curry")):
|
||||||
|
return "savory"
|
||||||
|
if any(token in normalized_name for token in ("naturjoghurt", "joghurt natur", "quark", "skyr", "haferflocken", "gurke", "karotte", "karotten", "kartoffel", "kartoffeln", "reis", "nudeln", "brot", "brötchen")):
|
||||||
|
return "neutral"
|
||||||
|
|
||||||
|
if "obst" in normalized_category or normalized_base_type == "fruit":
|
||||||
|
return "sweet"
|
||||||
|
if any(token in normalized_category for token in ("eiweiß", "protein")) or normalized_base_type == "protein":
|
||||||
|
return "savory"
|
||||||
|
if any(token in normalized_category for token in ("gemüse",)) or normalized_base_type in {"veg", "carb", "dairy", "nuts", "seeds"}:
|
||||||
|
return "neutral"
|
||||||
|
if normalized_role in {"topping", "cooking"}:
|
||||||
|
return "neutral"
|
||||||
|
return "neutral"
|
||||||
|
|
||||||
|
|
||||||
|
def infer_food_profile(name: str | None, category: str | None, energy_density: str | None) -> dict[str, object]:
|
||||||
|
normalized_name = normalize_name_for_profile(name)
|
||||||
|
normalized_category = (category or "").strip().lower()
|
||||||
|
|
||||||
|
base_type = "neutral"
|
||||||
|
if "eiweiß" in normalized_category or "protein" in normalized_category:
|
||||||
|
base_type = "protein"
|
||||||
|
elif "kohlenhyd" in normalized_category or "brot" in normalized_category or "getreide" in normalized_category:
|
||||||
|
base_type = "carb"
|
||||||
|
elif "milch" in normalized_category:
|
||||||
|
base_type = "dairy"
|
||||||
|
elif "obst" in normalized_category:
|
||||||
|
base_type = "fruit"
|
||||||
|
elif "gemüse" in normalized_category:
|
||||||
|
base_type = "veg"
|
||||||
|
elif "nüsse" in normalized_name or "nuss" in normalized_name:
|
||||||
|
base_type = "nuts"
|
||||||
|
elif "saat" in normalized_name or "leinsamen" in normalized_name or "chia" in normalized_name:
|
||||||
|
base_type = "seeds"
|
||||||
|
|
||||||
|
suggestion_role = "base"
|
||||||
|
suggestion_priority = "normal"
|
||||||
|
can_be_meal_core = 0
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("proteinpulver", "eiweißpulver", "whey", "clear whey")):
|
||||||
|
return {
|
||||||
|
"base_type": "protein",
|
||||||
|
"suggestion_role": "complement",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("flohsamen", "flohsamenschalen", "leinsamen", "chia", "hanfsamen")):
|
||||||
|
return {
|
||||||
|
"base_type": "seeds",
|
||||||
|
"suggestion_role": "topping",
|
||||||
|
"suggestion_priority": "normal",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if "tomatenmark" in normalized_name:
|
||||||
|
return {
|
||||||
|
"base_type": "neutral",
|
||||||
|
"suggestion_role": "cooking",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("saure gurken", "essiggurken", "cornichons", "gurkenscheiben")):
|
||||||
|
return {
|
||||||
|
"base_type": "veg",
|
||||||
|
"suggestion_role": "complement",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("tofu", "tempeh", "vegane schnitzel", "vegane nuggets", "veganes hack", "sojageschnetzeltes")):
|
||||||
|
return {
|
||||||
|
"base_type": "protein",
|
||||||
|
"suggestion_role": "main",
|
||||||
|
"suggestion_priority": "prefer",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("thunfisch", "lachs", "fisch", "huhn", "hähn", "rind", "schwein", "hack")):
|
||||||
|
return {
|
||||||
|
"base_type": "protein",
|
||||||
|
"suggestion_role": "main",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("joghurt", "skyr", "quark", "hüttenkäse", "körniger frischkäse")):
|
||||||
|
return {
|
||||||
|
"base_type": "dairy",
|
||||||
|
"suggestion_role": "base",
|
||||||
|
"suggestion_priority": "prefer",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("müsli", "hafer", "porridge", "cornflakes", "brot", "brötchen", "reis", "nudel", "kartoffel", "wrap")):
|
||||||
|
return {
|
||||||
|
"base_type": "carb",
|
||||||
|
"suggestion_role": "base",
|
||||||
|
"suggestion_priority": "normal",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("beeren", "banane", "apfel", "obst", "birne", "trauben", "kiwi")):
|
||||||
|
return {
|
||||||
|
"base_type": "fruit",
|
||||||
|
"suggestion_role": "complement",
|
||||||
|
"suggestion_priority": "prefer",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("gemüse", "brokkoli", "spinat", "erbsen", "paprika", "karotte", "zucchini", "salat", "tomate", "tk gemüse")):
|
||||||
|
return {
|
||||||
|
"base_type": "veg",
|
||||||
|
"suggestion_role": "complement",
|
||||||
|
"suggestion_priority": "prefer",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("nussmus", "erdnuss", "mandeln", "walnüsse", "cashew")):
|
||||||
|
return {
|
||||||
|
"base_type": "nuts",
|
||||||
|
"suggestion_role": "topping",
|
||||||
|
"suggestion_priority": "normal",
|
||||||
|
"can_be_meal_core": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if any(token in normalized_name for token in ("terrine", "5-minuten", "instant", "cup noodles")):
|
||||||
|
return {
|
||||||
|
"base_type": "carb" if (energy_density or "neutral") != "high" else "neutral",
|
||||||
|
"suggestion_role": "solo",
|
||||||
|
"suggestion_priority": "rare",
|
||||||
|
"can_be_meal_core": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if base_type in {"protein", "carb", "dairy"}:
|
||||||
|
suggestion_role = "base"
|
||||||
|
can_be_meal_core = 1
|
||||||
|
elif base_type in {"veg", "fruit"}:
|
||||||
|
suggestion_role = "complement"
|
||||||
|
elif base_type in {"nuts", "seeds"}:
|
||||||
|
suggestion_role = "topping"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"base_type": base_type,
|
||||||
|
"suggestion_role": suggestion_role,
|
||||||
|
"suggestion_priority": suggestion_priority,
|
||||||
|
"can_be_meal_core": can_be_meal_core,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def infer_meal_tags(name: str | None, legacy_category: str | None) -> str:
|
||||||
|
normalized_name = normalize_name_for_profile(name)
|
||||||
|
normalized_category = (legacy_category or "").strip().lower()
|
||||||
|
tags: list[str] = []
|
||||||
|
|
||||||
|
if normalized_category == "warmes":
|
||||||
|
tags.extend(["warm", "savory"])
|
||||||
|
if normalized_category == "kleines essen":
|
||||||
|
tags.extend(["simple", "quick"])
|
||||||
|
if normalized_category == "snack":
|
||||||
|
tags.append("simple")
|
||||||
|
if any(token in normalized_name for token in ("porridge", "müsli", "joghurt", "quark")):
|
||||||
|
tags.append("sweet")
|
||||||
|
if any(token in normalized_name for token in ("salat", "brot", "toast", "tofu", "reis", "nudel", "pfanne")):
|
||||||
|
tags.append("savory")
|
||||||
|
if any(token in normalized_name for token in ("to go", "unterwegs", "wrap")):
|
||||||
|
tags.append("portable")
|
||||||
|
if any(token in normalized_name for token in ("overnight", "vorbereitet", "meal prep")):
|
||||||
|
tags.append("prep")
|
||||||
|
if any(token in normalized_name for token in ("schnell", "5-minuten", "instant")):
|
||||||
|
tags.append("quick")
|
||||||
|
if any(token in normalized_name for token in ("einfach", "ruhig")):
|
||||||
|
tags.append("simple")
|
||||||
|
|
||||||
|
unique_tags: list[str] = []
|
||||||
|
for tag in tags:
|
||||||
|
if tag and tag not in unique_tags:
|
||||||
|
unique_tags.append(tag)
|
||||||
|
return ",".join(unique_tags)
|
||||||
|
|
||||||
|
|
||||||
|
def infer_meal_type_from_dayparts(database: sqlite3.Connection, item_id: int) -> str:
|
||||||
|
row = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT dayparts.slug
|
||||||
|
FROM item_dayparts
|
||||||
|
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
|
||||||
|
WHERE item_dayparts.item_id = ?
|
||||||
|
ORDER BY dayparts.sort_order
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(item_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return "snack"
|
||||||
|
return DAYPART_SLUG_TO_MEAL_TYPE.get(row["slug"], "snack")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_item_profiles(database: sqlite3.Connection) -> None:
|
||||||
|
rows = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, kind, name, category, energy_density
|
||||||
|
FROM items
|
||||||
|
ORDER BY id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
item_id = int(row["id"])
|
||||||
|
if row["kind"] == "food":
|
||||||
|
profile = infer_food_profile(row["name"], row["category"], row["energy_density"])
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE items
|
||||||
|
SET base_type = ?,
|
||||||
|
suggestion_role = ?,
|
||||||
|
suggestion_priority = ?,
|
||||||
|
can_be_meal_core = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
profile["base_type"],
|
||||||
|
profile["suggestion_role"],
|
||||||
|
profile["suggestion_priority"],
|
||||||
|
profile["can_be_meal_core"],
|
||||||
|
item_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
meal_type = infer_meal_type_from_dayparts(database, item_id)
|
||||||
|
meal_tags = infer_meal_tags(row["name"], row["category"])
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE items
|
||||||
|
SET meal_type = COALESCE(NULLIF(meal_type, ''), ?),
|
||||||
|
meal_tags = CASE
|
||||||
|
WHEN meal_tags IS NULL OR meal_tags = '' THEN ?
|
||||||
|
ELSE meal_tags
|
||||||
|
END,
|
||||||
|
category = CASE
|
||||||
|
WHEN kind = 'meal' AND category IN ('Kohlenhydrate', 'Milchprodukt', 'Obst', 'Gemüse', 'Eiweißquelle', 'Snack', 'Warmes', 'Kleines Essen')
|
||||||
|
THEN NULL
|
||||||
|
ELSE category
|
||||||
|
END
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(meal_type, meal_tags, item_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_food_flavor_profiles(database: sqlite3.Connection) -> None:
|
||||||
|
if get_meta(database, "food_flavor_profiles_migrated") == "1":
|
||||||
|
return
|
||||||
|
|
||||||
|
rows = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, name, category, base_type, suggestion_role, flavor_profile
|
||||||
|
FROM items
|
||||||
|
WHERE kind = 'food'
|
||||||
|
ORDER BY id
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
current_flavor = (row["flavor_profile"] or "").strip().lower()
|
||||||
|
if current_flavor in {"sweet", "savory"}:
|
||||||
|
continue
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE items
|
||||||
|
SET flavor_profile = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
infer_food_flavor_profile(
|
||||||
|
row["name"],
|
||||||
|
row["category"],
|
||||||
|
row["base_type"],
|
||||||
|
row["suggestion_role"],
|
||||||
|
),
|
||||||
|
int(row["id"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
set_meta(database, "food_flavor_profiles_migrated", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_item_archive_state(database: sqlite3.Connection) -> None:
|
||||||
|
if get_meta(database, "item_archive_state_migrated") == "1":
|
||||||
|
return
|
||||||
|
|
||||||
|
if "is_archived" not in table_columns(database, "items"):
|
||||||
|
return
|
||||||
|
|
||||||
|
database.execute("UPDATE items SET is_archived = 0 WHERE is_archived IS NULL")
|
||||||
|
database.execute("UPDATE items SET is_archived = 1 WHERE availability_state = 'archived'")
|
||||||
|
database.execute("UPDATE items SET availability_state = 'idea' WHERE availability_state = 'archived'")
|
||||||
|
set_meta(database, "item_archive_state_migrated", "1")
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> sqlite3.Connection:
|
def get_db() -> sqlite3.Connection:
|
||||||
@@ -18,9 +362,11 @@ def get_db() -> sqlite3.Connection:
|
|||||||
g.db = sqlite3.connect(
|
g.db = sqlite3.connect(
|
||||||
current_app.config["DATABASE_PATH"],
|
current_app.config["DATABASE_PATH"],
|
||||||
detect_types=sqlite3.PARSE_DECLTYPES,
|
detect_types=sqlite3.PARSE_DECLTYPES,
|
||||||
|
timeout=30,
|
||||||
)
|
)
|
||||||
g.db.row_factory = sqlite3.Row
|
g.db.row_factory = sqlite3.Row
|
||||||
g.db.execute("PRAGMA foreign_keys = ON")
|
g.db.execute("PRAGMA foreign_keys = ON")
|
||||||
|
g.db.execute("PRAGMA busy_timeout = 30000")
|
||||||
return g.db
|
return g.db
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +423,109 @@ def set_meta(database: sqlite3.Connection, key: str, value: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def item_kind_constraint_supports_shopping(database: sqlite3.Connection) -> bool:
|
||||||
|
row = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT sql
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'items'
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
return bool(row and row["sql"] and "'shopping'" in row["sql"])
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_items_kind_constraint(database: sqlite3.Connection) -> None:
|
||||||
|
if not table_exists(database, "items") or item_kind_constraint_supports_shopping(database):
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = table_columns(database, "items")
|
||||||
|
if "kind" not in columns:
|
||||||
|
return
|
||||||
|
|
||||||
|
foreign_keys_enabled = bool(database.execute("PRAGMA foreign_keys").fetchone()[0])
|
||||||
|
if foreign_keys_enabled:
|
||||||
|
database.execute("PRAGMA foreign_keys = OFF")
|
||||||
|
try:
|
||||||
|
database.execute("DROP TABLE IF EXISTS items_new")
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE items_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER,
|
||||||
|
owner_user_id INTEGER,
|
||||||
|
target_user_id INTEGER,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal', 'shopping')),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
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,
|
||||||
|
photo_filename TEXT,
|
||||||
|
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,
|
||||||
|
updated_by INTEGER,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (target_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
target_columns = [
|
||||||
|
"id",
|
||||||
|
"household_id",
|
||||||
|
"owner_user_id",
|
||||||
|
"target_user_id",
|
||||||
|
"visibility",
|
||||||
|
"kind",
|
||||||
|
"name",
|
||||||
|
"category",
|
||||||
|
"base_type",
|
||||||
|
"flavor_profile",
|
||||||
|
"suggestion_role",
|
||||||
|
"suggestion_priority",
|
||||||
|
"can_be_meal_core",
|
||||||
|
"meal_type",
|
||||||
|
"meal_tags",
|
||||||
|
"energy_density",
|
||||||
|
"note",
|
||||||
|
"photo_filename",
|
||||||
|
"availability_state",
|
||||||
|
"is_archived",
|
||||||
|
"is_quick_added",
|
||||||
|
"created_by",
|
||||||
|
"updated_by",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
]
|
||||||
|
copy_columns = [column for column in target_columns if column in columns]
|
||||||
|
quoted_columns = ", ".join(copy_columns)
|
||||||
|
database.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO items_new ({quoted_columns})
|
||||||
|
SELECT {quoted_columns}
|
||||||
|
FROM items
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
database.execute("DROP TABLE items")
|
||||||
|
database.execute("ALTER TABLE items_new RENAME TO items")
|
||||||
|
finally:
|
||||||
|
if foreign_keys_enabled:
|
||||||
|
database.execute("PRAGMA foreign_keys = ON")
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||||
ensure_meta_table(database)
|
ensure_meta_table(database)
|
||||||
database.execute(
|
database.execute(
|
||||||
@@ -127,12 +576,21 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
|||||||
add_column_if_missing(database, "items", "owner_user_id INTEGER")
|
add_column_if_missing(database, "items", "owner_user_id INTEGER")
|
||||||
add_column_if_missing(database, "items", "target_user_id INTEGER")
|
add_column_if_missing(database, "items", "target_user_id INTEGER")
|
||||||
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||||
|
add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "flavor_profile TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'")
|
||||||
|
add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'")
|
||||||
|
add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0")
|
||||||
|
add_column_if_missing(database, "items", "meal_type TEXT")
|
||||||
|
add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''")
|
||||||
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "is_archived INTEGER NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
if table_exists(database, "shopping_entries"):
|
if table_exists(database, "shopping_entries"):
|
||||||
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
|
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
|
||||||
add_column_if_missing(database, "shopping_entries", "owner_user_id INTEGER")
|
add_column_if_missing(database, "shopping_entries", "owner_user_id INTEGER")
|
||||||
add_column_if_missing(database, "shopping_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
add_column_if_missing(database, "shopping_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||||
|
add_column_if_missing(database, "shopping_entries", "shopping_note TEXT NOT NULL DEFAULT ''")
|
||||||
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
|
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
|
||||||
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
|
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
|
||||||
|
|
||||||
@@ -160,6 +618,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
|||||||
push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
|
push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
|
||||||
push_missing_lunch INTEGER NOT NULL DEFAULT 0,
|
push_missing_lunch INTEGER NOT NULL DEFAULT 0,
|
||||||
push_missing_dinner INTEGER NOT NULL DEFAULT 0,
|
push_missing_dinner INTEGER NOT NULL DEFAULT 0,
|
||||||
|
push_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||||
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||||
remind_nuts INTEGER NOT NULL DEFAULT 0,
|
remind_nuts INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -203,6 +662,19 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS hidden_generated_suggestions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
suggestion_key TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, suggestion_key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS shopping_needs (
|
CREATE TABLE IF NOT EXISTS shopping_needs (
|
||||||
@@ -238,9 +710,11 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
|||||||
if table_exists(database, "user_settings"):
|
if table_exists(database, "user_settings"):
|
||||||
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
||||||
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "user_settings", "protein_preference TEXT NOT NULL DEFAULT 'mixed'")
|
||||||
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
|
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
|
||||||
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
|
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
|
||||||
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
|
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
|
||||||
|
add_column_if_missing(database, "user_settings", "push_small_snack INTEGER NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
|
|
||||||
def ensure_default_household(database: sqlite3.Connection) -> int:
|
def ensure_default_household(database: sqlite3.Connection) -> int:
|
||||||
@@ -358,14 +832,27 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
add_column_if_missing(database, table_name, "owner_user_id INTEGER")
|
add_column_if_missing(database, table_name, "owner_user_id INTEGER")
|
||||||
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
|
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||||
add_column_if_missing(database, "items", "target_user_id INTEGER")
|
add_column_if_missing(database, "items", "target_user_id INTEGER")
|
||||||
|
add_column_if_missing(database, "items", "base_type TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "flavor_profile TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "suggestion_role TEXT NOT NULL DEFAULT 'base'")
|
||||||
|
add_column_if_missing(database, "items", "suggestion_priority TEXT NOT NULL DEFAULT 'normal'")
|
||||||
|
add_column_if_missing(database, "items", "can_be_meal_core INTEGER NOT NULL DEFAULT 0")
|
||||||
|
add_column_if_missing(database, "items", "meal_type TEXT")
|
||||||
|
add_column_if_missing(database, "items", "meal_tags TEXT NOT NULL DEFAULT ''")
|
||||||
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "is_archived INTEGER NOT NULL DEFAULT 0")
|
||||||
|
add_column_if_missing(database, "items", "is_quick_added INTEGER NOT NULL DEFAULT 0")
|
||||||
|
migrate_items_kind_constraint(database)
|
||||||
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
|
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
|
||||||
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
|
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
|
||||||
|
add_column_if_missing(database, "shopping_entries", "shopping_note TEXT NOT NULL DEFAULT ''")
|
||||||
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
||||||
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
add_column_if_missing(database, "user_settings", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "user_settings", "protein_preference TEXT NOT NULL DEFAULT 'mixed'")
|
||||||
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
|
add_column_if_missing(database, "user_settings", "push_missing_breakfast INTEGER NOT NULL DEFAULT 0")
|
||||||
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
|
add_column_if_missing(database, "user_settings", "push_missing_lunch INTEGER NOT NULL DEFAULT 0")
|
||||||
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
|
add_column_if_missing(database, "user_settings", "push_missing_dinner INTEGER NOT NULL DEFAULT 0")
|
||||||
|
add_column_if_missing(database, "user_settings", "push_small_snack INTEGER NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
if default_owner_id is not None:
|
if default_owner_id is not None:
|
||||||
database.execute(
|
database.execute(
|
||||||
@@ -404,6 +891,9 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
|
database.execute("UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''")
|
||||||
|
|
||||||
sync_default_categories(database)
|
sync_default_categories(database)
|
||||||
|
migrate_item_profiles(database)
|
||||||
|
migrate_food_flavor_profiles(database)
|
||||||
|
migrate_item_archive_state(database)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO user_settings (user_id)
|
INSERT OR IGNORE INTO user_settings (user_id)
|
||||||
@@ -411,11 +901,22 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
database.execute("UPDATE items SET energy_density = 'neutral' WHERE energy_density IS NULL OR energy_density = ''")
|
database.execute("UPDATE items SET energy_density = 'neutral' WHERE energy_density IS NULL OR energy_density = ''")
|
||||||
|
database.execute("UPDATE items SET base_type = 'neutral' WHERE base_type IS NULL OR base_type = ''")
|
||||||
|
database.execute("UPDATE items SET flavor_profile = 'neutral' WHERE flavor_profile IS NULL OR flavor_profile = ''")
|
||||||
|
database.execute("UPDATE items SET suggestion_role = 'base' WHERE suggestion_role IS NULL OR suggestion_role = ''")
|
||||||
|
database.execute("UPDATE items SET suggestion_priority = 'normal' WHERE suggestion_priority IS NULL OR suggestion_priority = ''")
|
||||||
|
database.execute("UPDATE items SET can_be_meal_core = 0 WHERE can_be_meal_core IS NULL")
|
||||||
|
database.execute("UPDATE items SET meal_tags = '' WHERE meal_tags IS NULL")
|
||||||
|
database.execute("UPDATE items SET is_archived = 0 WHERE is_archived IS NULL")
|
||||||
|
database.execute("UPDATE items SET is_quick_added = 0 WHERE is_quick_added IS NULL")
|
||||||
|
database.execute("UPDATE shopping_entries SET shopping_note = '' WHERE shopping_note IS NULL")
|
||||||
database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''")
|
database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''")
|
||||||
database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''")
|
database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''")
|
||||||
|
database.execute("UPDATE user_settings SET protein_preference = 'mixed' WHERE protein_preference IS NULL OR protein_preference = ''")
|
||||||
database.execute("UPDATE user_settings SET push_missing_breakfast = 0 WHERE push_missing_breakfast IS NULL")
|
database.execute("UPDATE user_settings SET push_missing_breakfast = 0 WHERE push_missing_breakfast IS NULL")
|
||||||
database.execute("UPDATE user_settings SET push_missing_lunch = 0 WHERE push_missing_lunch IS NULL")
|
database.execute("UPDATE user_settings SET push_missing_lunch = 0 WHERE push_missing_lunch IS NULL")
|
||||||
database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner IS NULL")
|
database.execute("UPDATE user_settings SET push_missing_dinner = 0 WHERE push_missing_dinner IS NULL")
|
||||||
|
database.execute("UPDATE user_settings SET push_small_snack = 0 WHERE push_small_snack IS NULL")
|
||||||
|
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
@@ -424,12 +925,24 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
WHERE email IS NOT NULL AND email != ''
|
WHERE email IS NOT NULL AND email != ''
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_kind_name
|
||||||
|
ON items (kind, name)
|
||||||
|
"""
|
||||||
|
)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
||||||
ON items (household_id, visibility, availability_state)
|
ON items (household_id, visibility, availability_state)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility_archived
|
||||||
|
ON items (household_id, visibility, is_archived, availability_state)
|
||||||
|
"""
|
||||||
|
)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_target_user
|
CREATE INDEX IF NOT EXISTS idx_items_target_user
|
||||||
@@ -448,12 +961,26 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
ON shopping_entries (household_id, visibility, is_checked)
|
ON shopping_entries (household_id, visibility, is_checked)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
database.execute("DROP INDEX IF EXISTS idx_shopping_entries_open_item")
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_shopping_entries_open_item
|
||||||
|
ON shopping_entries (item_id, COALESCE(shopping_note, ''))
|
||||||
|
WHERE is_checked = 0
|
||||||
|
"""
|
||||||
|
)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation
|
CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation
|
||||||
ON shopping_needs (household_id, activation_date, is_activated)
|
ON shopping_needs (household_id, activation_date, is_activated)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hidden_generated_suggestions_user
|
||||||
|
ON hidden_generated_suggestions (user_id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION)
|
set_meta(database, "schema_version", CURRENT_SCHEMA_VERSION)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,20 @@ from .push import send_push_message
|
|||||||
|
|
||||||
|
|
||||||
MEAL_PUSH_RULES = [
|
MEAL_PUSH_RULES = [
|
||||||
{"slug": "breakfast", "setting": "push_missing_breakfast", "hour": 8, "minute": 0, "label": "Frühstück"},
|
{"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, "label": "Mittagessen"},
|
{"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, "label": "Abendessen"},
|
{"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:
|
def current_local_time() -> datetime:
|
||||||
timezone_name = current_app.config.get("TIMEZONE", "Europe/Berlin")
|
timezone_name = current_app.config.get("TIMEZONE", "Europe/Berlin")
|
||||||
@@ -73,6 +82,24 @@ def plan_exists_for_daypart(user, *, planned_date: date, daypart_id: int) -> boo
|
|||||||
return bool(int(row["count"] or 0))
|
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:
|
def reminder_event_exists(user_id: int, event_key: str) -> bool:
|
||||||
row = get_db().execute(
|
row = get_db().execute(
|
||||||
"SELECT 1 FROM reminder_events WHERE user_id = ? AND event_key = ? LIMIT 1",
|
"SELECT 1 FROM reminder_events WHERE user_id = ? AND event_key = ? LIMIT 1",
|
||||||
@@ -92,10 +119,11 @@ def mark_reminder_event(user_id: int, event_key: str) -> None:
|
|||||||
get_db().commit()
|
get_db().commit()
|
||||||
|
|
||||||
|
|
||||||
def due_for_rule(now: datetime, *, hour: int, minute: int) -> bool:
|
def due_for_rule(now: datetime, *, hour: int, minute: int, end_hour: int) -> bool:
|
||||||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
current_minutes = (now.hour * 60) + now.minute
|
||||||
delta = (now - target).total_seconds()
|
target_minutes = (hour * 60) + minute
|
||||||
return 0 <= delta < 180
|
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:
|
def build_push_target_url(*, planned_date: date, daypart_id: int, suggestion: dict | None) -> str:
|
||||||
@@ -118,6 +146,13 @@ def build_push_message(label: str, suggestion: dict | None) -> tuple[str, str]:
|
|||||||
return title, f"Für {label.lower()} ist noch nichts geplant."
|
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:
|
def best_suggestion_for_user(user, daypart_id: int) -> dict | None:
|
||||||
previous_user = getattr(g, "user", None)
|
previous_user = getattr(g, "user", None)
|
||||||
g.user = user
|
g.user = user
|
||||||
@@ -128,6 +163,19 @@ def best_suggestion_for_user(user, daypart_id: int) -> dict | None:
|
|||||||
return suggestions[0] if suggestions else None
|
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:
|
def send_due_meal_pushes(now: datetime | None = None) -> int:
|
||||||
now = now or current_local_time()
|
now = now or current_local_time()
|
||||||
planned_date = now.date()
|
planned_date = now.date()
|
||||||
@@ -155,7 +203,7 @@ def send_due_meal_pushes(now: datetime | None = None) -> int:
|
|||||||
for rule in MEAL_PUSH_RULES:
|
for rule in MEAL_PUSH_RULES:
|
||||||
if not settings.get(rule["setting"]):
|
if not settings.get(rule["setting"]):
|
||||||
continue
|
continue
|
||||||
if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"]):
|
if not due_for_rule(now, hour=rule["hour"], minute=rule["minute"], end_hour=rule["end_hour"]):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
daypart = dayparts.get(rule["slug"])
|
daypart = dayparts.get(rule["slug"])
|
||||||
@@ -189,6 +237,50 @@ def send_due_meal_pushes(now: datetime | None = None) -> int:
|
|||||||
mark_reminder_event(int(user["id"]), event_key)
|
mark_reminder_event(int(user["id"]), event_key)
|
||||||
sent_count += 1
|
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
|
return sent_count
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ CREATE TABLE IF NOT EXISTS user_settings (
|
|||||||
notification_channel TEXT NOT NULL DEFAULT 'in_app',
|
notification_channel TEXT NOT NULL DEFAULT 'in_app',
|
||||||
suggestion_style TEXT NOT NULL DEFAULT 'balanced',
|
suggestion_style TEXT NOT NULL DEFAULT 'balanced',
|
||||||
energy_preference TEXT NOT NULL DEFAULT 'neutral',
|
energy_preference TEXT NOT NULL DEFAULT 'neutral',
|
||||||
|
protein_preference TEXT NOT NULL DEFAULT 'mixed',
|
||||||
remind_before_shopping INTEGER NOT NULL DEFAULT 1,
|
remind_before_shopping INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_on_shopping_day INTEGER NOT NULL DEFAULT 1,
|
remind_on_shopping_day INTEGER NOT NULL DEFAULT 1,
|
||||||
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
|
show_missing_for_upcoming_week INTEGER NOT NULL DEFAULT 1,
|
||||||
@@ -61,6 +62,7 @@ CREATE TABLE IF NOT EXISTS user_settings (
|
|||||||
push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
|
push_missing_breakfast INTEGER NOT NULL DEFAULT 0,
|
||||||
push_missing_lunch INTEGER NOT NULL DEFAULT 0,
|
push_missing_lunch INTEGER NOT NULL DEFAULT 0,
|
||||||
push_missing_dinner INTEGER NOT NULL DEFAULT 0,
|
push_missing_dinner INTEGER NOT NULL DEFAULT 0,
|
||||||
|
push_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||||
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
suggest_home_for_today INTEGER NOT NULL DEFAULT 1,
|
||||||
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
remind_small_snack INTEGER NOT NULL DEFAULT 0,
|
||||||
remind_nuts INTEGER NOT NULL DEFAULT 0,
|
remind_nuts INTEGER NOT NULL DEFAULT 0,
|
||||||
@@ -94,6 +96,15 @@ CREATE TABLE IF NOT EXISTS reminder_events (
|
|||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hidden_generated_suggestions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
suggestion_key TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE (user_id, suggestion_key),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dayparts (
|
CREATE TABLE IF NOT EXISTS dayparts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
@@ -107,13 +118,22 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
owner_user_id INTEGER,
|
owner_user_id INTEGER,
|
||||||
target_user_id INTEGER,
|
target_user_id INTEGER,
|
||||||
visibility TEXT NOT NULL DEFAULT 'shared',
|
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||||
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
|
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',
|
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,
|
||||||
@@ -147,6 +167,7 @@ CREATE TABLE IF NOT EXISTS shopping_entries (
|
|||||||
owner_user_id INTEGER,
|
owner_user_id INTEGER,
|
||||||
visibility TEXT NOT NULL DEFAULT 'shared',
|
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_date TEXT,
|
||||||
@@ -163,7 +184,7 @@ CREATE TABLE IF NOT EXISTS shopping_entries (
|
|||||||
);
|
);
|
||||||
|
|
||||||
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 (
|
CREATE TABLE IF NOT EXISTS shopping_needs (
|
||||||
@@ -286,6 +307,9 @@ ON items (kind, name);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
||||||
ON items (household_id, visibility, availability_state);
|
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
|
CREATE INDEX IF NOT EXISTS idx_items_target_user
|
||||||
ON items (target_user_id);
|
ON items (target_user_id);
|
||||||
|
|
||||||
|
|||||||
@@ -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 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 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 576 576"><path fill="currentColor" d="M99.1 99.8c36.8-31.2 88.7-50.7 158.9-51.8 70.8 1.3 118.7 27.7 149.8 67.9 32.6 42.2 48.7 102.3 48.7 172s-16.1 129.8-48.7 172c-31 40.2-79 66.6-149.8 67.9-70.2-1.1-122.1-20.6-158.9-51.8 14.2 2.5 29.3 3.8 45.4 3.8 4.1 0 8.1-.1 12-.2l0 .2c80.4 0 138.3-19.7 176.2-55.9 38-36.3 51.8-85.6 51.8-136.1s-13.8-99.8-51.8-136.1C294.8 115.7 236.9 96 156.5 96l0 .2c-3.9-.2-7.9-.2-12-.2-16.1 0-31.2 1.3-45.4 3.8zM252.5 .2C125.1 3.6 42.8 62.2 3.5 150.3l38.2 27.5c21.9-20.1 54.9-33.8 102.8-33.8 53.4 0 88.4 16.9 110.2 41.2 22.3 24.8 33.8 60.5 33.8 102.8S277 366 254.7 390.8c-21.9 24.4-56.8 41.2-110.2 41.2-47.9 0-80.9-13.6-102.8-33.8L3.5 425.7c39.3 88.2 121.6 146.7 249 150.1l0 .2c1.9 0 3.8 0 5.6 0 2.1 0 4.2 0 6.4 0 97.8 0 170.8-31.5 219.2-85.3 47.9-53.4 68.8-125.7 68.8-202.7S531.6 138.7 483.6 85.3c-48.3-53.8-121.4-85.3-219.2-85.3-2.1 0-4.3 0-6.4 0-1.9 0-3.7 0-5.6 0l0 .2zM216.5 252c0-26.5-14.4-48-48-48s-48 21.5-48 48 14.4 48 48 48 48-21.5 48-48zm-144 96c25.2 0 36-16.1 36-36s-10.8-36-36-36-36 16.1-36 36 10.8 36 36 36z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><path fill="currentColor" d="M309.6 0L266.4 0 222 47.9c-13.9 15-33.5 23.3-53.9 23L89.2 63 63 89.4 70.9 164.2c.3 19.5-7.3 38.3-21.1 52.1L0 266.1 0 309.6 47.9 354c15 13.9 23.3 33.5 23 53.9l-7.9 78.8 28 26 73.3-12.8c23.9-2.2 47.3 7.7 62.4 26.4l40.2 49.6 42.3 0 40.2-49.6c15.1-18.7 38.5-28.5 62.4-26.4l73.3 12.8 28-26-7.9-78.8c-.3-20.4 8-40 23-53.9l47.9-44.4 0-43.5-49.8-49.8c-13.8-13.8-21.4-32.6-21.1-52.1l7.9-74.8-26.3-26.4-78.8 7.9c-20.4 .3-40-8-53.9-23L309.6 0zM288 64.4l4.8 9.4c26.8 52.1 87.4 77.2 143.2 59.3l10-3.2-3.2 10c-17.9 55.8 7.2 116.4 59.3 143.2l9.4 4.8-9.4 4.8c-52.1 26.8-77.2 87.4-59.3 143.2l3.2 10-10-3.2c-55.8-17.9-116.4 7.2-143.2 59.3l-4.8 9.4-4.8-9.4c-26.8-52.1-87.4-77.2-143.2-59.3l-10 3.2 3.2-10c17.9-55.8-7.2-116.4-59.3-143.2l-9.4-4.8 9.4-4.8c52.1-26.8 77.2-87.4 59.3-143.2l-3.2-10 10 3.2c55.8 17.9 116.4-7.2 143.2-59.3l4.8-9.4zM322.3 224c8.6 14.4 13.7 36.5 13.7 64s-5.1 49.6-13.7 64c-7.8 13.1-18.4 20-34.3 20s-26.5-6.9-34.3-20c-8.6-14.4-13.7-36.5-13.7-64s5.1-49.6 13.7-64c7.8-13.1 18.4-20 34.3-20s26.5 6.9 34.3 20zM288 156c-43.2 0-77.2 14-100.2 39.6-22.6 25.1-31.8 58.5-31.8 92.4s9.2 67.3 31.8 92.4c23 25.6 57 39.6 100.2 39.6s77.2-14 100.2-39.6C410.8 355.3 420 321.9 420 288s-9.2-67.3-31.8-92.4C365.2 170 331.2 156 288 156z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M312.2 49.4L312.2 0L264.2 0L264.2 49.4L218.2 16.5L186.2 51.8L264.3 141L264.3 246.4L173 193.7L134.8 81.5L88.2 91.6L93.7 147.9L50.9 123.2L26.9 164.8L69.6 189.5L18.1 212.9L32.7 258.3L149 235.3L240.3 288L149 340.7L32.7 317.7L18.1 363.1L69.6 386.5L26.8 411.2L50.8 452.8L93.6 428.1L88.1 484.4L134.7 494.5L172.9 382.3L264.2 329.6L264.2 435L186.1 524.2L218.1 559.5L264.1 526.6L264.1 576L312.1 576L312.1 526.6L358.2 559.5L390.2 524.2L312.1 435L312.1 329.6L403.4 382.3L441.6 494.5L488.2 484.4L482.7 428.1L525.5 452.8L549.5 411.2L506.7 386.5L558.2 363.1L543.6 317.7L427.3 340.7L336 288L427.3 235.3L543.6 258.3L558.2 212.9L506.7 189.5L549.4 164.8L525.4 123.2L482.7 147.9L488.2 91.6L441.6 81.5L403.4 193.7L312.1 246.4L312.1 141L390.2 51.8L358.2 16.5L312.1 49.4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1016 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M191.6 66.9C211.6 38.8 243.9 24 288 24C332.1 24 364.4 38.8 384.4 66.9C400.1 89 406 116.5 407.6 144L480 144C533 144 576 187 576 240L576 552L0 552L0 240C0 187 43 144 96 144L168.4 144C169.9 116.5 175.9 89 191.6 66.9zM324.4 92.1C320.7 85.1 316.3 80.4 311.2 77.4C306.1 74.4 298.8 72 288 72C277.2 72 269.9 74.3 264.8 77.4C259.7 80.5 255.3 85.1 251.6 92.1C245.1 104.2 241.5 121.7 240.4 144L335.6 144C334.5 121.7 330.9 104.2 324.4 92.1zM480 504L480 384L331.5 384C333.8 395.1 337.2 411.1 341.6 432L234.4 432C238.8 411.1 242.2 395.1 244.5 384L96 384L96 504L480 504zM321.4 336L480 336L480 240C480 213.5 458.5 192 432 192L144 192C117.5 192 96 213.5 96 240L96 336L254.6 336L264.5 289.1L311.5 289.1L321.4 336z"/></svg>
|
||||||
|
After Width: | Height: | Size: 963 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M118.2 24L457.8 24L464.7 35.9L548.7 179.9L552 185.5L552 456C552 509 509 552 456 552L120 552C67 552 24 509 24 456L24 185.5L27.3 179.9L111.3 35.9L118.2 24zM120 456C120 482.5 141.5 504 168 504L408 504C434.5 504 456 482.5 456 456L456 216L120 216L120 456zM129.3 168L264 168L264 72L161.3 72L129.3 168zM312 168L446.7 168L414.7 72L312 72L312 168z"/></svg>
|
||||||
|
After Width: | Height: | Size: 606 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M44.6 23C-9.1 63.4-6.2 115.1 25.7 157.4L179 41.9C147.1-.5 98.2-17.5 44.6 23zM399.9 41.9L553.2 157.4C585.1 115.1 588 63.4 534.3 23C480.6-17.4 431.8-.5 399.9 41.9zM288 120C339.2 120 373.7 140.5 396.4 173C419.9 206.7 432 255.2 432 312C432 368.8 419.9 417.3 396.4 451C373.7 483.5 339.1 504 288 504C236.9 504 202.3 483.5 179.6 451C156.1 417.3 144 368.8 144 312C144 255.2 156.1 206.7 179.6 173C202.3 140.5 236.9 120 288 120zM288 72C207 72 146 98.1 105.4 143.2C65.3 187.9 48 248.2 48 312C48 362 58.7 410 82.7 449.9L29.7 512.5L66.3 543.5L112.8 488.5C153.2 528.8 211.8 552 288 552C364.2 552 422.8 528.9 463.2 488.5L509.7 543.5L546.3 512.5L493.3 449.9C517.3 410 528 362 528 312C528 248.3 510.7 187.9 470.6 143.2C430 98.1 369 72 288 72zM312 192L264 192L264 329.3C265.7 329.9 295.1 339.7 352.4 358.8L364.4 362.8L379.6 317.3C378.9 317.1 356.3 309.5 312 294.8L312 192z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M528 24L488.2 5.9C481.9 11.4 476.2 17.5 470.5 23.6C460 34.9 445.4 51.8 429.1 74.4C396.4 119.6 356.1 187.8 325.3 280.4L314.8 312L471.8 312L399.8 552L528.1 552L528.1 24zM480 109.1L480 264L425.8 264C441.5 197 462 145.6 480 109.1zM144 192C117.5 192 96 170.5 96 144L96 24L48 24L48 192C48 241.5 85.5 282.3 133.6 287.4L78.5 552L257.5 552L202.4 287.4C250.5 282.2 288 241.5 288 192L288 24L240 24L240 144C240 170.5 218.5 192 192 192L192 24L144 24L144 192zM198.5 504L137.5 504L168 357.7L198.5 504z"/></svg>
|
||||||
|
After Width: | Height: | Size: 754 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M552.5 48L552.5 24L528.5 24L528.5 48L552.5 48zM129.3 481.1C175.6 520.8 224.7 538.1 273.2 535.5C327.3 532.6 376.7 505.1 417.1 464.8C486.5 395.4 519.7 289.4 535.9 204.4C544.1 161.3 548.2 122.4 550.3 94.3C551.3 80.2 551.8 68.8 552.1 60.8C552.2 56.8 552.3 53.7 552.3 51.6C552.3 50.5 552.3 49.7 552.3 49.1L552.3 48.4L552.3 48.2L552.3 48.1L552.3 48.1L528.3 48.1C528.3 24.1 528.3 24.1 528.3 24.1L528.2 24.1L528 24.1L527.3 24.1C526.7 24.1 525.9 24.1 524.8 24.1C522.6 24.1 519.5 24.2 515.6 24.3C507.6 24.6 496.2 25.1 482.1 26.1C454 28.2 415.1 32.3 372 40.5C288.6 56.4 185.2 88.6 115.8 155.2L115.8 155.2C115.1 155.9 114.4 156.6 113.8 157.3C113.1 158 112.4 158.7 111.7 159.4C71.3 199.8 43.9 249.2 40.9 303.3C38.3 351.8 55.6 401 95.3 447.2L7.5 535L41.4 569L129.3 481.1zM196.8 413.6L226.4 384L360.5 384L360.5 336L274.4 336L425.4 185L391.5 151.1L240.5 302.1L240.5 216L192.5 216L192.5 350.1L162.8 379.8C128.6 341.6 113 308.8 110.1 280.8C107 250.6 118.5 221.2 147.8 191.1C206.8 133.6 299.4 103.2 381.2 87.6C422 79.8 459 75.9 485.8 73.9C492.4 73.4 498.4 73.1 503.7 72.8C503.4 78.1 503.1 84 502.6 90.7C500.6 117.5 496.7 154.5 488.9 195.3C474.4 271 447.3 356.2 397.6 415.1L397.5 415C366.4 446.1 333.2 461.4 298.9 460.5C269 459.7 234.6 446.4 196.8 413.6z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M312 144L312 48L264 48L264 144L192 144L192 48L144 48L144 144L96 144C43 144 0 187 0 240L0 528L576 528L576 240C576 187 533 144 480 144L432 144L432 48L384 48L384 144L312 144zM432 192C458.5 192 480 213.5 480 240L480 336C472.6 336 462.4 332.4 449 319C430.4 300.4 408.6 288 384 288C359.4 288 337.6 300.4 319 319C305.6 332.4 295.4 336 288 336C280.6 336 270.4 332.4 257 319C238.4 300.4 216.6 288 192 288C167.4 288 145.6 300.4 127 319C113.6 332.4 103.4 336 96 336L96 240C96 213.5 117.5 192 144 192L432 192zM96 480L96 384C120.6 384 142.4 371.6 161 353C174.4 339.6 184.6 336 192 336C199.4 336 209.6 339.6 223 353C241.6 371.6 263.4 384 288 384C312.6 384 334.4 371.6 353 353C366.4 339.6 376.6 336 384 336C391.4 336 401.6 339.6 415 353C433.6 371.6 455.4 384 480 384L480 480L96 480z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 576"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M309.6 0L266.4 0L222 47.9C208.1 62.9 188.5 71.2 168.1 70.9L89.2 63L63 89.4L70.9 164.2C71.2 183.7 63.6 202.5 49.8 216.3L0 266.1L0 309.6L47.9 354C62.9 367.9 71.2 387.5 70.9 407.9L63 486.7L91 512.7L164.3 499.9C188.2 497.7 211.6 507.6 226.7 526.3L266.9 575.9L309.2 575.9L349.4 526.3C364.5 507.6 387.9 497.8 411.8 499.9L485.1 512.7L513.1 486.7L505.2 407.9C504.9 387.5 513.2 367.9 528.2 354L576.1 309.6L576.1 266.1L526.3 216.3C512.5 202.5 504.9 183.7 505.2 164.2L513.1 89.4L486.8 63L408 70.9C387.6 71.2 368 62.9 354.1 47.9L309.6 0zM288 64.4L292.8 73.8C319.6 125.9 380.2 151 436 133.1L446 129.9L442.8 139.9C424.9 195.7 450 256.3 502.1 283.1L511.5 287.9L502.1 292.7C450 319.5 424.9 380.1 442.8 435.9L446 445.9L436 442.7C380.2 424.8 319.6 449.9 292.8 502L288 511.4L283.2 502C256.4 449.9 195.8 424.8 140 442.7L130 445.9L133.2 435.9C151.1 380.1 126 319.5 73.9 292.7L64.5 287.9L73.9 283.1C126 256.3 151.1 195.7 133.2 139.9L130 129.9L140 133.1C195.8 151 256.4 125.9 283.2 73.8L288 64.4zM322.3 224C330.9 238.4 336 260.5 336 288C336 315.5 330.9 337.6 322.3 352C314.5 365.1 303.9 372 288 372C272.1 372 261.5 365.1 253.7 352C245.1 337.6 240 315.5 240 288C240 260.5 245.1 238.4 253.7 224C261.5 210.9 272.1 204 288 204C303.9 204 314.5 210.9 322.3 224zM288 156C244.8 156 210.8 170 187.8 195.6C165.2 220.7 156 254.1 156 288C156 321.9 165.2 355.3 187.8 380.4C210.8 406 244.8 420 288 420C331.2 420 365.2 406 388.2 380.4C410.8 355.3 420 321.9 420 288C420 254.1 410.8 220.7 388.2 195.6C365.2 170 331.2 156 288 156z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -4,6 +4,38 @@
|
|||||||
return meta ? meta.getAttribute("content") : "";
|
return meta ? meta.getAttribute("content") : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scrollKey = "nouri-week-scroll";
|
||||||
|
|
||||||
|
const rememberScroll = () => {
|
||||||
|
sessionStorage.setItem(scrollKey, String(window.scrollY));
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreScroll = () => {
|
||||||
|
const savedScroll = sessionStorage.getItem(scrollKey);
|
||||||
|
if (!savedScroll) return;
|
||||||
|
sessionStorage.removeItem(scrollKey);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({ top: Number(savedScroll), left: 0, behavior: "auto" });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const postAndRefreshInPlace = async (form) => {
|
||||||
|
const payload = new URLSearchParams(new FormData(form));
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
body: payload.toString(),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("request failed");
|
||||||
|
}
|
||||||
|
rememberScroll();
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
const initWeekDragAndDrop = () => {
|
const initWeekDragAndDrop = () => {
|
||||||
const board = document.querySelector(".week-board");
|
const board = document.querySelector(".week-board");
|
||||||
if (!board) return;
|
if (!board) return;
|
||||||
@@ -63,11 +95,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
rememberScroll();
|
||||||
if (result.redirect_url) {
|
if (result.redirect_url) {
|
||||||
window.location.href = result.redirect_url;
|
window.location.href = result.redirect_url;
|
||||||
} else {
|
} else {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
rememberScroll();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initWeekCopyForward = () => {
|
||||||
|
document.querySelectorAll(".js-copy-forward-form").forEach((form) => {
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postAndRefreshInPlace(form);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
}
|
}
|
||||||
@@ -75,7 +122,239 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initWeekSlotPicker = () => {
|
||||||
|
const board = document.querySelector(".week-board");
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
const closeAllPickers = () => {
|
||||||
|
board.querySelectorAll(".week-card").forEach((card) => {
|
||||||
|
card.classList.remove("has-open-picker");
|
||||||
|
});
|
||||||
|
board.querySelectorAll(".week-slot").forEach((slot) => {
|
||||||
|
slot.classList.remove("is-picker-open");
|
||||||
|
});
|
||||||
|
board.querySelectorAll(".week-slot-picker").forEach((picker) => {
|
||||||
|
picker.hidden = true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
board.querySelectorAll("[data-week-slot-picker-open]").forEach((button) => {
|
||||||
|
button.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const slot = button.closest(".week-slot");
|
||||||
|
if (!slot) return;
|
||||||
|
const picker = slot.querySelector(".week-slot-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
const card = slot.closest(".week-card");
|
||||||
|
const shouldOpen = picker.hidden;
|
||||||
|
closeAllPickers();
|
||||||
|
if (shouldOpen) {
|
||||||
|
picker.hidden = false;
|
||||||
|
slot.classList.add("is-picker-open");
|
||||||
|
if (card) {
|
||||||
|
card.classList.add("has-open-picker");
|
||||||
|
}
|
||||||
|
const filterInput = picker.querySelector("[data-filter-input]");
|
||||||
|
if (filterInput instanceof HTMLInputElement) {
|
||||||
|
filterInput.value = "";
|
||||||
|
filterInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
window.requestAnimationFrame(() => filterInput.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
board.querySelectorAll("[data-week-slot-picker-close]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const slot = button.closest(".week-slot");
|
||||||
|
if (!slot) return;
|
||||||
|
const picker = slot.querySelector(".week-slot-picker");
|
||||||
|
const card = slot.closest(".week-card");
|
||||||
|
if (!picker) return;
|
||||||
|
picker.hidden = true;
|
||||||
|
slot.classList.remove("is-picker-open");
|
||||||
|
if (card) {
|
||||||
|
card.classList.remove("has-open-picker");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
board.querySelectorAll(".js-week-slot-submit").forEach((form) => {
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postAndRefreshInPlace(form);
|
||||||
|
} catch (_error) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
if (target.closest(".week-slot")) return;
|
||||||
|
closeAllPickers();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initWeekEntryDialogs = () => {
|
||||||
|
const board = document.querySelector(".week-board");
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
const openDialog = (trigger) => {
|
||||||
|
const dialogId = trigger.getAttribute("data-week-entry-dialog-id");
|
||||||
|
if (!dialogId) return;
|
||||||
|
const dialog = document.getElementById(dialogId);
|
||||||
|
if (!(dialog instanceof HTMLDialogElement)) return;
|
||||||
|
if (!dialog.open) {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
board.querySelectorAll("[data-week-entry-open]").forEach((entry) => {
|
||||||
|
entry.addEventListener("click", (event) => {
|
||||||
|
if (event.target instanceof Element && event.target.closest("button, a, input, select, textarea, label, form")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openDialog(entry);
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
openDialog(entry);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".week-entry-dialog").forEach((dialog) => {
|
||||||
|
if (!(dialog instanceof HTMLDialogElement)) return;
|
||||||
|
|
||||||
|
dialog.addEventListener("click", (event) => {
|
||||||
|
const rect = dialog.getBoundingClientRect();
|
||||||
|
const clickedInside =
|
||||||
|
rect.top <= event.clientY &&
|
||||||
|
event.clientY <= rect.top + rect.height &&
|
||||||
|
rect.left <= event.clientX &&
|
||||||
|
event.clientX <= rect.left + rect.width;
|
||||||
|
if (!clickedInside) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-week-entry-close]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const dialog = button.closest(".week-entry-dialog");
|
||||||
|
if (dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".js-week-entry-submit").forEach((form) => {
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
await postAndRefreshInPlace(form);
|
||||||
|
} catch (_error) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncActionContainerVisibility = (container) => {
|
||||||
|
if (!(container instanceof HTMLElement)) return;
|
||||||
|
const hasVisibleButtons = Array.from(container.querySelectorAll("button")).some((button) => {
|
||||||
|
return !button.hidden;
|
||||||
|
});
|
||||||
|
container.hidden = !hasVisibleButtons;
|
||||||
|
};
|
||||||
|
|
||||||
|
const revealActionButton = (container, selector) => {
|
||||||
|
if (!(container instanceof HTMLElement) || !selector) return;
|
||||||
|
const button = container.querySelector(`button[data-target="${selector}"]`);
|
||||||
|
if (!(button instanceof HTMLButtonElement)) return;
|
||||||
|
button.hidden = false;
|
||||||
|
container.hidden = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initDaySnackReveal = () => {
|
||||||
|
document.querySelectorAll("[data-day-snack-open]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const selector = button.getAttribute("data-target");
|
||||||
|
if (!selector) return;
|
||||||
|
const tile = document.querySelector(selector);
|
||||||
|
if (!(tile instanceof HTMLDetailsElement)) return;
|
||||||
|
tile.hidden = false;
|
||||||
|
tile.open = true;
|
||||||
|
button.hidden = true;
|
||||||
|
tile.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||||
|
syncActionContainerVisibility(button.closest("[data-day-snack-actions]"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-day-snack-hide]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const selector = button.getAttribute("data-target");
|
||||||
|
if (!selector) return;
|
||||||
|
const tile = document.querySelector(selector);
|
||||||
|
if (!(tile instanceof HTMLDetailsElement)) return;
|
||||||
|
tile.open = false;
|
||||||
|
tile.hidden = true;
|
||||||
|
revealActionButton(document.querySelector("[data-day-snack-actions]"), selector);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initWeekSnackReveal = () => {
|
||||||
|
document.querySelectorAll("[data-week-snack-slot-open]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const selector = button.getAttribute("data-target");
|
||||||
|
if (!selector) return;
|
||||||
|
const slot = document.querySelector(selector);
|
||||||
|
if (!(slot instanceof HTMLElement)) return;
|
||||||
|
slot.hidden = false;
|
||||||
|
button.hidden = true;
|
||||||
|
syncActionContainerVisibility(button.closest("[data-week-snack-actions]"));
|
||||||
|
const openButton = slot.querySelector("[data-week-slot-picker-open]");
|
||||||
|
if (openButton instanceof HTMLButtonElement) {
|
||||||
|
openButton.click();
|
||||||
|
} else {
|
||||||
|
slot.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-week-snack-slot-hide]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const selector = button.getAttribute("data-target");
|
||||||
|
if (!selector) return;
|
||||||
|
const slot = document.querySelector(selector);
|
||||||
|
if (!(slot instanceof HTMLElement)) return;
|
||||||
|
const picker = slot.querySelector(".week-slot-picker");
|
||||||
|
if (picker instanceof HTMLElement) {
|
||||||
|
picker.hidden = true;
|
||||||
|
}
|
||||||
|
slot.classList.remove("is-picker-open");
|
||||||
|
slot.hidden = true;
|
||||||
|
const card = slot.closest(".week-card");
|
||||||
|
if (card) {
|
||||||
|
card.classList.remove("has-open-picker");
|
||||||
|
}
|
||||||
|
revealActionButton(slot.closest(".week-card")?.querySelector("[data-week-snack-actions]"), selector);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
restoreScroll();
|
||||||
initWeekDragAndDrop();
|
initWeekDragAndDrop();
|
||||||
|
initWeekCopyForward();
|
||||||
|
initWeekSlotPicker();
|
||||||
|
initWeekEntryDialogs();
|
||||||
|
initDaySnackReveal();
|
||||||
|
initWeekSnackReveal();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -12,7 +12,22 @@
|
|||||||
root.dataset.theme = finalTheme;
|
root.dataset.theme = finalTheme;
|
||||||
|
|
||||||
toggles().forEach((button) => {
|
toggles().forEach((button) => {
|
||||||
button.textContent = finalTheme === "dark" ? "Hell" : "Dunkel";
|
const nextModeLabel = finalTheme === "dark" ? "Hell" : "Dunkel";
|
||||||
|
const label = button.querySelector("[data-theme-label]");
|
||||||
|
const icon = button.querySelector("[data-theme-icon]");
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
label.textContent = nextModeLabel;
|
||||||
|
} else {
|
||||||
|
button.textContent = nextModeLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
icon.classList.toggle("icon-sun-theme", finalTheme === "dark");
|
||||||
|
icon.classList.toggle("icon-moon-theme", finalTheme !== "dark");
|
||||||
|
}
|
||||||
|
|
||||||
|
button.setAttribute("aria-label", `${nextModeLabel} aktivieren`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,62 @@
|
|||||||
(() => {
|
(() => {
|
||||||
|
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 initMobileSheet = () => {
|
||||||
const sheet = document.querySelector("[data-mobile-sheet]");
|
const sheet = document.querySelector("[data-mobile-sheet]");
|
||||||
const navStack = document.querySelector("[data-mobile-nav-stack]");
|
const navStack = document.querySelector("[data-mobile-nav-stack]");
|
||||||
@@ -54,6 +112,7 @@
|
|||||||
const filterGroups = Array.from(container.querySelectorAll("[data-filter-group]"));
|
const filterGroups = Array.from(container.querySelectorAll("[data-filter-group]"));
|
||||||
const resultLimit = Number.parseInt(input.getAttribute("data-filter-limit") || "", 10);
|
const resultLimit = Number.parseInt(input.getAttribute("data-filter-limit") || "", 10);
|
||||||
const hasLimit = Number.isFinite(resultLimit) && resultLimit > 0;
|
const hasLimit = Number.isFinite(resultLimit) && resultLimit > 0;
|
||||||
|
const hideWhenEmpty = input.hasAttribute("data-filter-hide-empty");
|
||||||
|
|
||||||
const scoreItem = (label, term) => {
|
const scoreItem = (label, term) => {
|
||||||
if (label === term) return 0;
|
if (label === term) return 0;
|
||||||
@@ -78,12 +137,14 @@
|
|||||||
const applyFilter = () => {
|
const applyFilter = () => {
|
||||||
const term = input.value.trim().toLowerCase();
|
const term = input.value.trim().toLowerCase();
|
||||||
if (!term) {
|
if (!term) {
|
||||||
items.forEach((item) => {
|
items.forEach((item, index) => {
|
||||||
item.hidden = false;
|
item.hidden = hideWhenEmpty || (hasLimit ? index >= resultLimit : false);
|
||||||
});
|
});
|
||||||
|
container.hidden = hideWhenEmpty;
|
||||||
syncGroups();
|
syncGroups();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
container.hidden = false;
|
||||||
|
|
||||||
const rankedMatches = items
|
const rankedMatches = items
|
||||||
.map((item, index) => {
|
.map((item, index) => {
|
||||||
@@ -109,8 +170,180 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
initPostFormScrollMemory();
|
||||||
initMobileSheet();
|
initMobileSheet();
|
||||||
initFilterInputs();
|
initFilterInputs();
|
||||||
|
initSelectedPreviews();
|
||||||
|
initCreateFromSearch();
|
||||||
|
initIosPullToRefresh();
|
||||||
|
initDialogs();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_NAME = "nouri-v1-0-0";
|
const CACHE_NAME = "nouri-v1-3-4";
|
||||||
const OFFLINE_URL = "/static/pwa/offline.html";
|
const OFFLINE_URL = "/static/pwa/offline.html";
|
||||||
const STATIC_ASSETS = [
|
const STATIC_ASSETS = [
|
||||||
"/static/css/styles.css",
|
"/static/css/styles.css",
|
||||||
|
|||||||
@@ -66,6 +66,12 @@
|
|||||||
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
|
{% if category.is_active %}Pausieren{% else %}Wieder aktivieren{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if category.name not in default_categories %}
|
||||||
|
<form method="post" action="{{ url_for('admin.category_delete', category_id=category.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="ghost-button" type="submit">Löschen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@
|
|||||||
<input type="password" name="password_repeat" autocomplete="new-password" {% if not user %}required{% endif %}>
|
<input type="password" name="password_repeat" autocomplete="new-password" {% if not user %}required{% endif %}>
|
||||||
</label>
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<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>
|
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Archiv</p>
|
<p class="eyebrow">Archiv</p>
|
||||||
<h1>Frühere Ideen bleiben greifbar</h1>
|
<h1>Frühere Ideen bleiben greifbar</h1>
|
||||||
<p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder einplanen oder einkaufen.</p>
|
<p class="lead">Archiv bedeutet bewusst ausgeblendet, nicht verbraucht. Von hier aus lassen sich Dinge jederzeit wieder aktivieren.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -61,7 +61,21 @@
|
|||||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
<span class="chip">{{ item.for_label }}</span>
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
{% if item.kind == 'food' %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.base_type_label }}</span>
|
||||||
|
<span class="chip">{{ item.suggestion_role_label }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">{{ item_kind_labels[item.kind] }}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.meal_type_label }}</span>
|
||||||
|
<span class="chip">{{ energy_density_labels[item.energy_density] }}</span>
|
||||||
|
{% for tag in item.meal_tag_labels %}
|
||||||
|
<span class="chip">{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if item.dayparts %}
|
{% if item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
{% for daypart in item.dayparts %}
|
{% for daypart in item.dayparts %}
|
||||||
@@ -79,13 +93,15 @@
|
|||||||
<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>
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
{% if item.can_edit %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
||||||
|
{% endif %}
|
||||||
{% if item.can_edit %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,10 @@
|
|||||||
E-Mail
|
E-Mail
|
||||||
<input type="email" name="email" value="{{ g.user.email or '' }}" autocomplete="email">
|
<input type="email" name="email" value="{{ g.user.email or '' }}" autocomplete="email">
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Speichern</button>
|
<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>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="de" data-theme="auto">
|
<html lang="de" data-theme="auto">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
<title>{% block title %}Nouri{% endblock %}</title>
|
<title>{% block title %}Nouri{% endblock %}</title>
|
||||||
<meta name="theme-color" content="#de9862">
|
<meta name="theme-color" content="#de9862">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
@@ -53,7 +53,10 @@
|
|||||||
{% if g.user %}
|
{% if g.user %}
|
||||||
<div class="desktop-header-sub">
|
<div class="desktop-header-sub">
|
||||||
<div class="header-actions desktop-actions">
|
<div class="header-actions desktop-actions">
|
||||||
<button class="theme-toggle ghost-button" type="button" data-theme-toggle>Hell</button>
|
<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="ghost-button" href="{{ url_for('main.settings_view') }}">Optionen</a>
|
||||||
<a class="user-chip" href="{{ url_for('auth.profile') }}">
|
<a class="user-chip" href="{{ url_for('auth.profile') }}">
|
||||||
<span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span>
|
<span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span>
|
||||||
@@ -91,7 +94,7 @@
|
|||||||
|
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="footer-copy">
|
<div class="footer-copy">
|
||||||
<span>Version {{ app_version }}</span>
|
<a href="{{ app_release_url }}" target="_blank" rel="noreferrer">Version {{ app_version }}</a>
|
||||||
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
|
<span>Made with <span class="ui-icon icon-heart"></span> in Göttingen</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-copy">
|
<div class="footer-copy">
|
||||||
@@ -112,11 +115,10 @@
|
|||||||
<a class="mobile-extra-link" href="{{ url_for('auth.profile') }}"><span class="ui-icon icon-heart"></span><span>Profil</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' %}
|
{% if g.user.role == 'admin' %}
|
||||||
<a class="mobile-extra-link" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
|
<a class="mobile-extra-link" href="{{ url_for('admin.user_list') }}"><span class="ui-icon icon-sparkles"></span><span>Nutzer</span></a>
|
||||||
<a class="mobile-extra-link" href="{{ url_for('admin.category_settings') }}"><span class="ui-icon icon-seedling"></span><span>Kategorien</span></a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class="mobile-extra-link mobile-extra-button" type="button" data-theme-toggle>
|
<button class="mobile-extra-link mobile-extra-button" type="button" data-theme-toggle aria-label="Darstellung wechseln">
|
||||||
<span class="ui-icon icon-mobile-screen-button"></span>
|
<span class="ui-icon icon-sun-theme" data-theme-icon></span>
|
||||||
<span>Modus</span>
|
<span data-theme-label>Hell</span>
|
||||||
</button>
|
</button>
|
||||||
<form method="post" action="{{ url_for('auth.logout') }}" class="mobile-extra-form">
|
<form method="post" action="{{ url_for('auth.logout') }}" class="mobile-extra-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Heute</p>
|
<p class="eyebrow">Heute</p>
|
||||||
<h1>Ein ruhiger Blick auf euren Alltag</h1>
|
<h1>Ein guter Blick auf euren Alltag</h1>
|
||||||
<p class="lead">Du siehst schnell, was zuhause da ist, was schon geplant wurde, welche Vorlagen gut passen und wo heute noch etwas ergänzt werden könnte.</p>
|
<p class="lead">Du siehst, was zuhause da ist, was schon geplant wurde, welche Vorlagen passen und wo heute noch etwas dazukommen kann.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
|
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<span class="chip">{{ entry.for_label }}</span>
|
<span class="chip">{{ entry.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if entry.availability_state == 'home' %}
|
{% if entry.is_home %}
|
||||||
<span class="status-pill status-home">zuhause</span>
|
<span class="status-pill status-home">zuhause</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel">
|
<article class="panel dashboard-spaced-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Kurz griffbereit</h2>
|
<h2>Kurz griffbereit</h2>
|
||||||
<a href="{{ url_for('main.home_view') }}">Alles unter Zuhause</a>
|
<a href="{{ url_for('main.home_view') }}">Alles unter Zuhause</a>
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="two-column">
|
<section class="two-column">
|
||||||
<article class="panel">
|
<article class="panel dashboard-spaced-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Was zuhause gut zusammenpasst</h2>
|
<h2>Was zuhause gut zusammenpasst</h2>
|
||||||
<a href="{{ url_for('main.home_view') }}">Zuhause öffnen</a>
|
<a href="{{ url_for('main.home_view') }}">Zuhause öffnen</a>
|
||||||
@@ -134,8 +134,26 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>{{ suggestion.title }}</strong>
|
<strong>{{ suggestion.title }}</strong>
|
||||||
<small>{{ suggestion.reason }}</small>
|
<small>{{ suggestion.reason }}</small>
|
||||||
|
{% if suggestion.needs_shopping and suggestion.missing_components %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip status-idea">Es fehlt noch: {{ suggestion.missing_components|join(', ') }}</span>
|
||||||
</div>
|
</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>
|
<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>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -205,8 +223,8 @@
|
|||||||
<span>{{ card.planned_count }} Einträge</span>
|
<span>{{ card.planned_count }} Einträge</span>
|
||||||
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
|
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span>Noch frei</span>
|
<span>Noch offen</span>
|
||||||
<small>ruhiger Einstieg für den Tag</small>
|
<small>Du kannst mit einem Eintrag anfangen.</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Zuhause</p>
|
<p class="eyebrow">Zuhause</p>
|
||||||
<h1>Was aktuell da ist</h1>
|
<h1>Was aktuell da ist</h1>
|
||||||
<p class="lead">Sichtbar, ruhig und nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, bleibt es später im Archiv greifbar.</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>
|
||||||
|
|
||||||
@@ -51,8 +51,26 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>{{ suggestion.title }}</strong>
|
<strong>{{ suggestion.title }}</strong>
|
||||||
<small>{{ suggestion.reason }}</small>
|
<small>{{ suggestion.reason }}</small>
|
||||||
|
{% if suggestion.needs_shopping and suggestion.missing_components %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip status-idea">Es fehlt noch: {{ suggestion.missing_components|join(', ') }}</span>
|
||||||
</div>
|
</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>
|
<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>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -89,23 +107,44 @@
|
|||||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
<span class="chip">{{ item.for_label }}</span>
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
{% if item.kind == 'food' %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.base_type_label }}</span>
|
||||||
|
<span class="chip">{{ item.suggestion_role_label }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">{{ item_kind_labels[item.kind] }}</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.meal_type_label }}</span>
|
||||||
|
<span class="chip">{{ energy_density_labels[item.energy_density] }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% if item.components %}
|
{% if item.components %}
|
||||||
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
|
{% 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>
|
<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 %}
|
{% if item.can_edit %}
|
||||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
<form method="post" action="{{ url_for('main.item_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 %}
|
{% 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,8 +4,22 @@
|
|||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||||
<h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1>
|
<h1>
|
||||||
<p class="lead">Nur das Nötigste: Name, Sichtbarkeit, für wen etwas gedacht ist, Bild, Tageszeiten und eine kleine Notiz.</p>
|
{% if item and kind == 'meal' %}
|
||||||
|
{{ item.name }}
|
||||||
|
{% elif item %}
|
||||||
|
{{ item.name }} bearbeiten
|
||||||
|
{% else %}
|
||||||
|
Neue {{ item_kind_singular_labels[kind] }}
|
||||||
|
{% endif %}
|
||||||
|
</h1>
|
||||||
|
<p class="lead">
|
||||||
|
{% if kind == 'food' %}
|
||||||
|
Name, Sichtbarkeit und ein paar ruhige Hinweise dazu, wie ein Lebensmittel in Vorschlägen gut passt.
|
||||||
|
{% else %}
|
||||||
|
Name, Sichtbarkeit, Tageszeit und ein kurzer Charakter der Idee. So bleiben Mahlzeitenideen alltagsnah und leicht pflegbar.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% if item %}
|
{% if item %}
|
||||||
<div class="intro-pills">
|
<div class="intro-pills">
|
||||||
@@ -45,12 +59,62 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if kind == 'food' %}
|
||||||
|
<div class="dual-grid">
|
||||||
<label>
|
<label>
|
||||||
Kategorie
|
Baustein
|
||||||
<select name="category">
|
<select name="base_type">
|
||||||
<option value="">Ohne Kategorie</option>
|
{% for value, label in builder_options %}
|
||||||
{% for category in categories %}
|
<option value="{{ value }}" {% if form_data.base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
<option value="{{ category }}" {% if form_data.category == category %}selected{% endif %}>{{ category }}</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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -64,6 +128,38 @@
|
|||||||
</select>
|
</select>
|
||||||
<small class="helper-text">Hilft Nouri dabei, passende Ideen etwas ruhiger und persönlicher zu sortieren.</small>
|
<small class="helper-text">Hilft Nouri dabei, passende Ideen etwas ruhiger und persönlicher zu sortieren.</small>
|
||||||
</label>
|
</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
|
||||||
@@ -86,23 +182,30 @@
|
|||||||
</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>
|
||||||
<p class="muted">Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenstellen.</p>
|
<p class="muted">Du kannst eine Mahlzeitenidee frei benennen oder aus sichtbaren Lebensmitteln zusammensetzen. Nouri nutzt dabei später Grundtyp, Rolle und Tageszeit der Lebensmittel für ruhigere Vorschläge.</p>
|
||||||
<div class="inline-form">
|
<div class="meal-component-search">
|
||||||
<label class="wide">
|
<label class="wide">
|
||||||
Lebensmittel suchen
|
Lebensmittel suchen
|
||||||
<input
|
<input
|
||||||
@@ -112,25 +215,104 @@
|
|||||||
placeholder="z. B. Reis, Banane, Joghurt"
|
placeholder="z. B. Reis, Banane, Joghurt"
|
||||||
data-filter-input
|
data-filter-input
|
||||||
data-filter-target="#meal-components-list"
|
data-filter-target="#meal-components-list"
|
||||||
data-filter-limit="3"
|
data-filter-limit="8"
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
<button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="helper-text">Während der Suche zeigt Nouri nur die drei passendsten Lebensmittel, damit die Auswahl ruhig bleibt.</p>
|
<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 %}
|
{% if food_groups %}
|
||||||
<div class="stack-sections" id="meal-components-list">
|
<div class="meal-component-results" id="meal-components-list">
|
||||||
{% for group in food_groups %}
|
{% for group in food_groups %}
|
||||||
<div class="component-group">
|
<div class="component-group">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h3>{{ group["title"] }}</h3>
|
<h3>{{ group["title"] }}</h3>
|
||||||
<span>{{ group["items"]|length }} Einträge</span>
|
<span>{{ group["items"]|length }} Einträge</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox-grid filterable-checkbox-group" data-filter-group>
|
<div class="meal-component-option-grid" data-filter-group>
|
||||||
{% for food in group["items"] %}
|
{% for food in group["items"] %}
|
||||||
<label class="check-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }}">
|
{% 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 }} · {{ food.visibility_label }} · {{ food.for_label }}</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>
|
||||||
@@ -151,11 +333,34 @@
|
|||||||
<input type="text" name="quick_food_name" value="{{ form_data.quick_food_name }}" placeholder="z. B. Hüttenkäse">
|
<input type="text" name="quick_food_name" value="{{ form_data.quick_food_name }}" placeholder="z. B. Hüttenkäse">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Kategorie
|
Baustein
|
||||||
<select name="quick_food_category">
|
<select name="quick_food_base_type">
|
||||||
<option value="">Ohne Kategorie</option>
|
{% for value, label in builder_options %}
|
||||||
{% for category in categories %}
|
<option value="{{ value }}" {% if form_data.quick_food_base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
<option value="{{ category }}" {% if form_data.quick_food_category == category %}selected{% endif %}>{{ category }}</option>
|
{% endfor %}
|
||||||
|
</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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -167,6 +372,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="inline-check">
|
||||||
|
<input type="checkbox" name="quick_food_can_be_meal_core" value="1" {% if form_data.quick_food_can_be_meal_core %}checked{% endif %}>
|
||||||
|
<span>Kann gut eine Mahlzeit tragen</span>
|
||||||
|
</label>
|
||||||
<label class="wide">
|
<label class="wide">
|
||||||
Notiz
|
Notiz
|
||||||
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
|
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
|
||||||
@@ -178,7 +387,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" name="form_action" value="save_item">Speichern</button>
|
<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('main.item_list', kind=kind) }}">Zurück</a>
|
<a class="ghost-button" href="{{ url_for('main.item_list', kind=kind) }}">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,9 +5,18 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||||
<h1>{{ item_kind_labels[kind] }}</h1>
|
<h1>{{ item_kind_labels[kind] }}</h1>
|
||||||
|
{% if kind == 'food' %}
|
||||||
|
<p class="lead">Hier stehen alle aktiven Lebensmittel, egal ob sie gerade zuhause sind oder im Moment fehlen. Archiviertes bleibt bewusst außen vor.</p>
|
||||||
|
{% else %}
|
||||||
<p class="lead">Gemeinsame und persönliche Ideen bleiben hier ruhig sortiert, mit einem klaren Blick darauf, für wen etwas gedacht ist.</p>
|
<p class="lead">Gemeinsame und persönliche Ideen bleiben hier ruhig sortiert, mit einem klaren Blick darauf, für wen etwas gedacht ist.</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
{% if kind == 'food' %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_quick_add') }}">Schnell anlegen</a>
|
||||||
|
{% endif %}
|
||||||
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
|
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel compact-form-panel">
|
<section class="panel compact-form-panel">
|
||||||
@@ -51,8 +60,146 @@
|
|||||||
{% if items %}
|
{% if items %}
|
||||||
<section class="card-grid">
|
<section class="card-grid">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<article class="item-card">
|
{% if item.kind == 'food' %}
|
||||||
<div class="item-media">
|
{% set item_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(item.primary_builder_key or item.base_type, 'icon-component-neutral') %}
|
||||||
|
<article class="item-card item-card-food{% if not item.is_home %} item-card-food-muted{% endif %}">
|
||||||
|
{% if item.can_edit %}
|
||||||
|
<a class="item-card-cover-link" href="{{ url_for('main.item_edit', item_id=item.id) }}">
|
||||||
|
<span class="sr-only">{{ item.name }} bearbeiten</span>
|
||||||
|
</a>
|
||||||
|
<form
|
||||||
|
class="item-card-archive-form"
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('main.item_archive', item_id=item.id) }}"
|
||||||
|
onsubmit="return confirm('Willst du dieses Lebensmittel wirklich archivieren?');"
|
||||||
|
>
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="item-card-archive-button" type="submit" aria-label="{{ item.name }} archivieren">×</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<div class="item-card-quick-actions">
|
||||||
|
{% if item.can_edit %}
|
||||||
|
{% if item.is_home %}
|
||||||
|
<form method="post" action="{{ url_for('main.item_set_not_home', item_id=item.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="item-card-icon-button is-home" type="submit" aria-label="{{ item.name }} als nicht mehr da markieren" title="Lebensmittel ist zuhause">
|
||||||
|
<span class="ui-icon icon-house"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{{ url_for('main.item_set_home', item_id=item.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="item-card-icon-button is-missing" type="submit" aria-label="{{ item.name }} als zuhause markieren" title="Lebensmittel ist nicht zuhause">
|
||||||
|
<span class="ui-icon icon-house-xmark"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="{{ url_for('main.item_remove_from_shopping' if item.is_on_shopping_list else 'main.item_add_to_shopping', item_id=item.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button
|
||||||
|
class="item-card-icon-button {% if item.is_on_shopping_list %}is-active{% else %}is-inactive{% endif %}"
|
||||||
|
type="submit"
|
||||||
|
aria-label="{% if item.is_on_shopping_list %}{{ item.name }} von der Einkaufsliste entfernen{% else %}{{ item.name }} auf die Einkaufsliste setzen{% endif %}"
|
||||||
|
title="{% if item.is_on_shopping_list %}Auf Einkaufsliste{% else %}Nicht auf Einkaufsliste{% endif %}"
|
||||||
|
>
|
||||||
|
<span class="ui-icon icon-cart-shopping"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-media item-media-food">
|
||||||
|
{% if item.photo_filename %}
|
||||||
|
<img
|
||||||
|
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||||
|
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||||
|
sizes="{{ image_sizes('grid') }}"
|
||||||
|
alt="{{ item.name }}"
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<div class="placeholder-icon-tile">
|
||||||
|
<span class="ui-icon {{ item_icon_class }}"></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-body item-body-food">
|
||||||
|
<h2>{{ item.name }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-card-hover-meta" aria-hidden="true">
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
|
<span class="chip">{{ item.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
|
<span class="chip">{{ item.base_type_label }}</span>
|
||||||
|
<span class="chip">{{ item.suggestion_role_label }}</span>
|
||||||
|
<span class="chip">{{ item.suggestion_priority_label }}</span>
|
||||||
|
{% if item.can_be_meal_core %}
|
||||||
|
<span class="chip status-okay">Trägt gut eine Mahlzeit</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.is_on_shopping_list %}
|
||||||
|
<span class="chip status-idea">Auf Einkaufsliste</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.dayparts %}
|
||||||
|
{% for daypart in item.dayparts %}
|
||||||
|
<span class="chip">{{ daypart }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if item.note %}
|
||||||
|
<p>{{ item.note }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<article class="item-card item-card-meal">
|
||||||
|
{% if item.can_edit %}
|
||||||
|
<a class="item-card-cover-link" href="{{ url_for('main.item_edit', item_id=item.id) }}">
|
||||||
|
<span class="sr-only">{{ item.name }} bearbeiten</span>
|
||||||
|
</a>
|
||||||
|
<form
|
||||||
|
class="item-card-archive-form"
|
||||||
|
method="post"
|
||||||
|
action="{{ url_for('main.item_archive', item_id=item.id) }}"
|
||||||
|
onsubmit="return confirm('Willst du diese Mahlzeitenidee wirklich archivieren?');"
|
||||||
|
>
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="item-card-archive-button" type="submit" aria-label="{{ item.name }} archivieren">×</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<div class="item-card-quick-actions">
|
||||||
|
<a
|
||||||
|
class="item-card-icon-button item-card-icon-link"
|
||||||
|
href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}"
|
||||||
|
aria-label="{{ item.name }} im Tagesplan öffnen"
|
||||||
|
title="Im Tagesplan öffnen"
|
||||||
|
>
|
||||||
|
<span class="ui-icon icon-calendar"></span>
|
||||||
|
</a>
|
||||||
|
<form method="post" action="{{ url_for('main.item_remove_from_shopping' if item.is_on_shopping_list else 'main.item_add_to_shopping', item_id=item.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button
|
||||||
|
class="item-card-icon-button {% if item.is_on_shopping_list %}is-active{% else %}is-inactive{% endif %}"
|
||||||
|
type="submit"
|
||||||
|
aria-label="{% if item.is_on_shopping_list %}{{ item.name }} von der Einkaufsliste entfernen{% else %}{{ item.name }} auf die Einkaufsliste setzen{% endif %}"
|
||||||
|
title="{% if item.is_on_shopping_list %}Auf Einkaufsliste{% else %}Nicht auf Einkaufsliste{% endif %}"
|
||||||
|
>
|
||||||
|
<span class="ui-icon icon-cart-shopping"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-media item-media-meal">
|
||||||
{% if item.photo_filename %}
|
{% if item.photo_filename %}
|
||||||
<img
|
<img
|
||||||
src="{{ image_url(item.photo_filename, 'md') }}"
|
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||||
@@ -64,21 +211,25 @@
|
|||||||
<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-topline">
|
<div class="item-body item-body-meal">
|
||||||
<h2>{{ item.name }}</h2>
|
<h2>{{ item.name }}</h2>
|
||||||
<span class="status-pill status-{{ item.availability_state }}">{{ availability_labels[item.availability_state] }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="item-card-hover-meta" aria-hidden="true">
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
<span class="chip">{{ item.visibility_label }}</span>
|
<span class="chip">{{ item.visibility_label }}</span>
|
||||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
<span class="chip">{{ item.for_label }}</span>
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">
|
<div class="chip-row">
|
||||||
{% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %}
|
<span class="chip">{{ item.meal_type_label }}</span>
|
||||||
· {{ item_kind_labels[item.kind] }}
|
<span class="chip">{{ energy_density_labels[item.energy_density] }}</span>
|
||||||
</p>
|
{% for tag in item.meal_tag_labels %}
|
||||||
{% if item.dayparts %}
|
<span class="chip">{{ tag }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% if item.kind != 'food' and item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
{% for daypart in item.dayparts %}
|
{% for daypart in item.dayparts %}
|
||||||
<span class="chip">{{ daypart }}</span>
|
<span class="chip">{{ daypart }}</span>
|
||||||
@@ -88,33 +239,12 @@
|
|||||||
{% if item.components %}
|
{% if item.components %}
|
||||||
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.note %}
|
{% if item.kind != 'food' and item.note %}
|
||||||
<p>{{ item.note }}</p>
|
<p>{{ item.note }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
|
||||||
{% if item.can_edit %}
|
|
||||||
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
|
||||||
{% endif %}
|
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
|
||||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
|
||||||
{{ csrf_input() }}
|
|
||||||
<button type="submit">Auf Einkaufsliste</button>
|
|
||||||
</form>
|
|
||||||
{% if item.availability_state != 'home' and item.can_edit %}
|
|
||||||
<form method="post" action="{{ url_for('main.item_set_home', item_id=item.id) }}">
|
|
||||||
{{ csrf_input() }}
|
|
||||||
<button class="secondary" type="submit">Als Zuhause markieren</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% if item.availability_state != 'archived' and item.can_edit %}
|
|
||||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
|
||||||
{{ csrf_input() }}
|
|
||||||
<button class="ghost-button" type="submit">Ins Archiv</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Schnell anlegen | Nouri{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Lebensmittel</p>
|
||||||
|
<h1>Schnell anlegen</h1>
|
||||||
|
<p class="lead">Hier kannst du mehrere Lebensmittel in einem Schritt anlegen. Sie landen zuerst als ruhige Platzhalter auf der Einkaufsliste und lassen sich später einzeln weiter ordnen.</p>
|
||||||
|
</div>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_list', kind='food') }}">Zurück</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<form method="post" class="stack-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Namen untereinander
|
||||||
|
<textarea name="names_text" rows="10" placeholder="Erdbeeren Himbeeren Blaubeeren" required>{{ form_data.names_text }}</textarea>
|
||||||
|
<small class="helper-text">Jede Zeile wird als eigenes Lebensmittel angelegt.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="dual-grid">
|
||||||
|
<label>
|
||||||
|
Sichtbarkeit
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div class="dual-grid">
|
||||||
|
<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>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Passende Tageszeiten</legend>
|
||||||
|
<div class="checkbox-grid daypart-option-grid">
|
||||||
|
{% for daypart in dayparts %}
|
||||||
|
<label class="daypart-option">
|
||||||
|
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
||||||
|
<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>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Gemeinsame Notiz
|
||||||
|
<textarea name="note" rows="3" placeholder="Optional, wenn diese Gruppe etwas gemeinsam braucht.">{{ form_data.note }}</textarea>
|
||||||
|
<small class="helper-text">Die Einträge starten in „Unsortiert“, sind zunächst nicht zuhause und tauchen erst nach einer späteren Einordnung im Alltag regulär auf.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">In Unsortiert anlegen</button>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_list', kind='food') }}">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -85,7 +85,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<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('main.template_library') }}">Zurück</a>
|
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -38,6 +38,61 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if selected_items %}
|
||||||
|
<fieldset>
|
||||||
|
<legend>Schon ausgewählt</legend>
|
||||||
|
{% for item_id in form_data.item_ids %}
|
||||||
|
<input type="hidden" name="item_ids" value="{{ item_id }}">
|
||||||
|
{% endfor %}
|
||||||
|
<div class="selected-components-grid">
|
||||||
|
{% for item in selected_items %}
|
||||||
|
{% if item.kind == 'meal' %}
|
||||||
|
{% set item_icon_class = {
|
||||||
|
'breakfast': 'icon-daypart-breakfast',
|
||||||
|
'lunch': 'icon-daypart-lunch',
|
||||||
|
'dinner': 'icon-daypart-dinner',
|
||||||
|
'snack': 'icon-daypart-afternoon-snack',
|
||||||
|
}.get(item.meal_type, 'icon-utensils') %}
|
||||||
|
{% else %}
|
||||||
|
{% set item_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(item.primary_builder_key or item.base_type, 'icon-component-neutral') %}
|
||||||
|
{% endif %}
|
||||||
|
<article class="selected-component-card">
|
||||||
|
<button class="selected-component-remove" type="submit" name="remove_item_id" value="{{ item.id }}">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
<span class="sr-only">{{ item.name }} entfernen</span>
|
||||||
|
</button>
|
||||||
|
<div class="selected-component-visual">
|
||||||
|
{% if item.photo_filename %}
|
||||||
|
<img
|
||||||
|
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||||
|
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||||
|
sizes="{{ image_sizes('grid') }}"
|
||||||
|
alt="{{ item.name }}"
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<span class="selected-component-fallback">
|
||||||
|
<span class="ui-icon {{ item_icon_class }}"></span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="selected-component-main">
|
||||||
|
<strong>{{ item.name }}</strong>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Einträge auswählen</legend>
|
<legend>Einträge auswählen</legend>
|
||||||
<label>
|
<label>
|
||||||
@@ -51,11 +106,46 @@
|
|||||||
<h3>{{ group["title"] }}</h3>
|
<h3>{{ group["title"] }}</h3>
|
||||||
<span>{{ group["items"]|length }} Einträge</span>
|
<span>{{ group["items"]|length }} Einträge</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox-grid">
|
<div class="checkbox-grid package-option-grid">
|
||||||
{% for item in group["items"] %}
|
{% for item in group["items"] %}
|
||||||
<label class="check-option" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
|
{% if item.kind == 'meal' %}
|
||||||
|
{% set item_icon_class = {
|
||||||
|
'breakfast': 'icon-daypart-breakfast',
|
||||||
|
'lunch': 'icon-daypart-lunch',
|
||||||
|
'dinner': 'icon-daypart-dinner',
|
||||||
|
'snack': 'icon-daypart-afternoon-snack',
|
||||||
|
}.get(item.meal_type, 'icon-utensils') %}
|
||||||
|
{% else %}
|
||||||
|
{% set item_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(item.primary_builder_key or item.base_type, 'icon-component-neutral') %}
|
||||||
|
{% endif %}
|
||||||
|
<label class="set-item-option" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
|
||||||
<input type="checkbox" name="item_ids" value="{{ item.id }}" {% if item.id in form_data.item_ids %}checked{% endif %}>
|
<input type="checkbox" name="item_ids" value="{{ item.id }}" {% if item.id in form_data.item_ids %}checked{% endif %}>
|
||||||
<span>{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}</span>
|
<span class="set-item-option-card">
|
||||||
|
<span class="set-item-option-visual">
|
||||||
|
{% if item.photo_filename %}
|
||||||
|
<img
|
||||||
|
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||||
|
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||||
|
sizes="{{ image_sizes('grid') }}"
|
||||||
|
alt="{{ item.name }}"
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<span class="set-item-option-fallback">
|
||||||
|
<span class="ui-icon {{ item_icon_class }}"></span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="set-item-option-label">{{ item.name }}</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +155,8 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<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('main.template_library') }}">Zurück</a>
|
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -71,7 +71,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<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('main.template_library') }}">Zurück</a>
|
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Tagesplan</p>
|
<p class="eyebrow">Tagesplan</p>
|
||||||
<h1>{{ weekday_name(selected_date) }}, {{ selected_date.strftime('%d.%m.%Y') }}</h1>
|
<h1>{{ weekday_name(selected_date) }}, {{ selected_date.strftime('%d.%m.%Y') }}</h1>
|
||||||
<p class="lead">Der Tagesplan bleibt bewusst ruhig. Jede Tageszeit ist eine eigene Kachel und öffnet sich erst, wenn du sie brauchst.</p>
|
<p class="lead">Jede Tageszeit hat ihren eigenen Platz und öffnet sich erst dann, wenn du etwas eintragen möchtest.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="week-nav">
|
<div class="week-nav">
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=previous_day.isoformat()) }}">Vorheriger Tag</a>
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=previous_day.isoformat()) }}">Vorheriger Tag</a>
|
||||||
@@ -14,66 +14,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="two-column">
|
<section class="planner-day-layout">
|
||||||
<article class="panel">
|
<div class="planner-day-main">
|
||||||
|
<section class="planner-day-stack">
|
||||||
|
{% set hidden_snack_sections = sections | selectattr('is_snack_daypart') | rejectattr('visible_by_default') | list %}
|
||||||
|
{% if hidden_snack_sections %}
|
||||||
|
<section class="panel compact-form-panel snack-reveal-panel" data-day-snack-actions>
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Tagesvorlagen</h2>
|
<h2>Zwischenmahlzeit hinzufügen</h2>
|
||||||
<a href="{{ url_for('main.day_template_create', source_date=selected_date.isoformat()) }}">Als Vorlage speichern</a>
|
|
||||||
</div>
|
</div>
|
||||||
{% if day_templates %}
|
<div class="chip-row snack-reveal-actions">
|
||||||
<div class="stack-sections">
|
{% for section in hidden_snack_sections %}
|
||||||
{% for template in day_templates %}
|
<button
|
||||||
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
|
class="ghost-button snack-reveal-button"
|
||||||
{{ csrf_input() }}
|
type="button"
|
||||||
<input type="hidden" name="target_date" value="{{ selected_date.isoformat() }}">
|
data-day-snack-open
|
||||||
<div class="template-card">
|
data-target="#daypart-{{ section.daypart.id }}"
|
||||||
<strong>{{ template.name }}</strong>
|
>
|
||||||
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
|
{{ section.daypart.name }}
|
||||||
</div>
|
</button>
|
||||||
<button type="submit">Vorlage anwenden</button>
|
|
||||||
</form>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</section>
|
||||||
<p class="empty-state">Wenn du einen Tag öfter wiederverwenden möchtest, kannst du ihn hier als Tagesvorlage speichern.</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
|
||||||
|
|
||||||
{% if day_hints %}
|
|
||||||
<article class="panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<h2>Heute im Blick</h2>
|
|
||||||
</div>
|
|
||||||
<div class="hint-list">
|
|
||||||
{% for hint in day_hints %}
|
|
||||||
<p class="hint-chip">{{ hint }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="planner-day-stack">
|
|
||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
<details class="day-tile" id="daypart-{{ section.daypart.id }}" {% if section.is_open %}open{% endif %}>
|
<details
|
||||||
|
class="day-tile{% if section.entries %} has-entries{% endif %}{% if section.selected_quick_action %} has-selection{% endif %}"
|
||||||
|
id="daypart-{{ section.daypart.id }}"
|
||||||
|
{% if section.is_snack_daypart and not section.visible_by_default %}hidden data-day-snack-tile{% endif %}
|
||||||
|
{% if section.is_open %}open{% endif %}
|
||||||
|
>
|
||||||
<summary class="day-tile-summary">
|
<summary class="day-tile-summary">
|
||||||
<div class="day-tile-summary-main">
|
<div class="day-tile-summary-main">
|
||||||
<div class="day-tile-icon"><span class="ui-icon icon-calendar"></span></div>
|
<div class="day-tile-icon"><span class="ui-icon {{ daypart_icon_class(section.daypart.slug) }}"></span></div>
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ section.daypart.name }}</h2>
|
<h2>{{ section.daypart.name }}</h2>
|
||||||
{% if section.summary_items %}
|
{% if section.summary_items %}
|
||||||
<p class="muted">{{ section.summary_items|join(', ') }}</p>
|
<p class="day-tile-summary-text">{{ section.summary_items|join(', ') }}</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">Noch frei. Öffnen, wenn du etwas ergänzen möchtest.</p>
|
<p class="muted">Noch offen. Öffnen, wenn du etwas eintragen möchtest.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="status-pill">{{ section.entries|length }} geplant</span>
|
<span class="status-pill{% if section.entries %} status-home{% endif %}">{{ section.entries|length }} geplant</span>
|
||||||
</summary>
|
</summary>
|
||||||
|
|
||||||
<div class="day-tile-body">
|
<div class="day-tile-body">
|
||||||
{% if section.selected_quick_action %}
|
{% if section.selected_quick_action %}
|
||||||
<div class="suggestion-card">
|
<div class="suggestion-card selected-quick-action">
|
||||||
|
<span class="status-pill status-home">Schon ausgewählt</span>
|
||||||
<strong>{{ section.selected_quick_action.title }}</strong>
|
<strong>{{ section.selected_quick_action.title }}</strong>
|
||||||
<p class="muted">{{ section.selected_quick_action.subtitle }}</p>
|
<p class="muted">{{ section.selected_quick_action.subtitle }}</p>
|
||||||
{% if section.selected_quick_action.type == 'existing' %}
|
{% if section.selected_quick_action.type == 'existing' %}
|
||||||
@@ -137,7 +127,7 @@
|
|||||||
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||||
<button class="quick-add-button compact-button" type="submit">
|
<button class="quick-add-button compact-button" type="submit">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
{% if item.availability_state == 'home' %}<small>zuhause vorhanden</small>{% endif %}
|
{% if item.is_home %}<small>zuhause vorhanden</small>{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -150,6 +140,19 @@
|
|||||||
<h3>Passt gut dazu</h3>
|
<h3>Passt gut dazu</h3>
|
||||||
<div class="quick-add-row compact-quick-row">
|
<div class="quick-add-row compact-quick-row">
|
||||||
{% for suggestion in section.recipe_suggestions %}
|
{% for suggestion in section.recipe_suggestions %}
|
||||||
|
{% if suggestion.existing_item_id %}
|
||||||
|
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
|
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||||
|
<input type="hidden" name="item_id" value="{{ suggestion.existing_item_id }}">
|
||||||
|
<input type="hidden" name="visibility" value="{{ suggestion.visibility or 'shared' }}">
|
||||||
|
<button class="quick-add-button compact-button" type="submit">
|
||||||
|
<span>{{ suggestion.title }}</span>
|
||||||
|
<small>{{ suggestion.reason }}</small>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
<form method="post" action="{{ url_for('main.planner_generated_meal') }}">
|
<form method="post" action="{{ url_for('main.planner_generated_meal') }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
@@ -164,6 +167,7 @@
|
|||||||
<small>{{ suggestion.reason }}</small>
|
<small>{{ suggestion.reason }}</small>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,7 +179,7 @@
|
|||||||
<input type="text" placeholder="Lebensmittel oder Mahlzeiten suchen" data-filter-input data-filter-target="#planner-list-{{ section.daypart.id }}" data-filter-limit="3">
|
<input type="text" placeholder="Lebensmittel oder Mahlzeiten suchen" data-filter-input data-filter-target="#planner-list-{{ section.daypart.id }}" data-filter-limit="3">
|
||||||
</label>
|
</label>
|
||||||
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
|
<div class="compact-picker-list" id="planner-list-{{ section.daypart.id }}">
|
||||||
{% for item in section.food_candidates %}
|
{% for item in section.search_candidates %}
|
||||||
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
|
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
@@ -184,7 +188,10 @@
|
|||||||
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||||
<button class="picker-row" type="submit">
|
<button class="picker-row" type="submit">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
{% if item.availability_state == 'home' %}<small>zuhause</small>{% endif %}
|
<small>
|
||||||
|
{{ item_kind_labels[item.kind] }}
|
||||||
|
{% if item.is_home %} · zuhause{% endif %}
|
||||||
|
</small>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -198,7 +205,7 @@
|
|||||||
<div class="planner-entry-top">
|
<div class="planner-entry-top">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ entry.item_name }}</strong>
|
<strong>{{ entry.item_name }}</strong>
|
||||||
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
|
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.is_home %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
<span class="chip">{{ entry.visibility_label }}</span>
|
<span class="chip">{{ entry.visibility_label }}</span>
|
||||||
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||||
@@ -217,14 +224,89 @@
|
|||||||
{% if entry.note %}
|
{% if entry.note %}
|
||||||
<p>{{ entry.note }}</p>
|
<p>{{ entry.note }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if entry.can_edit %}
|
||||||
|
<details class="planner-entry-edit">
|
||||||
|
<summary class="ghost-button">Anpassen</summary>
|
||||||
|
<form method="post" action="{{ url_for('main.planner_update', entry_id=entry.id) }}" class="planner-entry-inline-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
|
<label>
|
||||||
|
Für wen?
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if entry.visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
Notiz
|
||||||
|
<input type="text" name="note" value="{{ entry.note or '' }}" placeholder="Optional">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty-state">Hier ist noch nichts eingetragen. Ein kleiner Anfang reicht völlig.</p>
|
<p class="empty-state">Hier ist noch nichts eingetragen. Ein kleiner Anfang reicht völlig.</p>
|
||||||
|
{% if section.is_snack_daypart %}
|
||||||
|
<div class="row-actions snack-inline-actions">
|
||||||
|
<button
|
||||||
|
class="ghost-button"
|
||||||
|
type="button"
|
||||||
|
data-day-snack-hide
|
||||||
|
data-target="#daypart-{{ section.daypart.id }}"
|
||||||
|
>
|
||||||
|
Wieder ausblenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="planner-day-sidebar">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Tagesvorlagen</h2>
|
||||||
|
<a href="{{ url_for('main.day_template_create', source_date=selected_date.isoformat()) }}">Als Vorlage speichern</a>
|
||||||
|
</div>
|
||||||
|
{% if day_templates %}
|
||||||
|
<div class="stack-sections">
|
||||||
|
{% for template in day_templates %}
|
||||||
|
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="target_date" value="{{ selected_date.isoformat() }}">
|
||||||
|
<div class="template-card">
|
||||||
|
<strong>{{ template.name }}</strong>
|
||||||
|
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Vorlage anwenden</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">Wenn du einen Tag öfter wiederverwenden möchtest, kannst du ihn hier als Tagesvorlage speichern.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% if day_hints %}
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Heute im Blick</h2>
|
||||||
|
</div>
|
||||||
|
<div class="hint-list">
|
||||||
|
{% for hint in day_hints %}
|
||||||
|
<p class="hint-chip">{{ hint }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,12 +4,19 @@
|
|||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Wochenansicht</p>
|
<p class="eyebrow">Wochenansicht</p>
|
||||||
<h1>Ein ruhiger Blick auf die nächsten sieben Tage</h1>
|
<h1>Die nächsten sieben Tage auf einen Blick</h1>
|
||||||
<p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und gleichzeitig sehen, was erst später für den Einkauf relevant wird.</p>
|
<p class="lead">Du kannst Einträge zwischen Tagen und Tageszeiten verschieben, Vorlagen anwenden und sehen, was erst später für den Einkauf wichtig wird.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="week-nav">
|
<div class="week-nav">
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
|
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
|
||||||
<span>{{ week_start.strftime('%d.%m.%Y') }} bis {{ week_end.strftime('%d.%m.%Y') }}</span>
|
<span>{{ week_start.strftime('%d.%m.%Y') }} bis {{ week_end.strftime('%d.%m.%Y') }}</span>
|
||||||
|
<details class="export-menu">
|
||||||
|
<summary class="ghost-button export-menu-trigger">PDF exportieren</summary>
|
||||||
|
<div class="export-menu-panel">
|
||||||
|
<a href="{{ url_for('main.planner_export_pdf', week=week_start.isoformat(), mode='mine') }}">Meinen Essensplan</a>
|
||||||
|
<a href="{{ url_for('main.planner_export_pdf', week=week_start.isoformat(), mode='household') }}">Unseren Essensplan</a>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner', week=next_week.isoformat()) }}">Nächste Woche</a>
|
<a class="ghost-button" href="{{ url_for('main.planner', week=next_week.isoformat()) }}">Nächste Woche</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -80,36 +87,213 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if card.filled_dayparts %}
|
{% if not card.filled_dayparts %}
|
||||||
<p class="week-card-count">{{ card.planned_count }} Einträge</p>
|
<p class="empty-state week-card-empty-copy">Noch offen. Du kannst den Tag nach und nach füllen.</p>
|
||||||
<div class="chip-row">
|
{% endif %}
|
||||||
{% for slot in card.filled_dayparts %}
|
|
||||||
<span class="chip">{{ slot.name }} · {{ slot.count }}</span>
|
{% if card.hidden_snack_slots %}
|
||||||
|
<div class="week-card-snack-actions" data-week-snack-actions>
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Snacks ergänzen</p>
|
||||||
|
</div>
|
||||||
|
<div class="chip-row snack-reveal-actions">
|
||||||
|
{% for hidden_slot in card.hidden_snack_slots %}
|
||||||
|
<button
|
||||||
|
class="ghost-button snack-reveal-button"
|
||||||
|
type="button"
|
||||||
|
data-week-snack-slot-open
|
||||||
|
data-target="#week-slot-{{ card.date.isoformat() }}-{{ hidden_slot.id }}"
|
||||||
|
>
|
||||||
|
{% if hidden_slot.name == 'Vormittagssnack' %}
|
||||||
|
Vormittag
|
||||||
|
{% elif hidden_slot.name == 'Nachmittagssnack' %}
|
||||||
|
Nachmittag
|
||||||
|
{% elif hidden_slot.name == 'Später Snack' %}
|
||||||
|
Abend
|
||||||
|
{% else %}
|
||||||
|
{{ hidden_slot.name }}
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">{{ card.preview_items | join(', ') }}</p>
|
</div>
|
||||||
{% else %}
|
|
||||||
<p class="empty-state">Noch offen. Du kannst den Tag ganz leicht nach und nach füllen.</p>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="week-slot-stack">
|
<div class="week-slot-stack">
|
||||||
{% for slot in card.slots %}
|
{% for slot in card.slots %}
|
||||||
<div class="week-slot drop-slot" data-target-date="{{ card.date.isoformat() }}" data-target-daypart-id="{{ slot.daypart.id }}">
|
<div
|
||||||
|
class="week-slot drop-slot{% if slot.entries %} has-entries{% endif %}{% if slot.is_snack_daypart %} week-slot-snack{% endif %}"
|
||||||
|
id="week-slot-{{ card.date.isoformat() }}-{{ slot.daypart.id }}"
|
||||||
|
data-target-date="{{ card.date.isoformat() }}"
|
||||||
|
data-target-daypart-id="{{ slot.daypart.id }}"
|
||||||
|
{% if slot.is_snack_daypart and not slot.visible_by_default %}hidden data-week-snack-slot{% endif %}
|
||||||
|
>
|
||||||
<div class="week-slot-head">
|
<div class="week-slot-head">
|
||||||
|
<div class="week-slot-title">
|
||||||
|
<span class="ui-icon {{ daypart_icon_class(slot.daypart.slug) }}"></span>
|
||||||
<strong>{{ slot.daypart.name }}</strong>
|
<strong>{{ slot.daypart.name }}</strong>
|
||||||
<span>{{ slot.entries|length }}</span>
|
</div>
|
||||||
|
<div class="week-slot-head-meta">
|
||||||
|
<span class="week-slot-count{% if slot.entries %} status-home{% endif %}">{{ slot.entries|length }}</span>
|
||||||
|
<button class="week-slot-add" type="button" data-week-slot-picker-open aria-label="{{ slot.daypart.name }} an {{ weekday_name(card.date) }} direkt ergänzen">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="week-slot-picker" hidden>
|
||||||
|
<div class="week-slot-picker-head">
|
||||||
|
<strong>{{ slot.daypart.name }} ergänzen</strong>
|
||||||
|
<button class="ghost-button week-slot-picker-close" type="button" data-week-slot-picker-close>Schließen</button>
|
||||||
|
</div>
|
||||||
|
<label class="planner-search week-slot-picker-search">
|
||||||
|
<span>Suche</span>
|
||||||
|
<input type="text" placeholder="Mahlzeiten oder Ideen suchen" data-filter-input data-filter-target="#week-slot-picker-list-{{ card.date.isoformat() }}-{{ slot.daypart.id }}">
|
||||||
|
</label>
|
||||||
|
<div id="week-slot-picker-list-{{ card.date.isoformat() }}-{{ slot.daypart.id }}">
|
||||||
|
{% if slot.picker.meal_candidates %}
|
||||||
|
<div class="planner-subsection">
|
||||||
|
<h3>Mahlzeitenideen</h3>
|
||||||
|
<div class="quick-add-row compact-quick-row">
|
||||||
|
{% for item in slot.picker.meal_candidates %}
|
||||||
|
<form method="post" action="{{ url_for('main.planner_day', date=card.date.isoformat()) }}" class="js-week-slot-submit" data-filter-label="{{ item.name|lower }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="daypart_id" value="{{ slot.daypart.id }}">
|
||||||
|
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||||
|
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||||
|
<button class="quick-add-button compact-button" type="submit">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
{% if item.is_home %}<small>Zuhause vorhanden</small>{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if slot.picker.recipe_suggestions %}
|
||||||
|
<div class="planner-subsection">
|
||||||
|
<h3>Passt gut dazu</h3>
|
||||||
|
<div class="quick-add-row compact-quick-row">
|
||||||
|
{% for suggestion in slot.picker.recipe_suggestions %}
|
||||||
|
{% if suggestion.existing_item_id %}
|
||||||
|
<form method="post" action="{{ url_for('main.planner_day', date=card.date.isoformat()) }}" class="js-week-slot-submit" data-filter-label="{{ suggestion.title|lower }} {{ suggestion.reason|lower }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="daypart_id" value="{{ slot.daypart.id }}">
|
||||||
|
<input type="hidden" name="item_id" value="{{ suggestion.existing_item_id }}">
|
||||||
|
<input type="hidden" name="visibility" value="{{ suggestion.visibility or 'shared' }}">
|
||||||
|
<button class="quick-add-button compact-button" type="submit">
|
||||||
|
<span>{{ suggestion.title }}</span>
|
||||||
|
<small>{{ suggestion.reason }}</small>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form method="post" action="{{ url_for('main.planner_generated_meal') }}" class="js-week-slot-submit" data-filter-label="{{ suggestion.title|lower }} {{ suggestion.reason|lower }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="daypart_id" value="{{ slot.daypart.id }}">
|
||||||
|
<input type="hidden" name="meal_name" value="{{ suggestion.title }}">
|
||||||
|
<input type="hidden" name="visibility" value="{{ suggestion.visibility or 'shared' }}">
|
||||||
|
{% for component_id in suggestion.component_ids %}
|
||||||
|
<input type="hidden" name="component_ids" value="{{ component_id }}">
|
||||||
|
{% endfor %}
|
||||||
|
<button class="quick-add-button compact-button" type="submit">
|
||||||
|
<span>{{ suggestion.title }}</span>
|
||||||
|
<small>{{ suggestion.reason }}</small>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if not slot.picker.meal_candidates and not slot.picker.recipe_suggestions %}
|
||||||
|
<p class="empty-state">Hier ist gerade noch nichts vorbereitet. Im Tagesplan kannst du jederzeit etwas Neues anlegen.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if slot.entries %}
|
{% if slot.entries %}
|
||||||
<div class="week-entry-stack">
|
<div class="week-entry-stack">
|
||||||
{% for entry in slot.entries %}
|
{% for entry in slot.entries %}
|
||||||
<article class="plan-chip draggable-plan-entry" draggable="{{ 'true' if entry.can_edit else 'false' }}" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}">
|
<article
|
||||||
|
class="plan-chip draggable-plan-entry{% if entry.can_edit %} is-editable{% endif %}"
|
||||||
|
draggable="{{ 'true' if entry.can_edit else 'false' }}"
|
||||||
|
data-entry-id="{{ entry.id }}"
|
||||||
|
data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}"
|
||||||
|
{% if entry.can_edit %}
|
||||||
|
data-week-entry-open
|
||||||
|
data-week-entry-dialog-id="week-entry-dialog-{{ entry.id }}"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="{{ entry.item_name }} bearbeiten"
|
||||||
|
{% endif %}
|
||||||
|
>
|
||||||
<strong>{{ entry.item_name }}</strong>
|
<strong>{{ entry.item_name }}</strong>
|
||||||
<small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
|
<small>{{ entry.visibility_label }} · {{ entry.for_label }}</small>
|
||||||
</article>
|
</article>
|
||||||
|
{% if entry.can_edit %}
|
||||||
|
<dialog class="week-entry-dialog" id="week-entry-dialog-{{ entry.id }}">
|
||||||
|
<div class="week-entry-dialog-card">
|
||||||
|
<div class="week-entry-dialog-head">
|
||||||
|
<div>
|
||||||
|
<h3>{{ entry.item_name }}</h3>
|
||||||
|
<p>{{ slot.daypart.name }} · {{ weekday_name(card.date) }}, {{ card.date.strftime('%d.%m.%Y') }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" type="button" data-week-entry-close>Schließen</button>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('main.planner_update', entry_id=entry.id) }}" class="planner-entry-inline-form js-week-entry-submit">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="return_week" value="{{ week_start.isoformat() }}">
|
||||||
|
<label>
|
||||||
|
Für wen?
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if entry.visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
Notiz
|
||||||
|
<input type="text" name="note" value="{{ entry.note or '' }}" placeholder="Optional">
|
||||||
|
</label>
|
||||||
|
<div class="week-entry-dialog-actions">
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id) }}" class="week-entry-remove-form js-week-entry-submit">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="return_week" value="{{ week_start.isoformat() }}">
|
||||||
|
<button class="ghost-button" type="submit">Eintrag entfernen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="week-slot-actions">
|
||||||
|
{% if slot.copy_allowed %}
|
||||||
|
<form method="post" action="{{ url_for('main.planner_slot_copy_forward') }}" class="js-copy-forward-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="source_date" value="{{ card.date.isoformat() }}">
|
||||||
|
<input type="hidden" name="daypart_id" value="{{ slot.daypart.id }}">
|
||||||
|
<button class="ghost-button week-slot-copy" type="submit">Zum nächsten Tag kopieren</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="week-slot-empty">Hierher ziehen</p>
|
<div class="week-slot-empty">
|
||||||
|
<p>Hierher ziehen</p>
|
||||||
|
{% if slot.is_snack_daypart %}
|
||||||
|
<button
|
||||||
|
class="ghost-button week-slot-hide"
|
||||||
|
type="button"
|
||||||
|
data-week-snack-slot-hide
|
||||||
|
data-target="#week-slot-{{ card.date.isoformat() }}-{{ slot.daypart.id }}"
|
||||||
|
>
|
||||||
|
Wieder ausblenden
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
<input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}">
|
<input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}">
|
||||||
</label>
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
@@ -109,6 +110,15 @@
|
|||||||
Ausgewogen bleibt offen. Fitness denkt stärker in proteinbetont und eher leicht. Proteinbetont priorisiert Eiweiß, ohne extra leicht zu werden.
|
Ausgewogen bleibt offen. Fitness denkt stärker in proteinbetont und eher leicht. Proteinbetont priorisiert Eiweiß, ohne extra leicht zu werden.
|
||||||
</small>
|
</small>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Proteinquellen eher
|
||||||
|
<select name="protein_preference">
|
||||||
|
{% for value, label in protein_preference_options %}
|
||||||
|
<option value="{{ value }}" {% if user_settings.protein_preference == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="helper-text">Hilft dabei, Fleisch und Fisch bei Bedarf leiser mitzudenken, ohne sie ganz auszuschließen.</small>
|
||||||
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -133,6 +143,7 @@
|
|||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Alltag</legend>
|
<legend>Alltag</legend>
|
||||||
|
<label class="inline-check"><input type="checkbox" name="push_small_snack" value="1" {% if user_settings.push_small_snack %}checked{% endif %}><span>Am Nachmittag an etwas Kleines erinnern</span></label>
|
||||||
<label class="inline-check"><input type="checkbox" name="remind_small_snack" value="1" {% if user_settings.remind_small_snack %}checked{% endif %}><span>An kleine Zwischenmahlzeiten erinnern</span></label>
|
<label class="inline-check"><input type="checkbox" name="remind_small_snack" value="1" {% if user_settings.remind_small_snack %}checked{% endif %}><span>An kleine Zwischenmahlzeiten erinnern</span></label>
|
||||||
<label class="inline-check"><input type="checkbox" name="remind_nuts" value="1" {% if user_settings.remind_nuts %}checked{% endif %}><span>Heute schon an Nüsse gedacht?</span></label>
|
<label class="inline-check"><input type="checkbox" name="remind_nuts" value="1" {% if user_settings.remind_nuts %}checked{% endif %}><span>Heute schon an Nüsse gedacht?</span></label>
|
||||||
<label class="inline-check"><input type="checkbox" name="suggest_templates" value="1" {% if user_settings.suggest_templates %}checked{% endif %}><span>Häufig genutzte Tages- und Wochenvorlagen vorschlagen</span></label>
|
<label class="inline-check"><input type="checkbox" name="suggest_templates" value="1" {% if user_settings.suggest_templates %}checked{% endif %}><span>Häufig genutzte Tages- und Wochenvorlagen vorschlagen</span></label>
|
||||||
@@ -141,7 +152,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<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('auth.profile') }}">Zum Profil</a>
|
<a class="ghost-button" href="{{ url_for('auth.profile') }}">Zum Profil</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -10,18 +10,81 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel compact-form-panel">
|
<section class="panel compact-form-panel">
|
||||||
<form method="post" class="inline-form">
|
<form method="post" class="shopping-add-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<select name="item_id">
|
<label>
|
||||||
<option value="">Bestehenden Eintrag hinzufügen</option>
|
Lebensmittel oder Artikel suchen
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="item_search"
|
||||||
|
id="shopping-item-search"
|
||||||
|
placeholder="Nach Lebensmitteln oder Artikeln suchen"
|
||||||
|
autocomplete="off"
|
||||||
|
data-filter-input
|
||||||
|
data-filter-target="#shopping-food-options"
|
||||||
|
data-filter-limit="8"
|
||||||
|
data-filter-hide-empty
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Einkaufshinweis
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="shopping_note"
|
||||||
|
maxlength="80"
|
||||||
|
placeholder="z. B. TK, Dose, frisch"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<div class="shopping-add-grid shopping-add-results" id="shopping-food-options">
|
||||||
{% for item in addable_items %}
|
{% for item in addable_items %}
|
||||||
<option value="{{ item.id }}">
|
{% set item_icon_class = {
|
||||||
{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}
|
'protein': 'icon-component-protein',
|
||||||
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
'carb': 'icon-component-carb',
|
||||||
</option>
|
'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(item.primary_builder_key or item.base_type, 'icon-component-neutral') %}
|
||||||
|
{% if item.kind == 'shopping' %}
|
||||||
|
{% set item_icon_class = 'icon-cart-shopping' %}
|
||||||
|
{% endif %}
|
||||||
|
<button
|
||||||
|
class="shopping-add-card"
|
||||||
|
type="submit"
|
||||||
|
name="item_id"
|
||||||
|
value="{{ item.id }}"
|
||||||
|
data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }} {{ item.base_type_label|lower }} {{ item.availability_label|lower }} {% if item.kind == 'shopping' %}einkaufsartikel artikel drogerie haushalt{% endif %}"
|
||||||
|
>
|
||||||
|
<span class="shopping-add-card-visual">
|
||||||
|
{% if item.photo_filename %}
|
||||||
|
<img
|
||||||
|
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||||
|
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||||
|
sizes="{{ image_sizes('grid') }}"
|
||||||
|
alt=""
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<span class="shopping-add-card-fallback">
|
||||||
|
<span class="ui-icon {{ item_icon_class }}"></span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="shopping-add-card-copy">
|
||||||
|
<strong>{{ item.name }}</strong>
|
||||||
|
<small>{% if item.kind == 'shopping' %}Einkaufsartikel{% else %}{{ item.availability_label }}{% endif %}</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</div>
|
||||||
<button type="submit">Auf Liste setzen</button>
|
<div class="shopping-create-actions" data-create-from="#shopping-item-search" hidden>
|
||||||
|
<input type="hidden" name="create_item_name" data-create-name>
|
||||||
|
<p class="muted">Kein passender Treffer? Direkt aus dem Suchbegriff anlegen:</p>
|
||||||
|
<button class="ghost-button" type="submit" name="create_as" value="food">Als Lebensmittel anlegen</button>
|
||||||
|
<button class="ghost-button" type="submit" name="create_as" value="shopping">Als Einkaufsartikel anlegen</button>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Auf die Liste</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -34,35 +97,146 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="stack-list">
|
<section class="stack-list">
|
||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
<article class="list-row stacked-mobile roomy-row">
|
{% set entry_icon_class = {
|
||||||
<div>
|
'protein': 'icon-component-protein',
|
||||||
<strong>{{ entry.item_name }}</strong>
|
'carb': 'icon-component-carb',
|
||||||
<p class="muted">{{ item_kind_labels[entry.item_kind] }}</p>
|
'veg': 'icon-component-veg',
|
||||||
<div class="chip-row">
|
'fruit': 'icon-component-fruit',
|
||||||
<span class="chip">{{ entry.visibility_label }}</span>
|
'dairy': 'icon-component-dairy',
|
||||||
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
'nuts': 'icon-component-nuts',
|
||||||
<span class="chip">{{ entry.for_label }}</span>
|
'seeds': 'icon-component-seeds',
|
||||||
{% if entry.needed_for_label %}
|
'neutral': 'icon-component-neutral',
|
||||||
<span class="chip status-home">
|
}.get(entry.primary_builder_key or entry.base_type, 'icon-component-neutral') %}
|
||||||
Für {{ entry.needed_for_label }}
|
{% if entry.item_kind == 'shopping' %}
|
||||||
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
|
{% set entry_icon_class = 'icon-cart-shopping' %}
|
||||||
|
{% endif %}
|
||||||
|
<article class="shopping-entry-card">
|
||||||
|
<div class="shopping-entry-row">
|
||||||
|
<div
|
||||||
|
class="shopping-entry-open"
|
||||||
|
data-dialog-open="shopping-entry-dialog-{{ entry.id }}"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="{{ entry.item_name }} öffnen"
|
||||||
|
>
|
||||||
|
<div class="shopping-entry-main">
|
||||||
|
<div class="shopping-entry-visual">
|
||||||
|
{% if entry.photo_filename %}
|
||||||
|
<img
|
||||||
|
src="{{ image_url(entry.photo_filename, 'md') }}"
|
||||||
|
srcset="{{ image_srcset(entry.photo_filename) }}"
|
||||||
|
sizes="{{ image_sizes('grid') }}"
|
||||||
|
alt=""
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<span class="shopping-entry-fallback">
|
||||||
|
<span class="ui-icon {{ entry_icon_class }}"></span>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="shopping-entry-copy">
|
||||||
|
<strong>{{ entry.item_name }}</strong>
|
||||||
|
{% if entry.shopping_note %}
|
||||||
|
<p class="shopping-entry-note">{{ entry.shopping_note }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if entry.needed_for_label %}
|
||||||
|
<p class="muted">
|
||||||
|
Für {{ entry.needed_for_label }}
|
||||||
|
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="row-actions">
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="shopping-entry-actions">
|
||||||
|
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button type="submit" class="shopping-entry-check-button">
|
||||||
|
<span class="shopping-entry-check-mark" aria-hidden="true">✔</span>
|
||||||
|
<span class="shopping-entry-check-label">Eingekauft</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% if entry.can_edit %}
|
||||||
|
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}" class="shopping-entry-close-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="shopping-entry-close" type="submit" aria-label="{{ entry.item_name }} entfernen">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<dialog class="shopping-entry-dialog week-entry-dialog" id="shopping-entry-dialog-{{ entry.id }}">
|
||||||
|
<div class="shopping-entry-dialog-card week-entry-dialog-card">
|
||||||
|
<div class="week-entry-dialog-head">
|
||||||
|
<div>
|
||||||
|
<h3>{{ entry.item_name }}</h3>
|
||||||
|
<p>
|
||||||
|
{% if entry.shopping_note %}
|
||||||
|
{{ entry.shopping_note }}
|
||||||
|
{% elif entry.needed_for_label %}
|
||||||
|
Für {{ entry.needed_for_label }}
|
||||||
|
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
|
||||||
|
{% elif entry.item_kind == 'shopping' %}
|
||||||
|
Einkaufsartikel
|
||||||
|
{% elif entry.is_home %}
|
||||||
|
Zuhause vorhanden
|
||||||
|
{% else %}
|
||||||
|
Gerade nicht da
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button class="ghost-button" type="button" data-dialog-close>Schließen</button>
|
||||||
|
</div>
|
||||||
|
<div class="shopping-entry-dialog-actions">
|
||||||
|
{% if entry.can_edit and entry.item_kind != 'shopping' %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=entry.item_id) }}">Bearbeiten</a>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button type="submit">Eingekauft</button>
|
<button type="submit">Eingekauft</button>
|
||||||
</form>
|
</form>
|
||||||
{% if entry.can_edit %}
|
{% if entry.can_edit %}
|
||||||
|
<form method="post" action="{{ url_for('main.shopping_update_note', entry_id=entry.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<label>
|
||||||
|
Einkaufshinweis
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="shopping_note"
|
||||||
|
maxlength="80"
|
||||||
|
value="{{ entry.shopping_note }}"
|
||||||
|
placeholder="z. B. TK, Dose, frisch"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<button class="ghost-button" type="submit">Hinweis speichern</button>
|
||||||
|
</form>
|
||||||
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}">
|
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Entfernen</button>
|
<button class="ghost-button" type="submit">Von Einkaufsliste nehmen</button>
|
||||||
|
</form>
|
||||||
|
{% if entry.item_kind != 'shopping' and entry.is_home %}
|
||||||
|
<form method="post" action="{{ url_for('main.item_set_not_home', item_id=entry.item_id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="ghost-button" type="submit">Nicht mehr da</button>
|
||||||
|
</form>
|
||||||
|
{% elif entry.item_kind != 'shopping' %}
|
||||||
|
<form method="post" action="{{ url_for('main.item_set_home', item_id=entry.item_id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="ghost-button" type="submit">Als zuhause markieren</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if entry.item_kind != 'shopping' %}
|
||||||
|
<form method="post" action="{{ url_for('main.item_archive', item_id=entry.item_id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="ghost-button" type="submit">Archivieren</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
|
</dialog>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ Flask==3.1.1
|
|||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
pywebpush==2.3.0
|
pywebpush==2.3.0
|
||||||
Pillow==11.2.1; python_version < "3.14"
|
Pillow==11.2.1; python_version < "3.14"
|
||||||
|
fpdf2==2.8.3
|
||||||
|
|||||||