Compare commits
7 Commits
216dde1414
...
V1.3.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fc2492918 | |||
| 1034ea72a8 | |||
| d3c58c5dd2 | |||
| 43fdd7081c | |||
| 5e9beb1d98 | |||
| 06be1371d3 | |||
| 85c72879cb |
@@ -4,8 +4,8 @@
|
||||
"author": "Florian Heinz",
|
||||
"description": "Private Flask app for meals, shopping and gentle food planning",
|
||||
"tagline": "einfach essen planen",
|
||||
"version": "1.2.2",
|
||||
"upstreamVersion": "1.2.2",
|
||||
"version": "1.3.2",
|
||||
"upstreamVersion": "1.3.2",
|
||||
"healthCheckPath": "/",
|
||||
"httpPort": 8000,
|
||||
"manifestVersion": 2,
|
||||
|
||||
@@ -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.
|
||||
+1
-1
@@ -93,7 +93,7 @@ def load_app_version(root_dir: Path) -> str:
|
||||
).strip()
|
||||
if manifest_version:
|
||||
return manifest_version
|
||||
return "1.2.2"
|
||||
return "1.3.2"
|
||||
|
||||
|
||||
def load_release_url() -> str:
|
||||
|
||||
+16
-1
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
from .auth import admin_required, can_remove_last_admin, validate_admin_user_form
|
||||
from .auth import admin_required, can_remove_last_admin, url_with_scroll_position, validate_admin_user_form, wants_to_stay_on_form
|
||||
from .constants import BUILDER_DESCRIPTIONS, BUILDER_OPTIONS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS, ROLE_LABELS
|
||||
from .db import get_db
|
||||
|
||||
@@ -104,6 +104,19 @@ def user_create():
|
||||
)
|
||||
database.commit()
|
||||
flash("Der Nutzer wurde angelegt.", "success")
|
||||
if wants_to_stay_on_form():
|
||||
new_user = database.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE household_id = ? AND username = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(g.user["household_id"], form_data["username"]),
|
||||
).fetchone()
|
||||
if new_user is not None:
|
||||
return redirect(url_with_scroll_position(url_for("admin.user_edit", user_id=int(new_user["id"]))))
|
||||
return redirect(url_for("admin.user_list"))
|
||||
|
||||
flash(error, "error")
|
||||
@@ -185,6 +198,8 @@ def user_edit(user_id: int):
|
||||
)
|
||||
database.commit()
|
||||
flash("Der Nutzer wurde aktualisiert.", "success")
|
||||
if wants_to_stay_on_form():
|
||||
return redirect(url_with_scroll_position(url_for("admin.user_edit", user_id=user_id)))
|
||||
return redirect(url_for("admin.user_list"))
|
||||
|
||||
flash(error, "error")
|
||||
|
||||
+27
-5
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import secrets
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
@@ -25,23 +26,23 @@ auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
|
||||
def login_required(view):
|
||||
@functools.wraps(view)
|
||||
def wrapped_view(**kwargs):
|
||||
def wrapped_view(*args, **kwargs):
|
||||
if g.user is None:
|
||||
return redirect(url_for("auth.login"))
|
||||
return view(**kwargs)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
|
||||
def admin_required(view):
|
||||
@functools.wraps(view)
|
||||
def wrapped_view(**kwargs):
|
||||
def wrapped_view(*args, **kwargs):
|
||||
if g.user is None:
|
||||
return redirect(url_for("auth.login"))
|
||||
if g.user["role"] != "admin":
|
||||
flash("Dieser Bereich ist für Admins gedacht.", "error")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
return view(**kwargs)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped_view
|
||||
|
||||
@@ -53,6 +54,25 @@ def ensure_csrf_token() -> str:
|
||||
return token
|
||||
|
||||
|
||||
def wants_to_stay_on_form() -> bool:
|
||||
return request.form.get("save_mode", "").strip() == "stay"
|
||||
|
||||
|
||||
def url_with_scroll_position(url: str) -> str:
|
||||
raw_scroll = request.form.get("_scroll", "").strip()
|
||||
if not raw_scroll:
|
||||
return url
|
||||
try:
|
||||
scroll_value = max(0, int(float(raw_scroll)))
|
||||
except ValueError:
|
||||
return url
|
||||
|
||||
parts = urlsplit(url)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["_scroll"] = str(scroll_value)
|
||||
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||
|
||||
|
||||
def normalize_login_value(raw: str) -> str:
|
||||
return raw.strip().lower()
|
||||
|
||||
@@ -231,7 +251,9 @@ def profile():
|
||||
)
|
||||
database.commit()
|
||||
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")
|
||||
|
||||
|
||||
+15
-3
@@ -52,13 +52,25 @@ def export_backup_archive(
|
||||
payload["tables"][table_name] = [dict(row) for row in rows]
|
||||
|
||||
uploads_root = Path(upload_folder)
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("backup.json", json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
uploads_snapshot_dir = Path(tempfile.mkdtemp(prefix="nouri-backup-uploads-"))
|
||||
try:
|
||||
if uploads_root.exists():
|
||||
for file_path in uploads_root.rglob("*"):
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
relative_path = file_path.relative_to(uploads_root)
|
||||
snapshot_path = uploads_snapshot_dir / relative_path
|
||||
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(file_path, snapshot_path)
|
||||
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("backup.json", json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
for file_path in uploads_snapshot_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
relative_path = file_path.relative_to(uploads_root)
|
||||
relative_path = file_path.relative_to(uploads_snapshot_dir)
|
||||
archive.write(file_path, f"uploads/{relative_path.as_posix()}")
|
||||
finally:
|
||||
shutil.rmtree(uploads_snapshot_dir, ignore_errors=True)
|
||||
|
||||
return archive_path, backup_name
|
||||
|
||||
|
||||
+5
-2
@@ -17,6 +17,7 @@ DAYPART_SLUG_TO_MEAL_TYPE = {
|
||||
}
|
||||
|
||||
DEFAULT_CATEGORIES = [
|
||||
"Unsortiert",
|
||||
"Kohlenhydrate",
|
||||
"Milchprodukt",
|
||||
"Obst",
|
||||
@@ -30,6 +31,7 @@ DEFAULT_CATEGORIES = [
|
||||
]
|
||||
|
||||
DEFAULT_CATEGORY_BUILDERS = {
|
||||
"Unsortiert": "neutral",
|
||||
"Kohlenhydrate": "carb",
|
||||
"Brot & Getreide": "carb",
|
||||
"Milchprodukt": "dairy",
|
||||
@@ -226,9 +228,10 @@ ITEM_KIND_SINGULAR_LABELS = {
|
||||
}
|
||||
|
||||
AVAILABILITY_LABELS = {
|
||||
"idea": "Merkliste",
|
||||
"idea": "Gerade nicht da",
|
||||
"home": "Zuhause",
|
||||
"archived": "Archiv",
|
||||
"unsorted": "Unsortiert",
|
||||
"archived": "Archiviert",
|
||||
}
|
||||
|
||||
ROLE_LABELS = {
|
||||
|
||||
+37
-1
@@ -15,7 +15,7 @@ from .constants import (
|
||||
DEFAULT_CATEGORY_BUILDERS,
|
||||
)
|
||||
|
||||
CURRENT_SCHEMA_VERSION = "1.2.2"
|
||||
CURRENT_SCHEMA_VERSION = "1.3.2"
|
||||
|
||||
ANIMAL_HINTS = (
|
||||
"huhn",
|
||||
@@ -344,6 +344,19 @@ def migrate_food_flavor_profiles(database: sqlite3.Connection) -> None:
|
||||
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:
|
||||
if "db" not in g:
|
||||
g.db = sqlite3.connect(
|
||||
@@ -468,11 +481,13 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||
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", "is_archived INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
if table_exists(database, "shopping_entries"):
|
||||
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
|
||||
add_column_if_missing(database, "shopping_entries", "owner_user_id INTEGER")
|
||||
add_column_if_missing(database, "shopping_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||
add_column_if_missing(database, "shopping_entries", "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_daypart_id INTEGER")
|
||||
|
||||
@@ -722,8 +737,11 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||
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", "is_archived INTEGER NOT NULL DEFAULT 0")
|
||||
add_column_if_missing(database, "items", "is_quick_added INTEGER NOT NULL DEFAULT 0")
|
||||
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", "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", "energy_preference TEXT NOT NULL DEFAULT 'neutral'")
|
||||
add_column_if_missing(database, "user_settings", "protein_preference TEXT NOT NULL DEFAULT 'mixed'")
|
||||
@@ -771,6 +789,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||
sync_default_categories(database)
|
||||
migrate_item_profiles(database)
|
||||
migrate_food_flavor_profiles(database)
|
||||
migrate_item_archive_state(database)
|
||||
database.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO user_settings (user_id)
|
||||
@@ -784,6 +803,9 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||
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 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 = ''")
|
||||
@@ -805,6 +827,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||
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(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_items_target_user
|
||||
@@ -823,6 +851,14 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||
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(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_shopping_needs_household_activation
|
||||
|
||||
+489
-114
@@ -9,7 +9,6 @@ import sqlite3
|
||||
|
||||
from flask import (
|
||||
Blueprint,
|
||||
after_this_request,
|
||||
current_app,
|
||||
flash,
|
||||
g,
|
||||
@@ -21,7 +20,7 @@ from flask import (
|
||||
url_for,
|
||||
)
|
||||
|
||||
from .auth import admin_required, login_required
|
||||
from .auth import admin_required, login_required, url_with_scroll_position, wants_to_stay_on_form
|
||||
from .backup import RESTORE_CONFIRMATION_TEXT, export_backup_archive, restore_backup_archive
|
||||
from .constants import (
|
||||
AVAILABILITY_LABELS,
|
||||
@@ -71,7 +70,8 @@ main_bp = Blueprint("main", __name__)
|
||||
ACTIVE_STATE_OPTIONS = [
|
||||
("", "Alle aktiven"),
|
||||
("home", "Zuhause"),
|
||||
("idea", "Merkliste"),
|
||||
("idea", "Gerade nicht da"),
|
||||
("unsorted", "Unsortiert"),
|
||||
]
|
||||
KIND_FILTER_OPTIONS = [
|
||||
("", "Alles"),
|
||||
@@ -465,6 +465,16 @@ def describe_record(entry: dict) -> dict:
|
||||
entry["for_label"] = "Für mich" if entry["is_mine"] else f"Für {owner_name}"
|
||||
else:
|
||||
entry["for_label"] = "Für alle"
|
||||
entry["is_archived"] = bool(entry.get("is_archived"))
|
||||
entry["is_quick_added"] = bool(entry.get("is_quick_added"))
|
||||
entry["is_home"] = bool(entry.get("availability_state") == "home" and not entry["is_archived"])
|
||||
if entry["is_archived"]:
|
||||
entry["availability_key"] = "archived"
|
||||
elif entry["is_quick_added"]:
|
||||
entry["availability_key"] = "unsorted"
|
||||
else:
|
||||
entry["availability_key"] = "home" if entry["is_home"] else "idea"
|
||||
entry["availability_label"] = AVAILABILITY_LABELS.get(entry["availability_key"], AVAILABILITY_LABELS["idea"])
|
||||
entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin"
|
||||
return entry
|
||||
|
||||
@@ -798,6 +808,7 @@ def fetch_items(
|
||||
kind: str | None = None,
|
||||
availability: str | None = None,
|
||||
include_archived: bool = False,
|
||||
include_quick_added: bool = False,
|
||||
query: str | None = None,
|
||||
daypart_id: int | None = None,
|
||||
visibility: str | None = None,
|
||||
@@ -809,10 +820,19 @@ def fetch_items(
|
||||
conditions.append("items.kind = ?")
|
||||
params.append(kind)
|
||||
if availability:
|
||||
conditions.append("items.availability_state = ?")
|
||||
params.append(availability)
|
||||
if availability == "archived":
|
||||
conditions.append("items.is_archived = 1")
|
||||
elif availability == "unsorted":
|
||||
conditions.append("items.is_archived = 0")
|
||||
conditions.append("COALESCE(items.is_quick_added, 0) = 1")
|
||||
else:
|
||||
conditions.append("items.is_archived = 0")
|
||||
conditions.append("items.availability_state = ?")
|
||||
params.append(availability)
|
||||
elif not include_archived:
|
||||
conditions.append("items.availability_state != 'archived'")
|
||||
conditions.append("items.is_archived = 0")
|
||||
if not include_quick_added and availability != "unsorted":
|
||||
conditions.append("COALESCE(items.is_quick_added, 0) = 0")
|
||||
if query:
|
||||
conditions.append("LOWER(items.name) LIKE ?")
|
||||
params.append(f"%{query.lower()}%")
|
||||
@@ -849,7 +869,11 @@ def fetch_items(
|
||||
LEFT JOIN users AS target ON target.id = items.target_user_id
|
||||
WHERE {' AND '.join(conditions)}
|
||||
ORDER BY
|
||||
CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END,
|
||||
CASE
|
||||
WHEN items.is_archived = 1 THEN 2
|
||||
WHEN items.availability_state = 'home' THEN 0
|
||||
ELSE 1
|
||||
END,
|
||||
CASE items.visibility WHEN 'shared' THEN 0 ELSE 1 END,
|
||||
LOWER(items.name)
|
||||
""",
|
||||
@@ -859,13 +883,14 @@ def fetch_items(
|
||||
|
||||
|
||||
def fetch_food_options(query: str | None = None):
|
||||
return fetch_items(kind="food", include_archived=True, query=query)
|
||||
return fetch_items(kind="food", include_archived=False, query=query)
|
||||
|
||||
|
||||
def group_items_by_availability(items: list[dict]) -> list[dict]:
|
||||
grouped = defaultdict(list)
|
||||
for item in items:
|
||||
grouped[item["availability_state"]].append(item)
|
||||
key = "archived" if item.get("is_archived") else item.get("availability_state", "idea")
|
||||
grouped[key].append(item)
|
||||
|
||||
result = []
|
||||
for state in ("home", "idea", "archived"):
|
||||
@@ -976,6 +1001,10 @@ def should_activate_shopping_need(needed_date: date, today: date | None = None)
|
||||
return (today or date.today()) >= shopping_activation_date_for(needed_date)
|
||||
|
||||
|
||||
def normalize_shopping_note(value: str | None) -> str:
|
||||
return " ".join((value or "").strip().split())[:80]
|
||||
|
||||
|
||||
def schedule_shopping_need(
|
||||
*,
|
||||
item_id: int,
|
||||
@@ -1057,14 +1086,16 @@ def add_to_shopping_list(
|
||||
visibility_override: str | None = None,
|
||||
needed_for_date: str | None = None,
|
||||
needed_for_daypart_id: int | None = None,
|
||||
shopping_note: str | None = None,
|
||||
) -> bool:
|
||||
item = get_item(item_id)
|
||||
normalized_note = normalize_shopping_note(shopping_note)
|
||||
existing = get_db().execute(
|
||||
"""
|
||||
SELECT id FROM shopping_entries
|
||||
WHERE item_id = ? AND is_checked = 0
|
||||
WHERE item_id = ? AND shopping_note = ? AND is_checked = 0
|
||||
""",
|
||||
(item_id,),
|
||||
(item_id, normalized_note),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return False
|
||||
@@ -1074,15 +1105,16 @@ def add_to_shopping_list(
|
||||
get_db().execute(
|
||||
"""
|
||||
INSERT INTO shopping_entries (
|
||||
household_id, owner_user_id, visibility, item_id, added_by, needed_for_date, needed_for_daypart_id
|
||||
household_id, owner_user_id, visibility, item_id, shopping_note, added_by, needed_for_date, needed_for_daypart_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
current_household_id(),
|
||||
owner_user_id,
|
||||
visibility,
|
||||
item_id,
|
||||
normalized_note,
|
||||
user_id,
|
||||
needed_for_date,
|
||||
needed_for_daypart_id,
|
||||
@@ -1108,6 +1140,7 @@ def fetch_meal_missing_components(meal_id: int) -> list[dict]:
|
||||
WHERE meal_components.meal_item_id = ?
|
||||
AND items.household_id = ?
|
||||
AND (items.visibility = 'shared' OR items.owner_user_id = ?)
|
||||
AND items.is_archived = 0
|
||||
AND items.availability_state != 'home'
|
||||
ORDER BY LOWER(items.name)
|
||||
""",
|
||||
@@ -1124,6 +1157,7 @@ def ensure_item_or_missing_components_are_shopped(
|
||||
needed_for_date: str | None = None,
|
||||
needed_for_daypart_id: int | None = None,
|
||||
source_item_id: int | None = None,
|
||||
shopping_note: str | None = None,
|
||||
) -> dict:
|
||||
item = get_item(item_id)
|
||||
if item["kind"] == "meal":
|
||||
@@ -1169,16 +1203,6 @@ def ensure_item_or_missing_components_are_shopped(
|
||||
"used_components": True,
|
||||
}
|
||||
|
||||
if item["availability_state"] == "home":
|
||||
return {
|
||||
"added": False,
|
||||
"count": 0,
|
||||
"names": [],
|
||||
"scheduled_count": 0,
|
||||
"scheduled_names": [],
|
||||
"used_components": False,
|
||||
}
|
||||
|
||||
if needed_for_date and not should_activate_shopping_need(parse_plan_date(needed_for_date)):
|
||||
schedule_shopping_need(
|
||||
item_id=item_id,
|
||||
@@ -1203,6 +1227,7 @@ def ensure_item_or_missing_components_are_shopped(
|
||||
visibility_override=visibility,
|
||||
needed_for_date=needed_for_date,
|
||||
needed_for_daypart_id=needed_for_daypart_id,
|
||||
shopping_note=shopping_note,
|
||||
)
|
||||
return {
|
||||
"added": added,
|
||||
@@ -1273,14 +1298,49 @@ def fetch_items_by_ids(item_ids: list[int]) -> list[dict]:
|
||||
return [items_by_id[item_id] for item_id in normalized_ids if item_id in items_by_id]
|
||||
|
||||
|
||||
def find_shopping_food_by_name(name: str) -> dict | None:
|
||||
normalized_name = name.strip().lower()
|
||||
if not normalized_name:
|
||||
return None
|
||||
row = get_db().execute(
|
||||
f"""
|
||||
SELECT items.*,
|
||||
owner.display_name AS owner_display_name,
|
||||
owner.username AS owner_username,
|
||||
target.display_name AS target_display_name,
|
||||
target.username AS target_username,
|
||||
EXISTS(
|
||||
SELECT 1
|
||||
FROM shopping_entries
|
||||
WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0
|
||||
) AS is_on_shopping_list
|
||||
FROM items
|
||||
LEFT JOIN users AS owner ON owner.id = items.owner_user_id
|
||||
LEFT JOIN users AS target ON target.id = items.target_user_id
|
||||
WHERE items.kind = 'food'
|
||||
AND items.is_archived = 0
|
||||
AND LOWER(items.name) = ?
|
||||
AND {visible_clause('items')}
|
||||
ORDER BY LOWER(items.name), items.id
|
||||
LIMIT 1
|
||||
""",
|
||||
[normalized_name, *visible_params()],
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return attach_builder_keys(attach_dayparts(describe_records([row])))[0]
|
||||
|
||||
|
||||
def fetch_shopping_entries():
|
||||
rows = get_db().execute(
|
||||
f"""
|
||||
SELECT shopping_entries.*,
|
||||
items.name AS item_name,
|
||||
items.kind AS item_kind,
|
||||
items.base_type,
|
||||
items.photo_filename,
|
||||
items.availability_state,
|
||||
items.is_archived,
|
||||
owner.display_name AS owner_display_name,
|
||||
owner.username AS owner_username,
|
||||
target.display_name AS target_display_name,
|
||||
@@ -1322,6 +1382,7 @@ def activate_due_shopping_needs(today: date | None = None) -> int:
|
||||
WHERE shopping_needs.household_id = ?
|
||||
AND shopping_needs.is_activated = 0
|
||||
AND shopping_needs.activation_date <= ?
|
||||
AND items.is_archived = 0
|
||||
ORDER BY shopping_needs.activation_date, shopping_needs.needed_for_date
|
||||
""",
|
||||
(current_household_id(), today.isoformat()),
|
||||
@@ -1353,6 +1414,7 @@ def fetch_upcoming_shopping_needs(limit: int | None = None) -> list[dict]:
|
||||
items.kind AS item_kind,
|
||||
items.photo_filename,
|
||||
items.availability_state,
|
||||
items.is_archived,
|
||||
owner.display_name AS owner_display_name,
|
||||
owner.username AS owner_username,
|
||||
target.display_name AS target_display_name,
|
||||
@@ -1366,6 +1428,7 @@ def fetch_upcoming_shopping_needs(limit: int | None = None) -> list[dict]:
|
||||
WHERE shopping_needs.household_id = ?
|
||||
AND shopping_needs.is_activated = 0
|
||||
AND (shopping_needs.visibility = 'shared' OR shopping_needs.owner_user_id = ?)
|
||||
AND items.is_archived = 0
|
||||
AND items.availability_state != 'home'
|
||||
ORDER BY shopping_needs.activation_date, shopping_needs.needed_for_date, LOWER(items.name)
|
||||
"""
|
||||
@@ -1393,6 +1456,7 @@ def fetch_plan_entries_for_range(start_date: date, end_date: date):
|
||||
items.kind AS item_kind,
|
||||
items.photo_filename,
|
||||
items.availability_state,
|
||||
items.is_archived,
|
||||
dayparts.name AS daypart_name,
|
||||
dayparts.slug AS daypart_slug,
|
||||
dayparts.sort_order,
|
||||
@@ -1434,6 +1498,7 @@ def fetch_recent_plan_items(daypart_id: int, limit: int = 6):
|
||||
items.note,
|
||||
items.photo_filename,
|
||||
items.availability_state,
|
||||
items.is_archived,
|
||||
owner.display_name AS owner_display_name,
|
||||
owner.username AS owner_username,
|
||||
target.display_name AS target_display_name,
|
||||
@@ -1453,7 +1518,7 @@ def fetch_recent_plan_items(daypart_id: int, limit: int = 6):
|
||||
|
||||
def fetch_plan_candidates(daypart_id: int, query: str | None = None):
|
||||
params = [daypart_id, *visible_params()]
|
||||
conditions = [visible_clause("items"), "items.availability_state != 'archived'"]
|
||||
conditions = [visible_clause("items"), "items.is_archived = 0"]
|
||||
if query:
|
||||
conditions.append("LOWER(items.name) LIKE ?")
|
||||
params.append(f"%{query.lower()}%")
|
||||
@@ -1495,7 +1560,7 @@ def fetch_home_food_ids() -> set[int]:
|
||||
f"""
|
||||
SELECT id
|
||||
FROM items
|
||||
WHERE kind = 'food' AND availability_state = 'home' AND {visible_clause('items')}
|
||||
WHERE kind = 'food' AND availability_state = 'home' AND is_archived = 0 AND {visible_clause('items')}
|
||||
""",
|
||||
visible_params(),
|
||||
).fetchall()
|
||||
@@ -1962,6 +2027,7 @@ def build_dashboard_hints(today: date) -> list[str]:
|
||||
JOIN item_dayparts ON item_dayparts.item_id = items.id
|
||||
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
|
||||
WHERE items.availability_state = 'home'
|
||||
AND items.is_archived = 0
|
||||
AND dayparts.slug = 'dinner'
|
||||
AND {visible_clause('items')}
|
||||
""",
|
||||
@@ -2001,13 +2067,13 @@ def build_dashboard_hints(today: date) -> list[str]:
|
||||
def build_setup_checklist(today: date) -> list[dict]:
|
||||
total_items = int(
|
||||
get_db().execute(
|
||||
f"SELECT COUNT(*) AS count FROM items WHERE {visible_clause('items')}",
|
||||
f"SELECT COUNT(*) AS count FROM items WHERE items.is_archived = 0 AND {visible_clause('items')}",
|
||||
visible_params(),
|
||||
).fetchone()["count"]
|
||||
)
|
||||
meal_count = int(
|
||||
get_db().execute(
|
||||
f"SELECT COUNT(*) AS count FROM items WHERE kind = 'meal' AND {visible_clause('items')}",
|
||||
f"SELECT COUNT(*) AS count FROM items WHERE kind = 'meal' AND items.is_archived = 0 AND {visible_clause('items')}",
|
||||
visible_params(),
|
||||
).fetchone()["count"]
|
||||
)
|
||||
@@ -2095,7 +2161,7 @@ def build_day_hints(selected_date: date) -> list[str]:
|
||||
FROM items
|
||||
JOIN item_dayparts ON item_dayparts.item_id = items.id
|
||||
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
|
||||
WHERE items.availability_state != 'archived'
|
||||
WHERE items.is_archived = 0
|
||||
AND dayparts.slug = 'afternoon-snack'
|
||||
AND {visible_clause('items')}
|
||||
""",
|
||||
@@ -2233,12 +2299,12 @@ def build_day_planner_sections(
|
||||
candidates = fetch_plan_candidates(daypart["id"])
|
||||
entries = day_entries.get((selected_date.isoformat(), daypart["id"]), [])
|
||||
meal_candidates = dedupe_items(
|
||||
[item for item in candidates if item["kind"] == "meal" and item["availability_state"] == "home"]
|
||||
[item for item in candidates if item["kind"] == "meal" and item.get("is_home")]
|
||||
+ [item for item in candidates if item["kind"] == "meal"],
|
||||
limit=6,
|
||||
)
|
||||
food_candidates = dedupe_items(
|
||||
[item for item in candidates if item["kind"] == "food" and item["availability_state"] == "home"]
|
||||
[item for item in candidates if item["kind"] == "food" and item.get("is_home")]
|
||||
+ fetch_recent_plan_items(daypart["id"])
|
||||
+ [item for item in candidates if item["kind"] == "food"],
|
||||
limit=20,
|
||||
@@ -2281,7 +2347,7 @@ def build_template_day_sections(selected_map: dict[int, list[int]] | None = None
|
||||
for daypart in get_dayparts():
|
||||
candidates = fetch_plan_candidates(int(daypart["id"]))
|
||||
quick_items = dedupe_items(
|
||||
[item for item in candidates if item["availability_state"] == "home"] + candidates,
|
||||
[item for item in candidates if item.get("is_home")] + candidates,
|
||||
limit=10,
|
||||
)
|
||||
quick_ids = {item["id"] for item in quick_items}
|
||||
@@ -2305,7 +2371,7 @@ def fetch_week_cards(week_start: date):
|
||||
for daypart in get_dayparts():
|
||||
candidates = fetch_plan_candidates(int(daypart["id"]))
|
||||
meal_candidates = dedupe_items(
|
||||
[item for item in candidates if item["kind"] == "meal" and item["availability_state"] == "home"]
|
||||
[item for item in candidates if item["kind"] == "meal" and item.get("is_home")]
|
||||
+ [item for item in candidates if item["kind"] == "meal"],
|
||||
limit=4,
|
||||
)
|
||||
@@ -2379,6 +2445,7 @@ def fetch_plan_entries_for_range_export(start_date: date, end_date: date, *, mod
|
||||
items.kind AS item_kind,
|
||||
items.photo_filename,
|
||||
items.availability_state,
|
||||
items.is_archived,
|
||||
dayparts.name AS daypart_name,
|
||||
dayparts.slug AS daypart_slug,
|
||||
dayparts.sort_order,
|
||||
@@ -2523,10 +2590,20 @@ def build_week_plan_pdf(week_start: date, *, mode: str = "mine") -> bytes:
|
||||
|
||||
|
||||
def count_visible_items(availability_state: str) -> int:
|
||||
row = get_db().execute(
|
||||
f"SELECT COUNT(*) AS count FROM items WHERE availability_state = ? AND {visible_clause('items')}",
|
||||
[availability_state, *visible_params()],
|
||||
).fetchone()
|
||||
if availability_state == "archived":
|
||||
row = get_db().execute(
|
||||
f"SELECT COUNT(*) AS count FROM items WHERE is_archived = 1 AND COALESCE(is_quick_added, 0) = 0 AND {visible_clause('items')}",
|
||||
visible_params(),
|
||||
).fetchone()
|
||||
else:
|
||||
row = get_db().execute(
|
||||
f"""
|
||||
SELECT COUNT(*) AS count
|
||||
FROM items
|
||||
WHERE availability_state = ? AND is_archived = 0 AND COALESCE(is_quick_added, 0) = 0 AND {visible_clause('items')}
|
||||
""",
|
||||
[availability_state, *visible_params()],
|
||||
).fetchone()
|
||||
return int(row["count"])
|
||||
|
||||
|
||||
@@ -2942,8 +3019,7 @@ def apply_item_set_to_shopping(set_id: int) -> dict:
|
||||
|
||||
|
||||
def render_item_form(kind: str, *, item: dict | None, form_data: dict):
|
||||
food_search = form_data.get("food_search") or None
|
||||
foods = fetch_food_options(query=food_search if kind == "meal" else None)
|
||||
foods = fetch_food_options() if kind == "meal" else []
|
||||
return render_template(
|
||||
"items/form.html",
|
||||
kind=kind,
|
||||
@@ -3225,6 +3301,8 @@ def day_template_create():
|
||||
sync_day_template_entries(template_id, form_data["selected_map"])
|
||||
get_db().commit()
|
||||
flash("Die Tagesvorlage wurde gespeichert.", "success")
|
||||
if wants_to_stay_on_form():
|
||||
return redirect(url_with_scroll_position(url_for("main.day_template_edit", template_id=template_id)))
|
||||
return redirect(url_for("main.template_library"))
|
||||
return render_template(
|
||||
"library/day_form.html",
|
||||
@@ -3270,6 +3348,8 @@ def day_template_edit(template_id: int):
|
||||
sync_day_template_entries(template_id, form_data["selected_map"])
|
||||
get_db().commit()
|
||||
flash("Die Tagesvorlage wurde aktualisiert.", "success")
|
||||
if wants_to_stay_on_form():
|
||||
return redirect(url_with_scroll_position(url_for("main.day_template_edit", template_id=template_id)))
|
||||
return redirect(url_for("main.template_library"))
|
||||
return render_template(
|
||||
"library/day_form.html",
|
||||
@@ -3337,6 +3417,8 @@ def week_template_create():
|
||||
sync_week_template_days(template_id, selected_map)
|
||||
get_db().commit()
|
||||
flash("Die Wochenvorlage wurde gespeichert.", "success")
|
||||
if wants_to_stay_on_form():
|
||||
return redirect(url_with_scroll_position(url_for("main.week_template_edit", template_id=template_id)))
|
||||
return redirect(url_for("main.template_library"))
|
||||
return render_template(
|
||||
"library/week_form.html",
|
||||
@@ -3382,6 +3464,8 @@ def week_template_edit(template_id: int):
|
||||
sync_week_template_days(template_id, form_data["selected_map"])
|
||||
get_db().commit()
|
||||
flash("Die Wochenvorlage wurde aktualisiert.", "success")
|
||||
if wants_to_stay_on_form():
|
||||
return redirect(url_with_scroll_position(url_for("main.week_template_edit", template_id=template_id)))
|
||||
return redirect(url_for("main.template_library"))
|
||||
return render_template(
|
||||
"library/week_form.html",
|
||||
@@ -3436,6 +3520,8 @@ def item_set_create():
|
||||
sync_item_set_items(set_id, form_data["item_ids"])
|
||||
get_db().commit()
|
||||
flash("Das Paket wurde gespeichert.", "success")
|
||||
if wants_to_stay_on_form():
|
||||
return redirect(url_with_scroll_position(url_for("main.item_set_edit", set_id=set_id)))
|
||||
return redirect(url_for("main.template_library"))
|
||||
items = fetch_items(include_archived=False, query=form_data["item_search"] or None)
|
||||
return render_template(
|
||||
@@ -3487,6 +3573,8 @@ def item_set_edit(set_id: int):
|
||||
sync_item_set_items(set_id, form_data["item_ids"])
|
||||
get_db().commit()
|
||||
flash("Das Paket wurde aktualisiert.", "success")
|
||||
if wants_to_stay_on_form():
|
||||
return redirect(url_with_scroll_position(url_for("main.item_set_edit", set_id=set_id)))
|
||||
return redirect(url_for("main.template_library"))
|
||||
items = fetch_items(include_archived=False, query=form_data["item_search"] or None)
|
||||
return render_template(
|
||||
@@ -3531,6 +3619,9 @@ def settings_view():
|
||||
)
|
||||
get_db().commit()
|
||||
flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success")
|
||||
if wants_to_stay_on_form():
|
||||
return redirect(url_with_scroll_position(url_for("main.settings_view")))
|
||||
return redirect(url_for("auth.profile"))
|
||||
elif form_name == "reminders":
|
||||
ensure_user_settings_row()
|
||||
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced")
|
||||
@@ -3591,6 +3682,9 @@ def settings_view():
|
||||
)
|
||||
get_db().commit()
|
||||
flash("Deine Erinnerungen und Hinweise wurden gespeichert.", "success")
|
||||
if wants_to_stay_on_form():
|
||||
return redirect(url_with_scroll_position(url_for("main.settings_view")))
|
||||
return redirect(url_for("auth.profile"))
|
||||
elif form_name == "push_test":
|
||||
subscription = get_db().execute(
|
||||
"""
|
||||
@@ -3656,18 +3750,17 @@ def backup_export():
|
||||
current_app.config["APP_VERSION"],
|
||||
)
|
||||
|
||||
@after_this_request
|
||||
def cleanup_backup(response):
|
||||
Path(archive_path).unlink(missing_ok=True)
|
||||
return response
|
||||
|
||||
return send_file(
|
||||
archive_size = Path(archive_path).stat().st_size
|
||||
response = send_file(
|
||||
archive_path,
|
||||
as_attachment=True,
|
||||
download_name=download_name,
|
||||
mimetype="application/zip",
|
||||
max_age=0,
|
||||
)
|
||||
response.content_length = archive_size
|
||||
response.call_on_close(lambda: Path(archive_path).unlink(missing_ok=True))
|
||||
return response
|
||||
|
||||
|
||||
@main_bp.post("/settings/backup/restore")
|
||||
@@ -3763,6 +3856,7 @@ def item_list(kind: str):
|
||||
items = fetch_items(
|
||||
kind=kind,
|
||||
availability=state or None,
|
||||
include_quick_added=state == "unsorted",
|
||||
query=query or None,
|
||||
daypart_id=daypart_id,
|
||||
visibility=scope or None,
|
||||
@@ -3783,6 +3877,104 @@ def item_list(kind: str):
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route("/items/food/quick-add", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def item_quick_add():
|
||||
form_data = {
|
||||
"names_text": "",
|
||||
"visibility": "shared",
|
||||
"target_user_id": None,
|
||||
"target_user_raw": TARGET_USER_OPTIONS_DEFAULT,
|
||||
"base_type": "neutral",
|
||||
"flavor_profile": "neutral",
|
||||
"suggestion_role": "base",
|
||||
"suggestion_priority": "normal",
|
||||
"can_be_meal_core": False,
|
||||
"energy_density": "neutral",
|
||||
"daypart_ids": [],
|
||||
"note": "",
|
||||
}
|
||||
|
||||
if request.method == "POST":
|
||||
form_data.update(
|
||||
{
|
||||
"names_text": request.form.get("names_text", "").strip(),
|
||||
"visibility": normalize_visibility(request.form.get("visibility"), form_data["visibility"]),
|
||||
"target_user_id": normalize_target_user_id(request.form.get("target_user_id")),
|
||||
"target_user_raw": request.form.get("target_user_id", TARGET_USER_OPTIONS_DEFAULT),
|
||||
"base_type": normalize_base_type(request.form.get("base_type"), form_data["base_type"]),
|
||||
"flavor_profile": normalize_food_flavor(request.form.get("flavor_profile"), form_data["flavor_profile"]),
|
||||
"suggestion_role": normalize_food_role(request.form.get("suggestion_role"), form_data["suggestion_role"]),
|
||||
"suggestion_priority": normalize_suggestion_priority(request.form.get("suggestion_priority"), form_data["suggestion_priority"]),
|
||||
"can_be_meal_core": request.form.get("can_be_meal_core", "0") == "1",
|
||||
"energy_density": normalize_energy_density(request.form.get("energy_density"), form_data["energy_density"]),
|
||||
"daypart_ids": [int(value) for value in request.form.getlist("daypart_ids") if value.isdigit()],
|
||||
"note": request.form.get("note", "").strip(),
|
||||
}
|
||||
)
|
||||
|
||||
names = list(
|
||||
dict.fromkeys(
|
||||
line.strip()
|
||||
for line in form_data["names_text"].splitlines()
|
||||
if line.strip()
|
||||
)
|
||||
)
|
||||
|
||||
if not names:
|
||||
flash("Bitte mindestens ein Lebensmittel eintragen.", "error")
|
||||
else:
|
||||
created_names: list[str] = []
|
||||
for name in names:
|
||||
cursor = get_db().execute(
|
||||
"""
|
||||
INSERT INTO items (
|
||||
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, availability_state, is_archived, is_quick_added, created_by, updated_by
|
||||
)
|
||||
VALUES (?, ?, ?, ?, 'food', ?, ?, ?, ?, ?, ?, ?, NULL, '', ?, ?, 'idea', 0, 1, ?, ?)
|
||||
""",
|
||||
(
|
||||
current_household_id(),
|
||||
g.user["id"],
|
||||
form_data["target_user_id"],
|
||||
form_data["visibility"],
|
||||
name,
|
||||
"Unsortiert",
|
||||
form_data["base_type"],
|
||||
form_data["flavor_profile"],
|
||||
form_data["suggestion_role"],
|
||||
form_data["suggestion_priority"],
|
||||
1 if form_data["can_be_meal_core"] else 0,
|
||||
form_data["energy_density"],
|
||||
form_data["note"],
|
||||
g.user["id"],
|
||||
g.user["id"],
|
||||
),
|
||||
)
|
||||
item_id = int(cursor.lastrowid)
|
||||
sync_item_dayparts(item_id, form_data["daypart_ids"])
|
||||
created_names.append(name)
|
||||
|
||||
get_db().commit()
|
||||
flash(f"{len(created_names)} Lebensmittel wurden in „Unsortiert“ angelegt.", "success")
|
||||
return redirect(url_for("main.item_list", kind="food", state="unsorted"))
|
||||
|
||||
return render_template(
|
||||
"items/quick_add.html",
|
||||
form_data=form_data,
|
||||
builder_options=[(key, label) for key, label in BUILDER_LABELS.items()],
|
||||
food_flavor_options=FOOD_FLAVOR_OPTIONS,
|
||||
food_flavor_descriptions=FOOD_FLAVOR_DESCRIPTIONS,
|
||||
food_role_options=FOOD_ROLE_OPTIONS,
|
||||
food_role_descriptions=FOOD_ROLE_DESCRIPTIONS,
|
||||
suggestion_priority_options=SUGGESTION_PRIORITY_OPTIONS,
|
||||
energy_density_options=ENERGY_DENSITY_OPTIONS,
|
||||
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||
target_user_options=get_target_user_options(),
|
||||
dayparts=get_dayparts(),
|
||||
)
|
||||
|
||||
|
||||
@main_bp.route("/items/<kind>/new", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def item_create(kind: str):
|
||||
@@ -3894,6 +4086,8 @@ def item_create(kind: str):
|
||||
sync_meal_components(item_id, form_data["component_ids"])
|
||||
get_db().commit()
|
||||
flash(f"{ITEM_KIND_SINGULAR_LABELS[kind]} wurde angelegt.", "success")
|
||||
if wants_to_stay_on_form():
|
||||
return redirect(url_with_scroll_position(url_for("main.item_edit", item_id=item_id)))
|
||||
return redirect(url_for("main.item_list", kind=kind))
|
||||
flash(error, "error")
|
||||
|
||||
@@ -4025,6 +4219,8 @@ def item_edit(item_id: int):
|
||||
sync_meal_components(item_id, form_data["component_ids"])
|
||||
get_db().commit()
|
||||
flash("Der Eintrag wurde aktualisiert.", "success")
|
||||
if wants_to_stay_on_form():
|
||||
return redirect(url_with_scroll_position(url_for("main.item_edit", item_id=item_id)))
|
||||
return redirect(url_for("main.item_list", kind=item["kind"]))
|
||||
flash(error, "error")
|
||||
|
||||
@@ -4040,6 +4236,13 @@ def item_add_to_shopping(item_id: int):
|
||||
flash(str(exc), "error")
|
||||
return redirect(request.referrer or url_for("main.shopping_list"))
|
||||
|
||||
if item.get("is_archived"):
|
||||
get_db().execute(
|
||||
"UPDATE items SET is_archived = 0, updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(g.user["id"], item_id),
|
||||
)
|
||||
get_db().commit()
|
||||
|
||||
result = ensure_item_or_missing_components_are_shopped(
|
||||
item_id,
|
||||
g.user["id"],
|
||||
@@ -4068,7 +4271,15 @@ def item_set_home(item_id: int):
|
||||
return redirect(request.referrer or url_for("main.home_view"))
|
||||
|
||||
get_db().execute(
|
||||
"UPDATE items SET availability_state = 'home', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
"""
|
||||
UPDATE items
|
||||
SET availability_state = 'home',
|
||||
is_archived = 0,
|
||||
is_quick_added = 0,
|
||||
updated_by = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(g.user["id"], item_id),
|
||||
)
|
||||
get_db().commit()
|
||||
@@ -4076,6 +4287,33 @@ def item_set_home(item_id: int):
|
||||
return redirect(request.referrer or url_for("main.home_view"))
|
||||
|
||||
|
||||
@main_bp.post("/items/<int:item_id>/set-not-home")
|
||||
@login_required
|
||||
def item_set_not_home(item_id: int):
|
||||
try:
|
||||
item = get_item(item_id)
|
||||
ensure_can_edit(item)
|
||||
except (ValueError, PermissionError) as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(request.referrer or url_for("main.home_view"))
|
||||
|
||||
get_db().execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET availability_state = 'idea',
|
||||
is_archived = 0,
|
||||
is_quick_added = 0,
|
||||
updated_by = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(g.user["id"], item_id),
|
||||
)
|
||||
get_db().commit()
|
||||
flash(f"{item['name']} ist jetzt als nicht mehr da markiert.", "info")
|
||||
return redirect(request.referrer or url_for("main.home_view"))
|
||||
|
||||
|
||||
@main_bp.post("/items/<int:item_id>/archive")
|
||||
@login_required
|
||||
def item_archive(item_id: int):
|
||||
@@ -4087,7 +4325,15 @@ def item_archive(item_id: int):
|
||||
return redirect(request.referrer or url_for("main.archive_view"))
|
||||
|
||||
get_db().execute(
|
||||
"UPDATE items SET availability_state = 'archived', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
"""
|
||||
UPDATE items
|
||||
SET availability_state = 'idea',
|
||||
is_archived = 1,
|
||||
is_quick_added = 0,
|
||||
updated_by = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(g.user["id"], item_id),
|
||||
)
|
||||
get_db().commit()
|
||||
@@ -4106,7 +4352,15 @@ def item_restore(item_id: int):
|
||||
return redirect(request.referrer or url_for("main.archive_view"))
|
||||
|
||||
get_db().execute(
|
||||
"UPDATE items SET availability_state = 'idea', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
"""
|
||||
UPDATE items
|
||||
SET is_archived = 0,
|
||||
is_quick_added = 0,
|
||||
availability_state = CASE WHEN availability_state = 'home' THEN 'home' ELSE 'idea' END,
|
||||
updated_by = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(g.user["id"], item_id),
|
||||
)
|
||||
get_db().commit()
|
||||
@@ -4114,35 +4368,199 @@ def item_restore(item_id: int):
|
||||
return redirect(request.referrer or url_for("main.archive_view"))
|
||||
|
||||
|
||||
def mark_shopping_entry_checked(entry_id: int) -> dict:
|
||||
entry = get_db().execute(
|
||||
f"""
|
||||
SELECT shopping_entries.*,
|
||||
owner.display_name AS owner_display_name,
|
||||
owner.username AS owner_username
|
||||
FROM shopping_entries
|
||||
LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id
|
||||
WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')}
|
||||
""",
|
||||
[entry_id, *visible_params()],
|
||||
).fetchone()
|
||||
if entry is None:
|
||||
raise ValueError("Der Einkaufseintrag wurde nicht gefunden.")
|
||||
|
||||
ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht ändern.")
|
||||
item = get_item(entry["item_id"])
|
||||
|
||||
get_db().execute(
|
||||
"UPDATE shopping_entries SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? WHERE id = ?",
|
||||
(g.user["id"], entry_id),
|
||||
)
|
||||
get_db().execute(
|
||||
"""
|
||||
UPDATE items
|
||||
SET availability_state = 'home',
|
||||
is_archived = 0,
|
||||
is_quick_added = 0,
|
||||
updated_by = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(g.user["id"], item["id"]),
|
||||
)
|
||||
get_db().commit()
|
||||
return item
|
||||
|
||||
|
||||
def remove_shopping_entry(entry_id: int) -> None:
|
||||
entry = get_db().execute(
|
||||
f"""
|
||||
SELECT shopping_entries.*,
|
||||
owner.display_name AS owner_display_name,
|
||||
owner.username AS owner_username
|
||||
FROM shopping_entries
|
||||
LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id
|
||||
WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')}
|
||||
""",
|
||||
[entry_id, *visible_params()],
|
||||
).fetchone()
|
||||
if entry is None:
|
||||
raise ValueError("Der Eintrag wurde nicht gefunden.")
|
||||
|
||||
ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht entfernen.")
|
||||
get_db().execute("DELETE FROM shopping_entries WHERE id = ?", (entry_id,))
|
||||
get_db().commit()
|
||||
|
||||
|
||||
@main_bp.post("/shopping/<int:entry_id>/note")
|
||||
@login_required
|
||||
def shopping_update_note(entry_id: int):
|
||||
entry = get_db().execute(
|
||||
f"""
|
||||
SELECT shopping_entries.*,
|
||||
owner.display_name AS owner_display_name,
|
||||
owner.username AS owner_username
|
||||
FROM shopping_entries
|
||||
LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id
|
||||
WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')}
|
||||
""",
|
||||
[entry_id, *visible_params()],
|
||||
).fetchone()
|
||||
if entry is None:
|
||||
flash("Der Einkaufseintrag wurde nicht gefunden.", "error")
|
||||
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||
|
||||
try:
|
||||
ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht ändern.")
|
||||
except PermissionError as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||
|
||||
shopping_note = normalize_shopping_note(request.form.get("shopping_note"))
|
||||
duplicate = get_db().execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM shopping_entries
|
||||
WHERE item_id = ?
|
||||
AND shopping_note = ?
|
||||
AND is_checked = 0
|
||||
AND id != ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(entry["item_id"], shopping_note, entry_id),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
flash("Dieser Hinweis steht für das Lebensmittel schon auf der Einkaufsliste.", "info")
|
||||
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||
|
||||
get_db().execute(
|
||||
"UPDATE shopping_entries SET shopping_note = ? WHERE id = ?",
|
||||
(shopping_note, entry_id),
|
||||
)
|
||||
get_db().commit()
|
||||
flash("Der Einkaufshinweis wurde gespeichert.", "success")
|
||||
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||
|
||||
|
||||
@main_bp.post("/items/<int:item_id>/shopping/bought")
|
||||
@login_required
|
||||
def item_mark_bought(item_id: int):
|
||||
entry = get_db().execute(
|
||||
"""
|
||||
SELECT id FROM shopping_entries
|
||||
WHERE item_id = ? AND is_checked = 0
|
||||
ORDER BY added_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
if entry is None:
|
||||
return redirect(request.referrer or url_for("main.shopping_list"))
|
||||
try:
|
||||
item = mark_shopping_entry_checked(int(entry["id"]))
|
||||
except (ValueError, PermissionError) as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(request.referrer or url_for("main.shopping_list"))
|
||||
flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success")
|
||||
return redirect(request.referrer or url_for("main.shopping_list"))
|
||||
|
||||
|
||||
@main_bp.post("/items/<int:item_id>/shopping/remove")
|
||||
@login_required
|
||||
def item_remove_from_shopping(item_id: int):
|
||||
entry = get_db().execute(
|
||||
"""
|
||||
SELECT id FROM shopping_entries
|
||||
WHERE item_id = ? AND is_checked = 0
|
||||
ORDER BY added_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
if entry is None:
|
||||
return redirect(request.referrer or url_for("main.shopping_list"))
|
||||
try:
|
||||
remove_shopping_entry(int(entry["id"]))
|
||||
except (ValueError, PermissionError) as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(request.referrer or url_for("main.shopping_list"))
|
||||
flash("Der Eintrag wurde von der Einkaufsliste entfernt.", "info")
|
||||
return redirect(request.referrer or url_for("main.shopping_list"))
|
||||
|
||||
|
||||
@main_bp.route("/shopping", methods=("GET", "POST"))
|
||||
@login_required
|
||||
def shopping_list():
|
||||
if request.method == "POST":
|
||||
selected_item_id = request.form.get("item_id", "").strip()
|
||||
if not selected_item_id.isdigit():
|
||||
flash("Bitte zuerst etwas auswählen.", "error")
|
||||
else:
|
||||
item_search = request.form.get("item_search", "").strip()
|
||||
shopping_note = normalize_shopping_note(request.form.get("shopping_note"))
|
||||
item = None
|
||||
if selected_item_id.isdigit():
|
||||
try:
|
||||
item = get_item(int(selected_item_id))
|
||||
result = ensure_item_or_missing_components_are_shopped(
|
||||
item["id"],
|
||||
g.user["id"],
|
||||
item["visibility"],
|
||||
)
|
||||
if result["count"]:
|
||||
flash(f"Die Einkaufsliste wurde ergänzt: {', '.join(result['names'][:4])}.", "success")
|
||||
elif result["scheduled_count"]:
|
||||
flash("Ein paar Dinge sind für einen späteren Einkauf vorgemerkt.", "info")
|
||||
else:
|
||||
flash("Dafür ist gerade nichts zusätzlich nötig.", "info")
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(url_for("main.shopping_list"))
|
||||
elif item_search:
|
||||
item = find_shopping_food_by_name(item_search)
|
||||
if item is None:
|
||||
flash("Bitte ein Lebensmittel aus der Suche auswählen.", "error")
|
||||
else:
|
||||
flash("Bitte zuerst etwas auswählen.", "error")
|
||||
|
||||
if item is not None:
|
||||
result = ensure_item_or_missing_components_are_shopped(
|
||||
item["id"],
|
||||
g.user["id"],
|
||||
item["visibility"],
|
||||
shopping_note=shopping_note,
|
||||
)
|
||||
if result["count"]:
|
||||
note_suffix = f" ({shopping_note})" if shopping_note else ""
|
||||
flash(f"Die Einkaufsliste wurde ergänzt: {', '.join(result['names'][:4])}{note_suffix}.", "success")
|
||||
elif result["scheduled_count"]:
|
||||
flash("Ein paar Dinge sind für einen späteren Einkauf vorgemerkt.", "info")
|
||||
else:
|
||||
flash("Dieser Einkaufseintrag steht so schon auf der Liste.", "info")
|
||||
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||
|
||||
entries = fetch_shopping_entries()
|
||||
upcoming_entries = fetch_upcoming_shopping_needs()
|
||||
addable_items = fetch_items(include_archived=False)
|
||||
addable_items = [item for item in addable_items if not item["is_on_shopping_list"]]
|
||||
addable_items = fetch_items(kind="food", include_archived=False, include_quick_added=True)
|
||||
household_settings = get_household_settings()
|
||||
shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt")
|
||||
return render_template(
|
||||
@@ -4158,68 +4576,25 @@ def shopping_list():
|
||||
@main_bp.post("/shopping/<int:entry_id>/check")
|
||||
@login_required
|
||||
def shopping_check(entry_id: int):
|
||||
entry = get_db().execute(
|
||||
f"""
|
||||
SELECT shopping_entries.*,
|
||||
owner.display_name AS owner_display_name,
|
||||
owner.username AS owner_username
|
||||
FROM shopping_entries
|
||||
LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id
|
||||
WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')}
|
||||
""",
|
||||
[entry_id, *visible_params()],
|
||||
).fetchone()
|
||||
if entry is None:
|
||||
flash("Der Einkaufseintrag wurde nicht gefunden.", "error")
|
||||
return redirect(url_for("main.shopping_list"))
|
||||
|
||||
entry_dict = describe_record(dict(entry))
|
||||
try:
|
||||
ensure_can_edit(entry_dict, "Diesen Einkaufseintrag kannst du gerade nicht ändern.")
|
||||
item = get_item(entry["item_id"])
|
||||
item = mark_shopping_entry_checked(entry_id)
|
||||
except (ValueError, PermissionError) as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(url_for("main.shopping_list"))
|
||||
|
||||
get_db().execute(
|
||||
"UPDATE shopping_entries SET is_checked = 1, checked_at = CURRENT_TIMESTAMP, checked_by = ? WHERE id = ?",
|
||||
(g.user["id"], entry_id),
|
||||
)
|
||||
get_db().execute(
|
||||
"UPDATE items SET availability_state = 'home', updated_by = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(g.user["id"], item["id"]),
|
||||
)
|
||||
get_db().commit()
|
||||
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||
flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success")
|
||||
return redirect(url_for("main.shopping_list"))
|
||||
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||
|
||||
|
||||
@main_bp.post("/shopping/<int:entry_id>/remove")
|
||||
@login_required
|
||||
def shopping_remove(entry_id: int):
|
||||
entry = get_db().execute(
|
||||
f"""
|
||||
SELECT shopping_entries.*,
|
||||
owner.display_name AS owner_display_name,
|
||||
owner.username AS owner_username
|
||||
FROM shopping_entries
|
||||
LEFT JOIN users AS owner ON owner.id = shopping_entries.owner_user_id
|
||||
WHERE shopping_entries.id = ? AND {visible_clause('shopping_entries')}
|
||||
""",
|
||||
[entry_id, *visible_params()],
|
||||
).fetchone()
|
||||
if entry is None:
|
||||
flash("Der Eintrag wurde nicht gefunden.", "error")
|
||||
return redirect(url_for("main.shopping_list"))
|
||||
try:
|
||||
ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht entfernen.")
|
||||
except PermissionError as exc:
|
||||
remove_shopping_entry(entry_id)
|
||||
except (ValueError, PermissionError) as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(url_for("main.shopping_list"))
|
||||
get_db().execute("DELETE FROM shopping_entries WHERE id = ?", (entry_id,))
|
||||
get_db().commit()
|
||||
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||
flash("Der Eintrag wurde von der Einkaufsliste entfernt.", "info")
|
||||
return redirect(url_for("main.shopping_list"))
|
||||
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||
|
||||
|
||||
@main_bp.get("/home")
|
||||
|
||||
+7
-1
@@ -132,6 +132,8 @@ CREATE TABLE IF NOT EXISTS items (
|
||||
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,
|
||||
@@ -165,6 +167,7 @@ CREATE TABLE IF NOT EXISTS shopping_entries (
|
||||
owner_user_id INTEGER,
|
||||
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||
item_id INTEGER NOT NULL,
|
||||
shopping_note TEXT NOT NULL DEFAULT '',
|
||||
added_by INTEGER,
|
||||
checked_by INTEGER,
|
||||
needed_for_date TEXT,
|
||||
@@ -181,7 +184,7 @@ CREATE TABLE IF NOT EXISTS shopping_entries (
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shopping_needs (
|
||||
@@ -304,6 +307,9 @@ ON items (kind, name);
|
||||
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
||||
ON items (household_id, visibility, availability_state);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_items_household_visibility_archived
|
||||
ON items (household_id, visibility, is_archived, availability_state);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_items_target_user
|
||||
ON items (target_user_id);
|
||||
|
||||
|
||||
+725
-4
@@ -659,6 +659,10 @@ h3 {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.selected-component-stack.is-live {
|
||||
margin: 0.8rem 0 1rem;
|
||||
}
|
||||
|
||||
.selected-components-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
@@ -677,6 +681,10 @@ h3 {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.selected-component-card.is-needed {
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, var(--line) 58%);
|
||||
}
|
||||
|
||||
.selected-component-main {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
@@ -692,6 +700,103 @@ h3 {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.selected-component-main small {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.meal-component-search {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.meal-component-results {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.meal-component-option-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.meal-component-option {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.meal-component-option input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.meal-component-option-card {
|
||||
min-height: 92px;
|
||||
display: grid;
|
||||
grid-template-columns: 58px minmax(0, 1fr);
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--surface-strong) 86%, var(--accent-soft) 14%);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.meal-component-option-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--accent) 38%, var(--line) 62%);
|
||||
}
|
||||
|
||||
.meal-component-option input:focus-visible + .meal-component-option-card {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent 55%);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.meal-component-option input:checked + .meal-component-option-card {
|
||||
border-color: color-mix(in srgb, var(--accent) 58%, var(--line) 42%);
|
||||
background: color-mix(in srgb, var(--accent-soft) 28%, var(--surface-strong) 72%);
|
||||
box-shadow: 0 12px 28px color-mix(in srgb, var(--accent) 13%, transparent 87%);
|
||||
}
|
||||
|
||||
.meal-component-option-visual {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--surface-soft) 84%, transparent 16%);
|
||||
border: 1px solid color-mix(in srgb, var(--line) 80%, transparent 20%);
|
||||
}
|
||||
|
||||
.meal-component-option-visual img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.meal-component-option-visual .ui-icon {
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
}
|
||||
|
||||
.meal-component-option-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.meal-component-option-copy strong {
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.selected-component-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -765,6 +870,42 @@ h3 {
|
||||
border-color: color-mix(in srgb, rgba(243, 177, 125, 0.36) 48%, var(--line) 52%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .selected-component-card.is-needed,
|
||||
[data-theme="dark"] .meal-component-option input:checked + .meal-component-option-card {
|
||||
border-color: color-mix(in srgb, rgba(243, 177, 125, 0.54) 52%, var(--line) 48%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .meal-component-option-card {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 70%, #4a3e3a 30%),
|
||||
color-mix(in srgb, var(--surface) 92%, #241f1d 8%)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .meal-component-option input:checked + .meal-component-option-card {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 58%, #5d4a40 42%),
|
||||
color-mix(in srgb, var(--surface) 88%, #302824 12%)
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .meal-component-option-visual {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 60%, #453a37 40%),
|
||||
color-mix(in srgb, var(--surface) 90%, #2b2523 10%)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.18) 42%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .food-status-badge.is-needed {
|
||||
color: color-mix(in srgb, var(--accent-strong) 82%, white 18%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .selected-component-fallback {
|
||||
background:
|
||||
linear-gradient(
|
||||
@@ -941,11 +1082,21 @@ h3 {
|
||||
color: #ece8e4;
|
||||
}
|
||||
|
||||
.status-unsorted {
|
||||
background: rgba(184, 161, 108, 0.18);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .status-unsorted {
|
||||
background: rgba(177, 148, 97, 0.2);
|
||||
color: #f1e7d8;
|
||||
}
|
||||
|
||||
.status-soft {
|
||||
background: var(--lilac-soft);
|
||||
}
|
||||
|
||||
.item-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 112px 1fr;
|
||||
gap: 1rem;
|
||||
@@ -979,6 +1130,20 @@ h3 {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.placeholder-icon-tile {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--surface-soft) 84%, transparent 16%);
|
||||
}
|
||||
|
||||
.placeholder-icon-tile .ui-icon {
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.item-body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
@@ -1057,13 +1222,531 @@ h3 {
|
||||
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
|
||||
}
|
||||
|
||||
.item-card-food {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.9rem;
|
||||
align-content: start;
|
||||
min-height: 260px;
|
||||
padding: 1.15rem 1.15rem 1.2rem;
|
||||
}
|
||||
|
||||
.item-card-food-muted {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.item-card-food .item-media-food {
|
||||
width: min(100%, 170px);
|
||||
justify-self: center;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.item-card-food .item-body-food {
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.item-card-food .item-body-food h2 {
|
||||
margin: 0;
|
||||
font-size: 1.9rem;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.item-card-cover-link {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.item-card-archive-form {
|
||||
position: absolute;
|
||||
top: 0.85rem;
|
||||
right: 0.85rem;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.item-card-archive-button {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--line) 82%, transparent 18%);
|
||||
background: color-mix(in srgb, var(--surface-soft) 88%, transparent 12%);
|
||||
color: var(--muted);
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.item-card-archive-button:hover {
|
||||
background: color-mix(in srgb, var(--accent-soft) 26%, var(--surface-soft) 74%);
|
||||
border-color: color-mix(in srgb, var(--accent) 34%, var(--line) 66%);
|
||||
color: var(--text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.item-card-hover-meta {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
align-content: end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: inherit;
|
||||
background: color-mix(in srgb, var(--surface) 68%, rgba(48, 39, 35, 0.86) 32%);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(8px);
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease, visibility 0.18s ease;
|
||||
}
|
||||
|
||||
.item-card-hover-meta p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.item-card-food:hover .item-card-hover-meta,
|
||||
.item-card-food:focus-within .item-card-hover-meta {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .item-card-archive-button {
|
||||
background: color-mix(in srgb, var(--surface-soft) 62%, rgba(26, 22, 21, 0.84) 38%);
|
||||
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .item-card-hover-meta {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(36, 29, 27, 0.16),
|
||||
rgba(31, 25, 23, 0.92)
|
||||
);
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.item-actions > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-actions form,
|
||||
.item-actions a {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-actions form button,
|
||||
.item-actions a.ghost-button,
|
||||
.item-actions a.button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.item-actions > .primary-action {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.shopping-add-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(11rem, 0.8fr) auto;
|
||||
gap: 0.8rem;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.shopping-add-form .shopping-add-results {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.shopping-add-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.shopping-add-grid > form {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shopping-add-empty {
|
||||
margin: 0;
|
||||
padding: 0.35rem 0.1rem;
|
||||
}
|
||||
|
||||
.shopping-add-card {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
display: grid;
|
||||
grid-template-columns: 58px minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
align-items: center;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: 22px;
|
||||
text-align: left;
|
||||
background: color-mix(in srgb, var(--surface-strong) 86%, var(--accent-soft) 14%);
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--line) 72%, var(--accent) 28%);
|
||||
}
|
||||
|
||||
.shopping-add-card[hidden],
|
||||
.meal-component-option[hidden],
|
||||
.selected-component-card[data-selected-preview-card][hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shopping-add-card-visual,
|
||||
.shopping-add-card-fallback,
|
||||
.shopping-entry-visual,
|
||||
.shopping-entry-fallback {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--surface-soft) 84%, transparent 16%);
|
||||
border: 1px solid color-mix(in srgb, var(--line) 80%, transparent 20%);
|
||||
}
|
||||
|
||||
.shopping-add-card-visual img,
|
||||
.shopping-entry-visual img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.shopping-add-card-fallback .ui-icon,
|
||||
.shopping-entry-fallback .ui-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.shopping-add-card-copy,
|
||||
.shopping-entry-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
.shopping-add-card-copy strong,
|
||||
.shopping-entry-copy strong {
|
||||
display: block;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.shopping-add-card-copy small {
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.food-status-badge {
|
||||
width: fit-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.food-status-badge .ui-icon {
|
||||
width: 0.86rem;
|
||||
height: 0.86rem;
|
||||
}
|
||||
|
||||
.food-status-badge.is-needed {
|
||||
color: color-mix(in srgb, var(--accent-strong) 72%, var(--text) 28%);
|
||||
}
|
||||
|
||||
.shopping-entry-card {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-strong) 90%, transparent 10%),
|
||||
color-mix(in srgb, var(--surface) 94%, transparent 6%)
|
||||
);
|
||||
}
|
||||
|
||||
.shopping-entry-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.shopping-entry-open {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border-radius: 18px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shopping-entry-open:hover .shopping-entry-main,
|
||||
.shopping-entry-open:focus-visible .shopping-entry-main {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.shopping-entry-main {
|
||||
display: grid;
|
||||
grid-template-columns: 58px minmax(0, 1fr);
|
||||
gap: 0.95rem;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
transition: transform 140ms ease;
|
||||
}
|
||||
|
||||
.shopping-entry-copy .muted {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shopping-entry-note {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.12rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent-soft) 56%, transparent 44%);
|
||||
color: color-mix(in srgb, var(--accent-strong) 70%, var(--text) 30%);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.shopping-entry-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.shopping-entry-actions form {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.shopping-entry-actions button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shopping-entry-check-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.shopping-entry-check-mark {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.shopping-entry-close-form {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.shopping-entry-close {
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
min-width: 2.1rem;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--surface-soft) 34%, transparent 66%);
|
||||
}
|
||||
|
||||
.shopping-entry-close span[aria-hidden="true"] {
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.shopping-entry-dialog {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
max-width: min(30rem, calc(100vw - 2rem));
|
||||
width: min(30rem, calc(100vw - 2rem));
|
||||
}
|
||||
|
||||
.shopping-entry-dialog::backdrop {
|
||||
background: rgba(29, 22, 19, 0.54);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.shopping-entry-dialog-card {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.shopping-entry-dialog-actions {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.shopping-entry-dialog-actions form,
|
||||
.shopping-entry-dialog-actions a {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shopping-entry-dialog-actions a {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .shopping-entry-close {
|
||||
background: color-mix(in srgb, var(--surface-soft) 46%, rgba(34, 29, 27, 0.54) 54%);
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.shopping-add-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.shopping-add-form button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.shopping-entry-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.shopping-entry-open {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.shopping-entry-main {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shopping-entry-visual,
|
||||
.shopping-entry-fallback {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.shopping-entry-copy {
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.shopping-entry-copy strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.shopping-entry-copy .muted {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shopping-entry-note {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.shopping-entry-actions,
|
||||
.shopping-entry-actions form,
|
||||
.shopping-entry-actions button,
|
||||
.shopping-entry-close-form {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.shopping-entry-check-button {
|
||||
min-width: 0;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 16px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.shopping-entry-check-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shopping-entry-check-mark {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.shopping-entry-close {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
min-width: 2.75rem;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] .shopping-add-card-fallback,
|
||||
[data-theme="dark"] .shopping-entry-fallback,
|
||||
[data-theme="dark"] .shopping-add-card-visual,
|
||||
[data-theme="dark"] .shopping-entry-visual {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 60%, #453a37 40%),
|
||||
color-mix(in srgb, var(--surface) 90%, #2b2523 10%)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.18) 42%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] button.shopping-add-card,
|
||||
[data-theme="dark"] .shopping-add-card {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 70%, #4a3e3a 30%),
|
||||
color-mix(in srgb, var(--surface) 92%, #241f1d 8%)
|
||||
);
|
||||
color: var(--text);
|
||||
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-theme="dark"] button.shopping-add-card:hover,
|
||||
[data-theme="dark"] .shopping-add-card:hover {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 62%, #5a4840 38%),
|
||||
color-mix(in srgb, var(--surface) 88%, #2f2724 12%)
|
||||
);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .shopping-add-card-copy small {
|
||||
color: color-mix(in srgb, var(--muted) 86%, white 14%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .shopping-entry-card {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 56%, #433834 44%),
|
||||
color-mix(in srgb, var(--surface) 94%, #26201e 6%)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.14) 42%);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .shopping-entry-note {
|
||||
background: color-mix(in srgb, var(--accent-soft) 54%, rgba(32, 27, 25, 0.46) 46%);
|
||||
color: color-mix(in srgb, var(--accent-strong) 82%, white 18%);
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
min-height: calc(100vh - 10rem);
|
||||
display: grid;
|
||||
@@ -1390,6 +2073,7 @@ legend {
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in srgb, var(--surface-strong) 82%, #fff 18%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.quick-select-card strong,
|
||||
@@ -1407,6 +2091,20 @@ legend {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .quick-select-card {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--surface-soft) 70%, #4a3e3a 30%),
|
||||
color-mix(in srgb, var(--surface) 92%, #241f1d 8%)
|
||||
);
|
||||
border-color: color-mix(in srgb, var(--line) 58%, rgba(243, 177, 125, 0.16) 42%);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .quick-select-card small {
|
||||
color: color-mix(in srgb, var(--muted) 86%, white 14%);
|
||||
}
|
||||
|
||||
.inline-photo img {
|
||||
width: min(220px, 100%);
|
||||
border-radius: 18px;
|
||||
@@ -2374,6 +3072,11 @@ legend {
|
||||
mask-image: url("../icons/fa/cart-shopping.svg");
|
||||
}
|
||||
|
||||
.icon-shopping-cart {
|
||||
-webkit-mask-image: url("../icons/fa/shopping-cart.svg");
|
||||
mask-image: url("../icons/fa/shopping-cart.svg");
|
||||
}
|
||||
|
||||
.icon-calendar {
|
||||
-webkit-mask-image: url("../icons/fa/calendar.svg");
|
||||
mask-image: url("../icons/fa/calendar.svg");
|
||||
@@ -2755,12 +3458,23 @@ legend {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.item-card-food {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.item-card-food .item-media-food {
|
||||
width: min(100%, 156px);
|
||||
}
|
||||
|
||||
.item-card-hover-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.simple-list li,
|
||||
.list-row,
|
||||
.planner-entry-top,
|
||||
.week-nav,
|
||||
.row-actions,
|
||||
.item-actions,
|
||||
.hero-actions,
|
||||
.more-actions,
|
||||
.filter-actions {
|
||||
@@ -2770,12 +3484,19 @@ legend {
|
||||
}
|
||||
|
||||
.row-actions > *,
|
||||
.item-actions > *,
|
||||
.hero-actions > *,
|
||||
.more-actions > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.item-actions > .primary-action {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.quick-add-row {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -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 |
+169
-1
@@ -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 sheet = document.querySelector("[data-mobile-sheet]");
|
||||
const navStack = document.querySelector("[data-mobile-nav-stack]");
|
||||
@@ -54,6 +112,7 @@
|
||||
const filterGroups = Array.from(container.querySelectorAll("[data-filter-group]"));
|
||||
const resultLimit = Number.parseInt(input.getAttribute("data-filter-limit") || "", 10);
|
||||
const hasLimit = Number.isFinite(resultLimit) && resultLimit > 0;
|
||||
const hideWhenEmpty = input.hasAttribute("data-filter-hide-empty");
|
||||
|
||||
const scoreItem = (label, term) => {
|
||||
if (label === term) return 0;
|
||||
@@ -79,11 +138,13 @@
|
||||
const term = input.value.trim().toLowerCase();
|
||||
if (!term) {
|
||||
items.forEach((item, index) => {
|
||||
item.hidden = hasLimit ? index >= resultLimit : false;
|
||||
item.hidden = hideWhenEmpty || (hasLimit ? index >= resultLimit : false);
|
||||
});
|
||||
container.hidden = hideWhenEmpty;
|
||||
syncGroups();
|
||||
return;
|
||||
}
|
||||
container.hidden = false;
|
||||
|
||||
const rankedMatches = items
|
||||
.map((item, index) => {
|
||||
@@ -109,6 +170,55 @@
|
||||
});
|
||||
};
|
||||
|
||||
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 initIosPullToRefresh = () => {
|
||||
const isAppleTouchDevice = /iP(ad|hone|od)/.test(navigator.userAgent)
|
||||
|| (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
@@ -153,9 +263,67 @@
|
||||
}, { passive: false });
|
||||
};
|
||||
|
||||
const initDialogs = () => {
|
||||
document.addEventListener("click", (event) => {
|
||||
const openButton = event.target.closest("[data-dialog-open]");
|
||||
if (openButton instanceof HTMLElement) {
|
||||
const dialogId = openButton.getAttribute("data-dialog-open");
|
||||
if (!dialogId) return;
|
||||
const dialog = document.getElementById(dialogId);
|
||||
if (dialog instanceof HTMLDialogElement) {
|
||||
dialog.showModal();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const closeButton = event.target.closest("[data-dialog-close]");
|
||||
if (closeButton instanceof HTMLElement) {
|
||||
const dialog = closeButton.closest("dialog");
|
||||
if (dialog instanceof HTMLDialogElement) {
|
||||
dialog.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const openButton = target.closest("[data-dialog-open]");
|
||||
if (!(openButton instanceof HTMLElement)) return;
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
const dialogId = openButton.getAttribute("data-dialog-open");
|
||||
if (!dialogId) return;
|
||||
const dialog = document.getElementById(dialogId);
|
||||
if (dialog instanceof HTMLDialogElement) {
|
||||
dialog.showModal();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll("dialog").forEach((dialog) => {
|
||||
dialog.addEventListener("click", (event) => {
|
||||
if (event.target === dialog && dialog instanceof HTMLDialogElement) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Escape") return;
|
||||
document.querySelectorAll("dialog[open]").forEach((dialog) => {
|
||||
if (dialog instanceof HTMLDialogElement) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initPostFormScrollMemory();
|
||||
initMobileSheet();
|
||||
initFilterInputs();
|
||||
initSelectedPreviews();
|
||||
initIosPullToRefresh();
|
||||
initDialogs();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = "nouri-v1-0-0";
|
||||
const CACHE_NAME = "nouri-v1-3-2";
|
||||
const OFFLINE_URL = "/static/pwa/offline.html";
|
||||
const STATIC_ASSETS = [
|
||||
"/static/css/styles.css",
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
<input type="password" name="password_repeat" autocomplete="new-password" {% if not user %}required{% endif %}>
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Archiv</p>
|
||||
<h1>Frühere Ideen bleiben greifbar</h1>
|
||||
<p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder einplanen oder einkaufen.</p>
|
||||
<p class="lead">Archiv bedeutet bewusst ausgeblendet, nicht verbraucht. Von hier aus lassen sich Dinge jederzeit wieder aktivieren.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -93,13 +93,15 @@
|
||||
<div class="item-actions">
|
||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button type="submit">Wieder einkaufen</button>
|
||||
<button type="submit">Auf Einkaufsliste</button>
|
||||
</form>
|
||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
||||
{% if item.can_edit %}
|
||||
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
||||
{% endif %}
|
||||
{% if item.can_edit %}
|
||||
<form method="post" action="{{ url_for('main.item_restore', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Zur aktiven Liste</button>
|
||||
<button class="ghost-button" type="submit">Wieder aktivieren</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,10 @@
|
||||
E-Mail
|
||||
<input type="email" name="email" value="{{ g.user.email or '' }}" autocomplete="email">
|
||||
</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>
|
||||
</article>
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<span class="chip">{{ entry.for_label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if entry.availability_state == 'home' %}
|
||||
{% if entry.is_home %}
|
||||
<span class="status-pill status-home">zuhause</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div>
|
||||
<p class="eyebrow">Zuhause</p>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
@@ -124,17 +124,27 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
||||
{% if item.can_edit %}
|
||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
||||
{% if item.kind == 'meal' %}
|
||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
||||
{% else %}
|
||||
{% if item.can_edit %}
|
||||
<form method="post" action="{{ url_for('main.item_set_not_home', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="secondary" type="submit">Nicht mehr da</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Verbraucht / nicht mehr da</button>
|
||||
<button type="submit">Auf Einkaufsliste</button>
|
||||
</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 %}
|
||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button type="submit">Erneut einkaufen</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
||||
@@ -205,11 +205,26 @@
|
||||
<fieldset>
|
||||
<legend>Bestandteile der Mahlzeitenidee</legend>
|
||||
<p class="muted">Du kannst eine Mahlzeitenidee frei benennen oder aus sichtbaren Lebensmitteln zusammensetzen. Nouri nutzt dabei später Grundtyp, Rolle und Tageszeit der Lebensmittel für ruhigere Vorschläge.</p>
|
||||
{% if selected_components %}
|
||||
<div class="selected-component-stack">
|
||||
<p class="helper-text">Schon ausgewählt</p>
|
||||
<div class="selected-components-grid">
|
||||
{% for component in selected_components %}
|
||||
<div class="meal-component-search">
|
||||
<label class="wide">
|
||||
Lebensmittel suchen
|
||||
<input
|
||||
type="text"
|
||||
name="food_search"
|
||||
value="{{ form_data.food_search }}"
|
||||
placeholder="z. B. Reis, Banane, Joghurt"
|
||||
data-filter-input
|
||||
data-filter-target="#meal-components-list"
|
||||
data-filter-limit="8"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div class="selected-component-stack is-live" data-selected-preview="#meal-components-list">
|
||||
<p class="helper-text">Ausgewählt</p>
|
||||
<p class="helper-text" data-selected-preview-empty>Noch nichts ausgewählt.</p>
|
||||
<div class="selected-components-grid">
|
||||
{% for group in food_groups %}
|
||||
{% for component in group["items"] %}
|
||||
{% set component_icon_class = {
|
||||
'protein': 'icon-component-protein',
|
||||
'carb': 'icon-component-carb',
|
||||
@@ -220,9 +235,11 @@
|
||||
'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">
|
||||
<input type="hidden" name="component_ids" value="{{ component.id }}">
|
||||
<button class="selected-component-remove" type="submit" name="remove_component_id" value="{{ component.id }}">
|
||||
<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>
|
||||
@@ -242,41 +259,60 @@
|
||||
</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 %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="inline-form">
|
||||
<label class="wide">
|
||||
Lebensmittel suchen
|
||||
<input
|
||||
type="text"
|
||||
name="food_search"
|
||||
value="{{ form_data.food_search }}"
|
||||
placeholder="z. B. Reis, Banane, Joghurt"
|
||||
data-filter-input
|
||||
data-filter-target="#meal-components-list"
|
||||
data-filter-limit="3"
|
||||
>
|
||||
</label>
|
||||
<button class="secondary" type="submit" name="form_action" value="filter_foods">Suchen</button>
|
||||
</div>
|
||||
<p class="helper-text">Während der Suche zeigt Nouri nur die drei passendsten Lebensmittel, damit die Auswahl ruhig bleibt.</p>
|
||||
<p class="helper-text">Während der Suche zeigt Nouri die passendsten Lebensmittel. Nicht vorrätige Lebensmittel sind mit Einkaufswagen markiert.</p>
|
||||
{% if food_groups %}
|
||||
<div class="stack-sections" id="meal-components-list">
|
||||
<div class="meal-component-results" id="meal-components-list">
|
||||
{% for group in food_groups %}
|
||||
<div class="component-group">
|
||||
<div class="panel-head">
|
||||
<h3>{{ group["title"] }}</h3>
|
||||
<span>{{ group["items"]|length }} Einträge</span>
|
||||
</div>
|
||||
<div class="checkbox-grid filterable-checkbox-group" data-filter-group>
|
||||
<div class="meal-component-option-grid" data-filter-group>
|
||||
{% for food in group["items"] %}
|
||||
<label class="check-option" data-filter-label="{{ food.name|lower }} {{ food.category|default('', true)|lower }} {{ food.base_type_label|lower }} {{ food.suggestion_role_label|lower }}">
|
||||
{% 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 %}>
|
||||
<span>{{ food.name }} · {{ food.base_type_label }} · {{ food.visibility_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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -351,7 +387,8 @@
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
+106
-69
@@ -5,9 +5,18 @@
|
||||
<div>
|
||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||
<h1>{{ item_kind_labels[kind] }}</h1>
|
||||
<p class="lead">Gemeinsame und persönliche Ideen bleiben hier ruhig sortiert, mit einem klaren Blick darauf, für wen etwas gedacht ist.</p>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</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>
|
||||
</div>
|
||||
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
|
||||
</section>
|
||||
|
||||
<section class="panel compact-form-panel">
|
||||
@@ -51,58 +60,92 @@
|
||||
{% if items %}
|
||||
<section class="card-grid">
|
||||
{% for item in items %}
|
||||
<article class="item-card">
|
||||
<div class="item-media">
|
||||
{% 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-tile">{{ item.name[:1] }}</div>
|
||||
{% if item.kind == 'food' %}
|
||||
{% 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>
|
||||
<div class="item-body">
|
||||
<div class="item-topline">
|
||||
<h2>{{ item.name }}</h2>
|
||||
<span class="status-pill status-{{ item.availability_state }}">{{ availability_labels[item.availability_state] }}</span>
|
||||
|
||||
<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>
|
||||
{% if item.kind == 'food' %}
|
||||
|
||||
<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>
|
||||
<details class="item-meta-disclosure">
|
||||
<summary>Mehr zeigen</summary>
|
||||
<div class="item-meta-panel">
|
||||
<div class="chip-row">
|
||||
<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 %}
|
||||
<span class="chip">{{ item_kind_labels[item.kind] }}</span>
|
||||
</div>
|
||||
{% if item.dayparts %}
|
||||
<div class="chip-row">
|
||||
{% for daypart in item.dayparts %}
|
||||
<span class="chip">{{ daypart }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.note %}
|
||||
<p>{{ item.note }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
{% if item.note %}
|
||||
<p>{{ item.note }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% else %}
|
||||
<article class="item-card">
|
||||
<div class="item-media">
|
||||
{% 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-tile">{{ item.name[:1] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="item-body">
|
||||
<div class="chip-row">
|
||||
<span class="chip">{{ item.visibility_label }}</span>
|
||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||
@@ -115,7 +158,6 @@
|
||||
<span class="chip">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if item.kind != 'food' and item.dayparts %}
|
||||
<div class="chip-row">
|
||||
{% for daypart in item.dayparts %}
|
||||
@@ -129,30 +171,25 @@
|
||||
{% if item.kind != 'food' and item.note %}
|
||||
<p>{{ item.note }}</p>
|
||||
{% endif %}
|
||||
</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) }}">
|
||||
</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 class="primary-action" method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="secondary" type="submit">Als Zuhause markieren</button>
|
||||
<button type="submit">Auf Einkaufsliste</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>
|
||||
{% if item.can_edit %}
|
||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Archivieren</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% 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 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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -155,7 +155,8 @@
|
||||
</fieldset>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -71,7 +71,8 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -127,7 +127,7 @@
|
||||
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||
<button class="quick-add-button compact-button" type="submit">
|
||||
<span>{{ item.name }}</span>
|
||||
{% if item.availability_state == 'home' %}<small>zuhause vorhanden</small>{% endif %}
|
||||
{% if item.is_home %}<small>zuhause vorhanden</small>{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
@@ -190,7 +190,7 @@
|
||||
<span>{{ item.name }}</span>
|
||||
<small>
|
||||
{{ item_kind_labels[item.kind] }}
|
||||
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
||||
{% if item.is_home %} · zuhause{% endif %}
|
||||
</small>
|
||||
</button>
|
||||
</form>
|
||||
@@ -205,7 +205,7 @@
|
||||
<div class="planner-entry-top">
|
||||
<div>
|
||||
<strong>{{ entry.item_name }}</strong>
|
||||
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
|
||||
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.is_home %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
|
||||
<div class="chip-row">
|
||||
<span class="chip">{{ entry.visibility_label }}</span>
|
||||
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||
<button class="quick-add-button compact-button" type="submit">
|
||||
<span>{{ item.name }}</span>
|
||||
{% if item.availability_state == 'home' %}<small>Zuhause vorhanden</small>{% endif %}
|
||||
{% if item.is_home %}<small>Zuhause vorhanden</small>{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
<input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}">
|
||||
</label>
|
||||
<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>
|
||||
</form>
|
||||
</article>
|
||||
@@ -151,7 +152,8 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -10,18 +10,71 @@
|
||||
</section>
|
||||
|
||||
<section class="panel compact-form-panel">
|
||||
<form method="post" class="inline-form">
|
||||
<form method="post" class="shopping-add-form">
|
||||
{{ csrf_input() }}
|
||||
<select name="item_id">
|
||||
<option value="">Bestehenden Eintrag hinzufügen</option>
|
||||
<label>
|
||||
Lebensmittel suchen
|
||||
<input
|
||||
type="text"
|
||||
name="item_search"
|
||||
placeholder="Nach Lebensmitteln 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 %}
|
||||
<option value="{{ item.id }}">
|
||||
{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}
|
||||
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
||||
</option>
|
||||
{% 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') %}
|
||||
<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 }}"
|
||||
>
|
||||
<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>{{ item.availability_label }}</small>
|
||||
</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Auf Liste setzen</button>
|
||||
</div>
|
||||
<button type="submit">Auf die Liste</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -34,35 +87,139 @@
|
||||
</section>
|
||||
<section class="stack-list">
|
||||
{% for entry in entries %}
|
||||
<article class="list-row stacked-mobile roomy-row">
|
||||
<div>
|
||||
<strong>{{ entry.item_name }}</strong>
|
||||
<p class="muted">{{ item_kind_labels[entry.item_kind] }}</p>
|
||||
<div class="chip-row">
|
||||
<span class="chip">{{ entry.visibility_label }}</span>
|
||||
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||
<span class="chip">{{ entry.for_label }}</span>
|
||||
{% if entry.needed_for_label %}
|
||||
<span class="chip status-home">
|
||||
Für {{ entry.needed_for_label }}
|
||||
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% set entry_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(entry.primary_builder_key or entry.base_type, 'icon-component-neutral') %}
|
||||
<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>
|
||||
{% endif %}
|
||||
</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>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button type="submit">Eingekauft</button>
|
||||
</form>
|
||||
{% if entry.can_edit %}
|
||||
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}">
|
||||
<div class="shopping-entry-actions">
|
||||
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Entfernen</button>
|
||||
<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.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 %}
|
||||
<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) }}">
|
||||
{{ csrf_input() }}
|
||||
<button type="submit">Eingekauft</button>
|
||||
</form>
|
||||
{% 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) }}">
|
||||
{{ csrf_input() }}
|
||||
<button class="ghost-button" type="submit">Von Einkaufsliste nehmen</button>
|
||||
</form>
|
||||
{% if 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>
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
|
||||
Reference in New Issue
Block a user