Compare commits
5 Commits
c5dea16c53
...
V.1.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e9beb1d98 | |||
| 06be1371d3 | |||
| 85c72879cb | |||
| 216dde1414 | |||
| 6f6269c66d |
@@ -4,8 +4,8 @@
|
|||||||
"author": "Florian Heinz",
|
"author": "Florian Heinz",
|
||||||
"description": "Private Flask app for meals, shopping and gentle food planning",
|
"description": "Private Flask app for meals, shopping and gentle food planning",
|
||||||
"tagline": "einfach essen planen",
|
"tagline": "einfach essen planen",
|
||||||
"version": "1.2.2",
|
"version": "1.3.0",
|
||||||
"upstreamVersion": "1.2.2",
|
"upstreamVersion": "1.3.0",
|
||||||
"healthCheckPath": "/",
|
"healthCheckPath": "/",
|
||||||
"httpPort": 8000,
|
"httpPort": 8000,
|
||||||
"manifestVersion": 2,
|
"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.
|
||||||
@@ -93,7 +93,7 @@ def load_app_version(root_dir: Path) -> str:
|
|||||||
).strip()
|
).strip()
|
||||||
if manifest_version:
|
if manifest_version:
|
||||||
return manifest_version
|
return manifest_version
|
||||||
return "1.2.2"
|
return "1.3.0"
|
||||||
|
|
||||||
|
|
||||||
def load_release_url() -> str:
|
def load_release_url() -> str:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
|
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
from .auth import admin_required, can_remove_last_admin, validate_admin_user_form
|
from .auth import admin_required, can_remove_last_admin, url_with_scroll_position, validate_admin_user_form, wants_to_stay_on_form
|
||||||
from .constants import BUILDER_DESCRIPTIONS, BUILDER_OPTIONS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS, ROLE_LABELS
|
from .constants import BUILDER_DESCRIPTIONS, BUILDER_OPTIONS, DEFAULT_CATEGORIES, DEFAULT_CATEGORY_BUILDERS, ROLE_LABELS
|
||||||
from .db import get_db
|
from .db import get_db
|
||||||
|
|
||||||
@@ -104,6 +104,19 @@ def user_create():
|
|||||||
)
|
)
|
||||||
database.commit()
|
database.commit()
|
||||||
flash("Der Nutzer wurde angelegt.", "success")
|
flash("Der Nutzer wurde angelegt.", "success")
|
||||||
|
if wants_to_stay_on_form():
|
||||||
|
new_user = database.execute(
|
||||||
|
"""
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
WHERE household_id = ? AND username = ?
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(g.user["household_id"], form_data["username"]),
|
||||||
|
).fetchone()
|
||||||
|
if new_user is not None:
|
||||||
|
return redirect(url_with_scroll_position(url_for("admin.user_edit", user_id=int(new_user["id"]))))
|
||||||
return redirect(url_for("admin.user_list"))
|
return redirect(url_for("admin.user_list"))
|
||||||
|
|
||||||
flash(error, "error")
|
flash(error, "error")
|
||||||
@@ -185,6 +198,8 @@ def user_edit(user_id: int):
|
|||||||
)
|
)
|
||||||
database.commit()
|
database.commit()
|
||||||
flash("Der Nutzer wurde aktualisiert.", "success")
|
flash("Der Nutzer wurde aktualisiert.", "success")
|
||||||
|
if wants_to_stay_on_form():
|
||||||
|
return redirect(url_with_scroll_position(url_for("admin.user_edit", user_id=user_id)))
|
||||||
return redirect(url_for("admin.user_list"))
|
return redirect(url_for("admin.user_list"))
|
||||||
|
|
||||||
flash(error, "error")
|
flash(error, "error")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import functools
|
import functools
|
||||||
import secrets
|
import secrets
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -25,23 +26,23 @@ auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
|||||||
|
|
||||||
def login_required(view):
|
def login_required(view):
|
||||||
@functools.wraps(view)
|
@functools.wraps(view)
|
||||||
def wrapped_view(**kwargs):
|
def wrapped_view(*args, **kwargs):
|
||||||
if g.user is None:
|
if g.user is None:
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
return view(**kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
return wrapped_view
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
def admin_required(view):
|
def admin_required(view):
|
||||||
@functools.wraps(view)
|
@functools.wraps(view)
|
||||||
def wrapped_view(**kwargs):
|
def wrapped_view(*args, **kwargs):
|
||||||
if g.user is None:
|
if g.user is None:
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
if g.user["role"] != "admin":
|
if g.user["role"] != "admin":
|
||||||
flash("Dieser Bereich ist für Admins gedacht.", "error")
|
flash("Dieser Bereich ist für Admins gedacht.", "error")
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
return view(**kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
return wrapped_view
|
return wrapped_view
|
||||||
|
|
||||||
@@ -53,6 +54,25 @@ def ensure_csrf_token() -> str:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def wants_to_stay_on_form() -> bool:
|
||||||
|
return request.form.get("save_mode", "").strip() == "stay"
|
||||||
|
|
||||||
|
|
||||||
|
def url_with_scroll_position(url: str) -> str:
|
||||||
|
raw_scroll = request.form.get("_scroll", "").strip()
|
||||||
|
if not raw_scroll:
|
||||||
|
return url
|
||||||
|
try:
|
||||||
|
scroll_value = max(0, int(float(raw_scroll)))
|
||||||
|
except ValueError:
|
||||||
|
return url
|
||||||
|
|
||||||
|
parts = urlsplit(url)
|
||||||
|
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||||
|
query["_scroll"] = str(scroll_value)
|
||||||
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
||||||
|
|
||||||
|
|
||||||
def normalize_login_value(raw: str) -> str:
|
def normalize_login_value(raw: str) -> str:
|
||||||
return raw.strip().lower()
|
return raw.strip().lower()
|
||||||
|
|
||||||
@@ -231,7 +251,9 @@ def profile():
|
|||||||
)
|
)
|
||||||
database.commit()
|
database.commit()
|
||||||
flash("Dein Profil wurde aktualisiert.", "success")
|
flash("Dein Profil wurde aktualisiert.", "success")
|
||||||
return redirect(url_for("auth.profile"))
|
if wants_to_stay_on_form():
|
||||||
|
return redirect(url_with_scroll_position(url_for("auth.profile")))
|
||||||
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|
||||||
flash(error, "error")
|
flash(error, "error")
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ DAYPART_SLUG_TO_MEAL_TYPE = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_CATEGORIES = [
|
DEFAULT_CATEGORIES = [
|
||||||
|
"Unsortiert",
|
||||||
"Kohlenhydrate",
|
"Kohlenhydrate",
|
||||||
"Milchprodukt",
|
"Milchprodukt",
|
||||||
"Obst",
|
"Obst",
|
||||||
@@ -30,6 +31,7 @@ DEFAULT_CATEGORIES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
DEFAULT_CATEGORY_BUILDERS = {
|
DEFAULT_CATEGORY_BUILDERS = {
|
||||||
|
"Unsortiert": "neutral",
|
||||||
"Kohlenhydrate": "carb",
|
"Kohlenhydrate": "carb",
|
||||||
"Brot & Getreide": "carb",
|
"Brot & Getreide": "carb",
|
||||||
"Milchprodukt": "dairy",
|
"Milchprodukt": "dairy",
|
||||||
@@ -226,9 +228,10 @@ ITEM_KIND_SINGULAR_LABELS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
AVAILABILITY_LABELS = {
|
AVAILABILITY_LABELS = {
|
||||||
"idea": "Merkliste",
|
"idea": "Gerade nicht da",
|
||||||
"home": "Zuhause",
|
"home": "Zuhause",
|
||||||
"archived": "Archiv",
|
"unsorted": "Unsortiert",
|
||||||
|
"archived": "Archiviert",
|
||||||
}
|
}
|
||||||
|
|
||||||
ROLE_LABELS = {
|
ROLE_LABELS = {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from .constants import (
|
|||||||
DEFAULT_CATEGORY_BUILDERS,
|
DEFAULT_CATEGORY_BUILDERS,
|
||||||
)
|
)
|
||||||
|
|
||||||
CURRENT_SCHEMA_VERSION = "1.2.2"
|
CURRENT_SCHEMA_VERSION = "1.3.0"
|
||||||
|
|
||||||
ANIMAL_HINTS = (
|
ANIMAL_HINTS = (
|
||||||
"huhn",
|
"huhn",
|
||||||
@@ -344,6 +344,19 @@ def migrate_food_flavor_profiles(database: sqlite3.Connection) -> None:
|
|||||||
set_meta(database, "food_flavor_profiles_migrated", "1")
|
set_meta(database, "food_flavor_profiles_migrated", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_item_archive_state(database: sqlite3.Connection) -> None:
|
||||||
|
if get_meta(database, "item_archive_state_migrated") == "1":
|
||||||
|
return
|
||||||
|
|
||||||
|
if "is_archived" not in table_columns(database, "items"):
|
||||||
|
return
|
||||||
|
|
||||||
|
database.execute("UPDATE items SET is_archived = 0 WHERE is_archived IS NULL")
|
||||||
|
database.execute("UPDATE items SET is_archived = 1 WHERE availability_state = 'archived'")
|
||||||
|
database.execute("UPDATE items SET availability_state = 'idea' WHERE availability_state = 'archived'")
|
||||||
|
set_meta(database, "item_archive_state_migrated", "1")
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> sqlite3.Connection:
|
def get_db() -> sqlite3.Connection:
|
||||||
if "db" not in g:
|
if "db" not in g:
|
||||||
g.db = sqlite3.connect(
|
g.db = sqlite3.connect(
|
||||||
@@ -468,6 +481,7 @@ def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
|||||||
add_column_if_missing(database, "items", "meal_type TEXT")
|
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", "meal_tags TEXT NOT NULL DEFAULT ''")
|
||||||
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "is_archived INTEGER NOT NULL DEFAULT 0")
|
||||||
|
|
||||||
if table_exists(database, "shopping_entries"):
|
if table_exists(database, "shopping_entries"):
|
||||||
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
|
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
|
||||||
@@ -722,6 +736,8 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
add_column_if_missing(database, "items", "meal_type TEXT")
|
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", "meal_tags TEXT NOT NULL DEFAULT ''")
|
||||||
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
add_column_if_missing(database, "items", "energy_density TEXT NOT NULL DEFAULT 'neutral'")
|
||||||
|
add_column_if_missing(database, "items", "is_archived INTEGER NOT NULL DEFAULT 0")
|
||||||
|
add_column_if_missing(database, "items", "is_quick_added INTEGER NOT NULL DEFAULT 0")
|
||||||
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
|
add_column_if_missing(database, "shopping_entries", "needed_for_date TEXT")
|
||||||
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
|
add_column_if_missing(database, "shopping_entries", "needed_for_daypart_id INTEGER")
|
||||||
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
add_column_if_missing(database, "user_settings", "suggestion_style TEXT NOT NULL DEFAULT 'balanced'")
|
||||||
@@ -771,6 +787,7 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
sync_default_categories(database)
|
sync_default_categories(database)
|
||||||
migrate_item_profiles(database)
|
migrate_item_profiles(database)
|
||||||
migrate_food_flavor_profiles(database)
|
migrate_food_flavor_profiles(database)
|
||||||
|
migrate_item_archive_state(database)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO user_settings (user_id)
|
INSERT OR IGNORE INTO user_settings (user_id)
|
||||||
@@ -784,6 +801,8 @@ 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 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 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 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 user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''")
|
database.execute("UPDATE user_settings SET suggestion_style = 'balanced' WHERE suggestion_style IS NULL OR suggestion_style = ''")
|
||||||
database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''")
|
database.execute("UPDATE user_settings SET energy_preference = 'neutral' WHERE energy_preference IS NULL OR energy_preference = ''")
|
||||||
database.execute("UPDATE user_settings SET protein_preference = 'mixed' WHERE protein_preference IS NULL OR protein_preference = ''")
|
database.execute("UPDATE user_settings SET protein_preference = 'mixed' WHERE protein_preference IS NULL OR protein_preference = ''")
|
||||||
@@ -805,6 +824,12 @@ def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
|||||||
ON items (household_id, visibility, availability_state)
|
ON items (household_id, visibility, availability_state)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility_archived
|
||||||
|
ON items (household_id, visibility, is_archived, availability_state)
|
||||||
|
"""
|
||||||
|
)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_target_user
|
CREATE INDEX IF NOT EXISTS idx_items_target_user
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from flask import (
|
|||||||
url_for,
|
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 .backup import RESTORE_CONFIRMATION_TEXT, export_backup_archive, restore_backup_archive
|
||||||
from .constants import (
|
from .constants import (
|
||||||
AVAILABILITY_LABELS,
|
AVAILABILITY_LABELS,
|
||||||
@@ -71,7 +71,8 @@ main_bp = Blueprint("main", __name__)
|
|||||||
ACTIVE_STATE_OPTIONS = [
|
ACTIVE_STATE_OPTIONS = [
|
||||||
("", "Alle aktiven"),
|
("", "Alle aktiven"),
|
||||||
("home", "Zuhause"),
|
("home", "Zuhause"),
|
||||||
("idea", "Merkliste"),
|
("idea", "Gerade nicht da"),
|
||||||
|
("unsorted", "Unsortiert"),
|
||||||
]
|
]
|
||||||
KIND_FILTER_OPTIONS = [
|
KIND_FILTER_OPTIONS = [
|
||||||
("", "Alles"),
|
("", "Alles"),
|
||||||
@@ -465,6 +466,16 @@ def describe_record(entry: dict) -> dict:
|
|||||||
entry["for_label"] = "Für mich" if entry["is_mine"] else f"Für {owner_name}"
|
entry["for_label"] = "Für mich" if entry["is_mine"] else f"Für {owner_name}"
|
||||||
else:
|
else:
|
||||||
entry["for_label"] = "Für alle"
|
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"
|
entry["can_edit"] = entry["is_shared"] or entry["is_mine"] or g.user["role"] == "admin"
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
@@ -798,6 +809,7 @@ def fetch_items(
|
|||||||
kind: str | None = None,
|
kind: str | None = None,
|
||||||
availability: str | None = None,
|
availability: str | None = None,
|
||||||
include_archived: bool = False,
|
include_archived: bool = False,
|
||||||
|
include_quick_added: bool = False,
|
||||||
query: str | None = None,
|
query: str | None = None,
|
||||||
daypart_id: int | None = None,
|
daypart_id: int | None = None,
|
||||||
visibility: str | None = None,
|
visibility: str | None = None,
|
||||||
@@ -809,10 +821,19 @@ def fetch_items(
|
|||||||
conditions.append("items.kind = ?")
|
conditions.append("items.kind = ?")
|
||||||
params.append(kind)
|
params.append(kind)
|
||||||
if availability:
|
if availability:
|
||||||
conditions.append("items.availability_state = ?")
|
if availability == "archived":
|
||||||
params.append(availability)
|
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:
|
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:
|
if query:
|
||||||
conditions.append("LOWER(items.name) LIKE ?")
|
conditions.append("LOWER(items.name) LIKE ?")
|
||||||
params.append(f"%{query.lower()}%")
|
params.append(f"%{query.lower()}%")
|
||||||
@@ -849,7 +870,11 @@ def fetch_items(
|
|||||||
LEFT JOIN users AS target ON target.id = items.target_user_id
|
LEFT JOIN users AS target ON target.id = items.target_user_id
|
||||||
WHERE {' AND '.join(conditions)}
|
WHERE {' AND '.join(conditions)}
|
||||||
ORDER BY
|
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,
|
CASE items.visibility WHEN 'shared' THEN 0 ELSE 1 END,
|
||||||
LOWER(items.name)
|
LOWER(items.name)
|
||||||
""",
|
""",
|
||||||
@@ -859,13 +884,14 @@ def fetch_items(
|
|||||||
|
|
||||||
|
|
||||||
def fetch_food_options(query: str | None = None):
|
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]:
|
def group_items_by_availability(items: list[dict]) -> list[dict]:
|
||||||
grouped = defaultdict(list)
|
grouped = defaultdict(list)
|
||||||
for item in items:
|
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 = []
|
result = []
|
||||||
for state in ("home", "idea", "archived"):
|
for state in ("home", "idea", "archived"):
|
||||||
@@ -1108,6 +1134,7 @@ def fetch_meal_missing_components(meal_id: int) -> list[dict]:
|
|||||||
WHERE meal_components.meal_item_id = ?
|
WHERE meal_components.meal_item_id = ?
|
||||||
AND items.household_id = ?
|
AND items.household_id = ?
|
||||||
AND (items.visibility = 'shared' OR items.owner_user_id = ?)
|
AND (items.visibility = 'shared' OR items.owner_user_id = ?)
|
||||||
|
AND items.is_archived = 0
|
||||||
AND items.availability_state != 'home'
|
AND items.availability_state != 'home'
|
||||||
ORDER BY LOWER(items.name)
|
ORDER BY LOWER(items.name)
|
||||||
""",
|
""",
|
||||||
@@ -1169,16 +1196,6 @@ def ensure_item_or_missing_components_are_shopped(
|
|||||||
"used_components": True,
|
"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)):
|
if needed_for_date and not should_activate_shopping_need(parse_plan_date(needed_for_date)):
|
||||||
schedule_shopping_need(
|
schedule_shopping_need(
|
||||||
item_id=item_id,
|
item_id=item_id,
|
||||||
@@ -1279,8 +1296,10 @@ def fetch_shopping_entries():
|
|||||||
SELECT shopping_entries.*,
|
SELECT shopping_entries.*,
|
||||||
items.name AS item_name,
|
items.name AS item_name,
|
||||||
items.kind AS item_kind,
|
items.kind AS item_kind,
|
||||||
|
items.base_type,
|
||||||
items.photo_filename,
|
items.photo_filename,
|
||||||
items.availability_state,
|
items.availability_state,
|
||||||
|
items.is_archived,
|
||||||
owner.display_name AS owner_display_name,
|
owner.display_name AS owner_display_name,
|
||||||
owner.username AS owner_username,
|
owner.username AS owner_username,
|
||||||
target.display_name AS target_display_name,
|
target.display_name AS target_display_name,
|
||||||
@@ -1322,6 +1341,7 @@ def activate_due_shopping_needs(today: date | None = None) -> int:
|
|||||||
WHERE shopping_needs.household_id = ?
|
WHERE shopping_needs.household_id = ?
|
||||||
AND shopping_needs.is_activated = 0
|
AND shopping_needs.is_activated = 0
|
||||||
AND shopping_needs.activation_date <= ?
|
AND shopping_needs.activation_date <= ?
|
||||||
|
AND items.is_archived = 0
|
||||||
ORDER BY shopping_needs.activation_date, shopping_needs.needed_for_date
|
ORDER BY shopping_needs.activation_date, shopping_needs.needed_for_date
|
||||||
""",
|
""",
|
||||||
(current_household_id(), today.isoformat()),
|
(current_household_id(), today.isoformat()),
|
||||||
@@ -1353,6 +1373,7 @@ def fetch_upcoming_shopping_needs(limit: int | None = None) -> list[dict]:
|
|||||||
items.kind AS item_kind,
|
items.kind AS item_kind,
|
||||||
items.photo_filename,
|
items.photo_filename,
|
||||||
items.availability_state,
|
items.availability_state,
|
||||||
|
items.is_archived,
|
||||||
owner.display_name AS owner_display_name,
|
owner.display_name AS owner_display_name,
|
||||||
owner.username AS owner_username,
|
owner.username AS owner_username,
|
||||||
target.display_name AS target_display_name,
|
target.display_name AS target_display_name,
|
||||||
@@ -1366,6 +1387,7 @@ def fetch_upcoming_shopping_needs(limit: int | None = None) -> list[dict]:
|
|||||||
WHERE shopping_needs.household_id = ?
|
WHERE shopping_needs.household_id = ?
|
||||||
AND shopping_needs.is_activated = 0
|
AND shopping_needs.is_activated = 0
|
||||||
AND (shopping_needs.visibility = 'shared' OR shopping_needs.owner_user_id = ?)
|
AND (shopping_needs.visibility = 'shared' OR shopping_needs.owner_user_id = ?)
|
||||||
|
AND items.is_archived = 0
|
||||||
AND items.availability_state != 'home'
|
AND items.availability_state != 'home'
|
||||||
ORDER BY shopping_needs.activation_date, shopping_needs.needed_for_date, LOWER(items.name)
|
ORDER BY shopping_needs.activation_date, shopping_needs.needed_for_date, LOWER(items.name)
|
||||||
"""
|
"""
|
||||||
@@ -1393,6 +1415,7 @@ def fetch_plan_entries_for_range(start_date: date, end_date: date):
|
|||||||
items.kind AS item_kind,
|
items.kind AS item_kind,
|
||||||
items.photo_filename,
|
items.photo_filename,
|
||||||
items.availability_state,
|
items.availability_state,
|
||||||
|
items.is_archived,
|
||||||
dayparts.name AS daypart_name,
|
dayparts.name AS daypart_name,
|
||||||
dayparts.slug AS daypart_slug,
|
dayparts.slug AS daypart_slug,
|
||||||
dayparts.sort_order,
|
dayparts.sort_order,
|
||||||
@@ -1434,6 +1457,7 @@ def fetch_recent_plan_items(daypart_id: int, limit: int = 6):
|
|||||||
items.note,
|
items.note,
|
||||||
items.photo_filename,
|
items.photo_filename,
|
||||||
items.availability_state,
|
items.availability_state,
|
||||||
|
items.is_archived,
|
||||||
owner.display_name AS owner_display_name,
|
owner.display_name AS owner_display_name,
|
||||||
owner.username AS owner_username,
|
owner.username AS owner_username,
|
||||||
target.display_name AS target_display_name,
|
target.display_name AS target_display_name,
|
||||||
@@ -1453,7 +1477,7 @@ def fetch_recent_plan_items(daypart_id: int, limit: int = 6):
|
|||||||
|
|
||||||
def fetch_plan_candidates(daypart_id: int, query: str | None = None):
|
def fetch_plan_candidates(daypart_id: int, query: str | None = None):
|
||||||
params = [daypart_id, *visible_params()]
|
params = [daypart_id, *visible_params()]
|
||||||
conditions = [visible_clause("items"), "items.availability_state != 'archived'"]
|
conditions = [visible_clause("items"), "items.is_archived = 0"]
|
||||||
if query:
|
if query:
|
||||||
conditions.append("LOWER(items.name) LIKE ?")
|
conditions.append("LOWER(items.name) LIKE ?")
|
||||||
params.append(f"%{query.lower()}%")
|
params.append(f"%{query.lower()}%")
|
||||||
@@ -1482,7 +1506,12 @@ def fetch_plan_candidates(daypart_id: int, query: str | None = None):
|
|||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return decorate_items(rows)
|
items = decorate_items(rows)
|
||||||
|
return [
|
||||||
|
item
|
||||||
|
for item in items
|
||||||
|
if item["kind"] != "meal" or meal_matches_daypart(item, daypart_id)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def fetch_home_food_ids() -> set[int]:
|
def fetch_home_food_ids() -> set[int]:
|
||||||
@@ -1490,7 +1519,7 @@ def fetch_home_food_ids() -> set[int]:
|
|||||||
f"""
|
f"""
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM items
|
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(),
|
visible_params(),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -1561,6 +1590,22 @@ def item_matches_daypart(item: dict, daypart_id: int | None) -> bool:
|
|||||||
return any(int(daypart["id"]) == int(daypart_id) for daypart in dayparts_meta)
|
return any(int(daypart["id"]) == int(daypart_id) for daypart in dayparts_meta)
|
||||||
|
|
||||||
|
|
||||||
|
def meal_matches_daypart(item: dict, daypart_id: int | None) -> bool:
|
||||||
|
if item.get("kind") != "meal" or daypart_id is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
dayparts_meta = item.get("dayparts_meta") or []
|
||||||
|
if dayparts_meta:
|
||||||
|
return any(int(daypart["id"]) == int(daypart_id) for daypart in dayparts_meta)
|
||||||
|
|
||||||
|
raw_meal_type = (item.get("meal_type") or "").strip()
|
||||||
|
if not raw_meal_type:
|
||||||
|
return False
|
||||||
|
|
||||||
|
expected_meal_type = meal_type_for_daypart(daypart_id)
|
||||||
|
return normalize_meal_type(raw_meal_type, "snack") == expected_meal_type
|
||||||
|
|
||||||
|
|
||||||
def normalized_component_signature(component_ids: list[int]) -> tuple[int, ...]:
|
def normalized_component_signature(component_ids: list[int]) -> tuple[int, ...]:
|
||||||
return tuple(sorted({int(component_id) for component_id in component_ids}))
|
return tuple(sorted({int(component_id) for component_id in component_ids}))
|
||||||
|
|
||||||
@@ -1941,6 +1986,7 @@ def build_dashboard_hints(today: date) -> list[str]:
|
|||||||
JOIN item_dayparts ON item_dayparts.item_id = items.id
|
JOIN item_dayparts ON item_dayparts.item_id = items.id
|
||||||
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
|
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
|
||||||
WHERE items.availability_state = 'home'
|
WHERE items.availability_state = 'home'
|
||||||
|
AND items.is_archived = 0
|
||||||
AND dayparts.slug = 'dinner'
|
AND dayparts.slug = 'dinner'
|
||||||
AND {visible_clause('items')}
|
AND {visible_clause('items')}
|
||||||
""",
|
""",
|
||||||
@@ -1980,13 +2026,13 @@ def build_dashboard_hints(today: date) -> list[str]:
|
|||||||
def build_setup_checklist(today: date) -> list[dict]:
|
def build_setup_checklist(today: date) -> list[dict]:
|
||||||
total_items = int(
|
total_items = int(
|
||||||
get_db().execute(
|
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(),
|
visible_params(),
|
||||||
).fetchone()["count"]
|
).fetchone()["count"]
|
||||||
)
|
)
|
||||||
meal_count = int(
|
meal_count = int(
|
||||||
get_db().execute(
|
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(),
|
visible_params(),
|
||||||
).fetchone()["count"]
|
).fetchone()["count"]
|
||||||
)
|
)
|
||||||
@@ -2074,7 +2120,7 @@ def build_day_hints(selected_date: date) -> list[str]:
|
|||||||
FROM items
|
FROM items
|
||||||
JOIN item_dayparts ON item_dayparts.item_id = items.id
|
JOIN item_dayparts ON item_dayparts.item_id = items.id
|
||||||
JOIN dayparts ON dayparts.id = item_dayparts.daypart_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 dayparts.slug = 'afternoon-snack'
|
||||||
AND {visible_clause('items')}
|
AND {visible_clause('items')}
|
||||||
""",
|
""",
|
||||||
@@ -2212,12 +2258,12 @@ def build_day_planner_sections(
|
|||||||
candidates = fetch_plan_candidates(daypart["id"])
|
candidates = fetch_plan_candidates(daypart["id"])
|
||||||
entries = day_entries.get((selected_date.isoformat(), daypart["id"]), [])
|
entries = day_entries.get((selected_date.isoformat(), daypart["id"]), [])
|
||||||
meal_candidates = dedupe_items(
|
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"],
|
+ [item for item in candidates if item["kind"] == "meal"],
|
||||||
limit=6,
|
limit=6,
|
||||||
)
|
)
|
||||||
food_candidates = dedupe_items(
|
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"])
|
+ fetch_recent_plan_items(daypart["id"])
|
||||||
+ [item for item in candidates if item["kind"] == "food"],
|
+ [item for item in candidates if item["kind"] == "food"],
|
||||||
limit=20,
|
limit=20,
|
||||||
@@ -2260,7 +2306,7 @@ def build_template_day_sections(selected_map: dict[int, list[int]] | None = None
|
|||||||
for daypart in get_dayparts():
|
for daypart in get_dayparts():
|
||||||
candidates = fetch_plan_candidates(int(daypart["id"]))
|
candidates = fetch_plan_candidates(int(daypart["id"]))
|
||||||
quick_items = dedupe_items(
|
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,
|
limit=10,
|
||||||
)
|
)
|
||||||
quick_ids = {item["id"] for item in quick_items}
|
quick_ids = {item["id"] for item in quick_items}
|
||||||
@@ -2284,7 +2330,7 @@ def fetch_week_cards(week_start: date):
|
|||||||
for daypart in get_dayparts():
|
for daypart in get_dayparts():
|
||||||
candidates = fetch_plan_candidates(int(daypart["id"]))
|
candidates = fetch_plan_candidates(int(daypart["id"]))
|
||||||
meal_candidates = dedupe_items(
|
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"],
|
+ [item for item in candidates if item["kind"] == "meal"],
|
||||||
limit=4,
|
limit=4,
|
||||||
)
|
)
|
||||||
@@ -2358,6 +2404,7 @@ def fetch_plan_entries_for_range_export(start_date: date, end_date: date, *, mod
|
|||||||
items.kind AS item_kind,
|
items.kind AS item_kind,
|
||||||
items.photo_filename,
|
items.photo_filename,
|
||||||
items.availability_state,
|
items.availability_state,
|
||||||
|
items.is_archived,
|
||||||
dayparts.name AS daypart_name,
|
dayparts.name AS daypart_name,
|
||||||
dayparts.slug AS daypart_slug,
|
dayparts.slug AS daypart_slug,
|
||||||
dayparts.sort_order,
|
dayparts.sort_order,
|
||||||
@@ -2502,10 +2549,20 @@ def build_week_plan_pdf(week_start: date, *, mode: str = "mine") -> bytes:
|
|||||||
|
|
||||||
|
|
||||||
def count_visible_items(availability_state: str) -> int:
|
def count_visible_items(availability_state: str) -> int:
|
||||||
row = get_db().execute(
|
if availability_state == "archived":
|
||||||
f"SELECT COUNT(*) AS count FROM items WHERE availability_state = ? AND {visible_clause('items')}",
|
row = get_db().execute(
|
||||||
[availability_state, *visible_params()],
|
f"SELECT COUNT(*) AS count FROM items WHERE is_archived = 1 AND COALESCE(is_quick_added, 0) = 0 AND {visible_clause('items')}",
|
||||||
).fetchone()
|
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"])
|
return int(row["count"])
|
||||||
|
|
||||||
|
|
||||||
@@ -2885,12 +2942,16 @@ def sync_item_set_items(set_id: int, item_ids: list[int]) -> None:
|
|||||||
|
|
||||||
def extract_item_set_form_data(existing: dict | None = None) -> dict:
|
def extract_item_set_form_data(existing: dict | None = None) -> dict:
|
||||||
form_data = existing or {}
|
form_data = existing or {}
|
||||||
|
item_ids = [int(value) for value in request.form.getlist("item_ids") if value.isdigit()]
|
||||||
|
remove_item_id = request.form.get("remove_item_id", "").strip()
|
||||||
|
if remove_item_id.isdigit():
|
||||||
|
item_ids = [item_id for item_id in item_ids if item_id != int(remove_item_id)]
|
||||||
form_data.update(
|
form_data.update(
|
||||||
{
|
{
|
||||||
"name": request.form.get("name", "").strip(),
|
"name": request.form.get("name", "").strip(),
|
||||||
"description": request.form.get("description", "").strip(),
|
"description": request.form.get("description", "").strip(),
|
||||||
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
|
"visibility": normalize_visibility(request.form.get("visibility"), form_data.get("visibility", "shared")),
|
||||||
"item_ids": [int(value) for value in request.form.getlist("item_ids") if value.isdigit()],
|
"item_ids": item_ids,
|
||||||
"item_search": request.form.get("item_search", "").strip(),
|
"item_search": request.form.get("item_search", "").strip(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -3200,6 +3261,8 @@ def day_template_create():
|
|||||||
sync_day_template_entries(template_id, form_data["selected_map"])
|
sync_day_template_entries(template_id, form_data["selected_map"])
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash("Die Tagesvorlage wurde gespeichert.", "success")
|
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 redirect(url_for("main.template_library"))
|
||||||
return render_template(
|
return render_template(
|
||||||
"library/day_form.html",
|
"library/day_form.html",
|
||||||
@@ -3245,6 +3308,8 @@ def day_template_edit(template_id: int):
|
|||||||
sync_day_template_entries(template_id, form_data["selected_map"])
|
sync_day_template_entries(template_id, form_data["selected_map"])
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash("Die Tagesvorlage wurde aktualisiert.", "success")
|
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 redirect(url_for("main.template_library"))
|
||||||
return render_template(
|
return render_template(
|
||||||
"library/day_form.html",
|
"library/day_form.html",
|
||||||
@@ -3312,6 +3377,8 @@ def week_template_create():
|
|||||||
sync_week_template_days(template_id, selected_map)
|
sync_week_template_days(template_id, selected_map)
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash("Die Wochenvorlage wurde gespeichert.", "success")
|
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 redirect(url_for("main.template_library"))
|
||||||
return render_template(
|
return render_template(
|
||||||
"library/week_form.html",
|
"library/week_form.html",
|
||||||
@@ -3357,6 +3424,8 @@ def week_template_edit(template_id: int):
|
|||||||
sync_week_template_days(template_id, form_data["selected_map"])
|
sync_week_template_days(template_id, form_data["selected_map"])
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash("Die Wochenvorlage wurde aktualisiert.", "success")
|
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 redirect(url_for("main.template_library"))
|
||||||
return render_template(
|
return render_template(
|
||||||
"library/week_form.html",
|
"library/week_form.html",
|
||||||
@@ -3411,6 +3480,8 @@ def item_set_create():
|
|||||||
sync_item_set_items(set_id, form_data["item_ids"])
|
sync_item_set_items(set_id, form_data["item_ids"])
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash("Das Paket wurde gespeichert.", "success")
|
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"))
|
return redirect(url_for("main.template_library"))
|
||||||
items = fetch_items(include_archived=False, query=form_data["item_search"] or None)
|
items = fetch_items(include_archived=False, query=form_data["item_search"] or None)
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -3420,6 +3491,7 @@ def item_set_create():
|
|||||||
visibility_options=VISIBILITY_FORM_OPTIONS,
|
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||||
name_suggestions=ITEM_SET_NAME_SUGGESTIONS,
|
name_suggestions=ITEM_SET_NAME_SUGGESTIONS,
|
||||||
item_groups=group_items_by_availability(items),
|
item_groups=group_items_by_availability(items),
|
||||||
|
selected_items=fetch_items_by_ids(form_data["item_ids"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3461,6 +3533,8 @@ def item_set_edit(set_id: int):
|
|||||||
sync_item_set_items(set_id, form_data["item_ids"])
|
sync_item_set_items(set_id, form_data["item_ids"])
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash("Das Paket wurde aktualisiert.", "success")
|
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"))
|
return redirect(url_for("main.template_library"))
|
||||||
items = fetch_items(include_archived=False, query=form_data["item_search"] or None)
|
items = fetch_items(include_archived=False, query=form_data["item_search"] or None)
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -3470,6 +3544,7 @@ def item_set_edit(set_id: int):
|
|||||||
visibility_options=VISIBILITY_FORM_OPTIONS,
|
visibility_options=VISIBILITY_FORM_OPTIONS,
|
||||||
name_suggestions=ITEM_SET_NAME_SUGGESTIONS,
|
name_suggestions=ITEM_SET_NAME_SUGGESTIONS,
|
||||||
item_groups=group_items_by_availability(items),
|
item_groups=group_items_by_availability(items),
|
||||||
|
selected_items=fetch_items_by_ids(form_data["item_ids"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -3504,6 +3579,9 @@ def settings_view():
|
|||||||
)
|
)
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash("Die Einkaufsrhythmus-Einstellungen wurden gespeichert.", "success")
|
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":
|
elif form_name == "reminders":
|
||||||
ensure_user_settings_row()
|
ensure_user_settings_row()
|
||||||
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced")
|
suggestion_style = normalize_suggestion_style(request.form.get("suggestion_style"), "balanced")
|
||||||
@@ -3564,6 +3642,9 @@ def settings_view():
|
|||||||
)
|
)
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash("Deine Erinnerungen und Hinweise wurden gespeichert.", "success")
|
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":
|
elif form_name == "push_test":
|
||||||
subscription = get_db().execute(
|
subscription = get_db().execute(
|
||||||
"""
|
"""
|
||||||
@@ -3736,6 +3817,7 @@ def item_list(kind: str):
|
|||||||
items = fetch_items(
|
items = fetch_items(
|
||||||
kind=kind,
|
kind=kind,
|
||||||
availability=state or None,
|
availability=state or None,
|
||||||
|
include_quick_added=state == "unsorted",
|
||||||
query=query or None,
|
query=query or None,
|
||||||
daypart_id=daypart_id,
|
daypart_id=daypart_id,
|
||||||
visibility=scope or None,
|
visibility=scope or None,
|
||||||
@@ -3756,6 +3838,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"))
|
@main_bp.route("/items/<kind>/new", methods=("GET", "POST"))
|
||||||
@login_required
|
@login_required
|
||||||
def item_create(kind: str):
|
def item_create(kind: str):
|
||||||
@@ -3867,6 +4047,8 @@ def item_create(kind: str):
|
|||||||
sync_meal_components(item_id, form_data["component_ids"])
|
sync_meal_components(item_id, form_data["component_ids"])
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash(f"{ITEM_KIND_SINGULAR_LABELS[kind]} wurde angelegt.", "success")
|
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))
|
return redirect(url_for("main.item_list", kind=kind))
|
||||||
flash(error, "error")
|
flash(error, "error")
|
||||||
|
|
||||||
@@ -3998,6 +4180,8 @@ def item_edit(item_id: int):
|
|||||||
sync_meal_components(item_id, form_data["component_ids"])
|
sync_meal_components(item_id, form_data["component_ids"])
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
flash("Der Eintrag wurde aktualisiert.", "success")
|
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"]))
|
return redirect(url_for("main.item_list", kind=item["kind"]))
|
||||||
flash(error, "error")
|
flash(error, "error")
|
||||||
|
|
||||||
@@ -4013,6 +4197,13 @@ def item_add_to_shopping(item_id: int):
|
|||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
return redirect(request.referrer or url_for("main.shopping_list"))
|
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(
|
result = ensure_item_or_missing_components_are_shopped(
|
||||||
item_id,
|
item_id,
|
||||||
g.user["id"],
|
g.user["id"],
|
||||||
@@ -4041,7 +4232,15 @@ def item_set_home(item_id: int):
|
|||||||
return redirect(request.referrer or url_for("main.home_view"))
|
return redirect(request.referrer or url_for("main.home_view"))
|
||||||
|
|
||||||
get_db().execute(
|
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),
|
(g.user["id"], item_id),
|
||||||
)
|
)
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
@@ -4049,6 +4248,33 @@ def item_set_home(item_id: int):
|
|||||||
return redirect(request.referrer or url_for("main.home_view"))
|
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")
|
@main_bp.post("/items/<int:item_id>/archive")
|
||||||
@login_required
|
@login_required
|
||||||
def item_archive(item_id: int):
|
def item_archive(item_id: int):
|
||||||
@@ -4060,7 +4286,15 @@ def item_archive(item_id: int):
|
|||||||
return redirect(request.referrer or url_for("main.archive_view"))
|
return redirect(request.referrer or url_for("main.archive_view"))
|
||||||
|
|
||||||
get_db().execute(
|
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),
|
(g.user["id"], item_id),
|
||||||
)
|
)
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
@@ -4079,7 +4313,15 @@ def item_restore(item_id: int):
|
|||||||
return redirect(request.referrer or url_for("main.archive_view"))
|
return redirect(request.referrer or url_for("main.archive_view"))
|
||||||
|
|
||||||
get_db().execute(
|
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),
|
(g.user["id"], item_id),
|
||||||
)
|
)
|
||||||
get_db().commit()
|
get_db().commit()
|
||||||
@@ -4087,6 +4329,110 @@ def item_restore(item_id: int):
|
|||||||
return redirect(request.referrer or url_for("main.archive_view"))
|
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("/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"))
|
@main_bp.route("/shopping", methods=("GET", "POST"))
|
||||||
@login_required
|
@login_required
|
||||||
def shopping_list():
|
def shopping_list():
|
||||||
@@ -4110,12 +4456,12 @@ def shopping_list():
|
|||||||
flash("Dafür ist gerade nichts zusätzlich nötig.", "info")
|
flash("Dafür ist gerade nichts zusätzlich nötig.", "info")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
return redirect(url_for("main.shopping_list"))
|
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||||
|
|
||||||
entries = fetch_shopping_entries()
|
entries = fetch_shopping_entries()
|
||||||
upcoming_entries = fetch_upcoming_shopping_needs()
|
upcoming_entries = fetch_upcoming_shopping_needs()
|
||||||
addable_items = fetch_items(include_archived=False)
|
addable_items = fetch_items(include_archived=True, include_quick_added=True)
|
||||||
addable_items = [item for item in addable_items if not item["is_on_shopping_list"]]
|
addable_items = [item for item in addable_items if item["kind"] == "food" and not item["is_on_shopping_list"]]
|
||||||
household_settings = get_household_settings()
|
household_settings = get_household_settings()
|
||||||
shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt")
|
shopping_weekday_label = dict(WEEKDAY_OPTIONS).get(household_settings["shopping_weekday"], "gesetzt")
|
||||||
return render_template(
|
return render_template(
|
||||||
@@ -4131,68 +4477,25 @@ def shopping_list():
|
|||||||
@main_bp.post("/shopping/<int:entry_id>/check")
|
@main_bp.post("/shopping/<int:entry_id>/check")
|
||||||
@login_required
|
@login_required
|
||||||
def shopping_check(entry_id: int):
|
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:
|
try:
|
||||||
ensure_can_edit(entry_dict, "Diesen Einkaufseintrag kannst du gerade nicht ändern.")
|
item = mark_shopping_entry_checked(entry_id)
|
||||||
item = get_item(entry["item_id"])
|
|
||||||
except (ValueError, PermissionError) as exc:
|
except (ValueError, PermissionError) as exc:
|
||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
return redirect(url_for("main.shopping_list"))
|
return redirect(url_with_scroll_position(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()
|
|
||||||
flash(f"{item['name']} ist jetzt als Zuhause vorhanden markiert.", "success")
|
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")
|
@main_bp.post("/shopping/<int:entry_id>/remove")
|
||||||
@login_required
|
@login_required
|
||||||
def shopping_remove(entry_id: int):
|
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:
|
try:
|
||||||
ensure_can_edit(describe_record(dict(entry)), "Diesen Einkaufseintrag kannst du gerade nicht entfernen.")
|
remove_shopping_entry(entry_id)
|
||||||
except PermissionError as exc:
|
except (ValueError, PermissionError) as exc:
|
||||||
flash(str(exc), "error")
|
flash(str(exc), "error")
|
||||||
return redirect(url_for("main.shopping_list"))
|
return redirect(url_with_scroll_position(url_for("main.shopping_list")))
|
||||||
get_db().execute("DELETE FROM shopping_entries WHERE id = ?", (entry_id,))
|
|
||||||
get_db().commit()
|
|
||||||
flash("Der Eintrag wurde von der Einkaufsliste entfernt.", "info")
|
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")
|
@main_bp.get("/home")
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
note TEXT,
|
note TEXT,
|
||||||
photo_filename TEXT,
|
photo_filename TEXT,
|
||||||
availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')),
|
availability_state TEXT NOT NULL DEFAULT 'idea' CHECK (availability_state IN ('idea', 'home', 'archived')),
|
||||||
|
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_quick_added INTEGER NOT NULL DEFAULT 0,
|
||||||
created_by INTEGER,
|
created_by INTEGER,
|
||||||
updated_by INTEGER,
|
updated_by INTEGER,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -304,6 +306,9 @@ ON items (kind, name);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
||||||
ON items (household_id, visibility, availability_state);
|
ON items (household_id, visibility, availability_state);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility_archived
|
||||||
|
ON items (household_id, visibility, is_archived, availability_state);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_target_user
|
CREATE INDEX IF NOT EXISTS idx_items_target_user
|
||||||
ON items (target_user_id);
|
ON items (target_user_id);
|
||||||
|
|
||||||
|
|||||||
@@ -661,50 +661,247 @@ h3 {
|
|||||||
|
|
||||||
.selected-components-grid {
|
.selected-components-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
gap: 0.75rem;
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-component-card {
|
.selected-component-card {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
gap: 0.65rem;
|
||||||
align-items: center;
|
min-height: 138px;
|
||||||
gap: 0.85rem;
|
padding: 0.9rem 0.85rem 0.8rem;
|
||||||
padding: 0.9rem 1rem;
|
border-radius: 22px;
|
||||||
border-radius: 18px;
|
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: color-mix(in srgb, var(--accent-soft) 18%, var(--surface-strong) 82%);
|
background: color-mix(in srgb, var(--accent-soft) 10%, var(--surface-strong) 90%);
|
||||||
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-component-main {
|
.selected-component-main {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.2rem;
|
gap: 0.15rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-top: auto;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-component-main strong,
|
.selected-component-main strong {
|
||||||
.selected-component-main small {
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-component-main small {
|
.selected-component-visual {
|
||||||
color: var(--muted);
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 4.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-component-visual img,
|
||||||
|
.selected-component-fallback {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-component-visual img {
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--line) 76%, transparent 24%);
|
||||||
|
box-shadow: 0 8px 18px color-mix(in srgb, var(--accent) 10%, transparent 90%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-component-fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: color-mix(in srgb, var(--surface) 76%, #fff 24%);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--line) 76%, transparent 24%);
|
||||||
|
color: color-mix(in srgb, var(--accent-strong) 70%, var(--text) 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-component-fallback .ui-icon {
|
||||||
|
width: 1.45rem;
|
||||||
|
height: 1.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-component-remove {
|
.selected-component-remove {
|
||||||
white-space: nowrap;
|
position: absolute;
|
||||||
|
top: 0.45rem;
|
||||||
|
right: 0.45rem;
|
||||||
|
width: 1.9rem;
|
||||||
|
height: 1.9rem;
|
||||||
|
min-width: 1.9rem;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--line) 74%, transparent 26%);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-component-remove:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 40%, transparent 60%);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 40%, var(--line) 60%);
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-component-remove span[aria-hidden="true"] {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .selected-component-card {
|
[data-theme="dark"] .selected-component-card {
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
180deg,
|
180deg,
|
||||||
color-mix(in srgb, var(--surface-soft) 82%, #4d413d 18%),
|
color-mix(in srgb, var(--surface-soft) 76%, #4d413d 24%),
|
||||||
color-mix(in srgb, var(--surface) 96%, #2c2523 4%)
|
color-mix(in srgb, var(--surface) 94%, #2c2523 6%)
|
||||||
);
|
);
|
||||||
border-color: color-mix(in srgb, rgba(243, 177, 125, 0.36) 48%, var(--line) 52%);
|
border-color: color-mix(in srgb, rgba(243, 177, 125, 0.36) 48%, var(--line) 52%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .selected-component-fallback {
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--surface-soft) 72%, #4a403c 28%),
|
||||||
|
color-mix(in srgb, var(--surface-strong) 86%, #241f1d 14%)
|
||||||
|
);
|
||||||
|
border-color: color-mix(in srgb, var(--line) 54%, rgba(243, 177, 125, 0.18) 46%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .selected-component-remove {
|
||||||
|
background: rgba(32, 27, 25, 0.16);
|
||||||
|
color: color-mix(in srgb, var(--muted) 90%, white 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .selected-component-remove:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 42%, rgba(32, 27, 25, 0.58) 58%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-option-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
min-height: 138px;
|
||||||
|
padding: 0.9rem 0.85rem 0.8rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 10%, var(--surface-strong) 90%);
|
||||||
|
color: var(--muted);
|
||||||
|
align-content: start;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option-visual {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 4.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option-visual img,
|
||||||
|
.set-item-option-fallback {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option-visual img {
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--line) 76%, transparent 24%);
|
||||||
|
box-shadow: 0 8px 18px color-mix(in srgb, var(--accent) 10%, transparent 90%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option-fallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: color-mix(in srgb, var(--surface) 76%, #fff 24%);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--line) 76%, transparent 24%);
|
||||||
|
color: color-mix(in srgb, var(--accent-strong) 70%, var(--text) 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option-fallback .ui-icon {
|
||||||
|
width: 1.45rem;
|
||||||
|
height: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option-label {
|
||||||
|
margin-top: auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option:hover .set-item-option-card {
|
||||||
|
border-color: var(--accent-soft);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option input:focus-visible + .set-item-option-card {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--accent) 45%, transparent 55%);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option input:checked + .set-item-option-card {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 55%, var(--line) 45%);
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 18%, var(--surface-strong) 82%);
|
||||||
|
box-shadow: 0 12px 30px color-mix(in srgb, var(--accent) 14%, transparent 86%);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.set-item-option input:checked + .set-item-option-card .set-item-option-fallback {
|
||||||
|
background: color-mix(in srgb, var(--accent) 16%, var(--surface) 84%);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 38%, var(--line) 62%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .set-item-option-card {
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--surface-soft) 76%, #4d413d 24%),
|
||||||
|
color-mix(in srgb, var(--surface) 94%, #2c2523 6%)
|
||||||
|
);
|
||||||
|
border-color: color-mix(in srgb, rgba(243, 177, 125, 0.36) 48%, var(--line) 52%);
|
||||||
|
color: color-mix(in srgb, var(--muted) 92%, white 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .set-item-option-fallback {
|
||||||
|
background:
|
||||||
|
linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--surface-soft) 72%, #4a403c 28%),
|
||||||
|
color-mix(in srgb, var(--surface-strong) 86%, #241f1d 14%)
|
||||||
|
);
|
||||||
|
border-color: color-mix(in srgb, var(--line) 54%, rgba(243, 177, 125, 0.18) 46%);
|
||||||
|
}
|
||||||
|
|
||||||
.quick-food-grid {
|
.quick-food-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
@@ -744,11 +941,21 @@ h3 {
|
|||||||
color: #ece8e4;
|
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 {
|
.status-soft {
|
||||||
background: var(--lilac-soft);
|
background: var(--lilac-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card {
|
.item-card {
|
||||||
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 112px 1fr;
|
grid-template-columns: 112px 1fr;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@@ -782,21 +989,474 @@ h3 {
|
|||||||
color: var(--accent-strong);
|
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 {
|
.item-body {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-body p {
|
.item-body p {
|
||||||
|
margin: 0;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-meta-disclosure {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta-disclosure > summary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--line) 78%, transparent 22%);
|
||||||
|
background: color-mix(in srgb, var(--surface-soft) 38%, transparent 62%);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta-disclosure > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta-disclosure > summary:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 32%, var(--line) 68%);
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 22%, var(--surface-soft) 78%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta-disclosure[open] > summary {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 36%, var(--line) 64%);
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 26%, var(--surface-soft) 74%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.9rem 1rem 0.95rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--line) 82%, transparent 18%);
|
||||||
|
background: color-mix(in srgb, var(--surface-soft) 46%, transparent 54%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .item-meta-disclosure > summary {
|
||||||
|
background: color-mix(in srgb, var(--surface-soft) 42%, rgba(33, 28, 27, 0.58) 58%);
|
||||||
|
border-color: color-mix(in srgb, var(--line) 62%, rgba(243, 177, 125, 0.12) 38%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .item-meta-disclosure > summary:hover,
|
||||||
|
[data-theme="dark"] .item-meta-disclosure[open] > summary {
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 24%, rgba(38, 31, 29, 0.76) 76%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .item-meta-panel {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in srgb, var(--surface-soft) 60%, #463c39 40%),
|
||||||
|
color-mix(in srgb, var(--surface) 92%, #2a2422 8%)
|
||||||
|
);
|
||||||
|
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 {
|
.item-actions {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: wrap;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 0.65rem;
|
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-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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-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-entry-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-entry-open {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-entry-actions,
|
||||||
|
.shopping-entry-actions form,
|
||||||
|
.shopping-entry-actions button,
|
||||||
|
.shopping-entry-close-form {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shopping-entry-close {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[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"] .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%);
|
||||||
|
}
|
||||||
|
|
||||||
.auth-shell {
|
.auth-shell {
|
||||||
min-height: calc(100vh - 10rem);
|
min-height: calc(100vh - 10rem);
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -825,6 +1485,7 @@ h3 {
|
|||||||
.stack-form,
|
.stack-form,
|
||||||
.stack-sections,
|
.stack-sections,
|
||||||
.planner-day-stack,
|
.planner-day-stack,
|
||||||
|
.planner-day-sidebar,
|
||||||
.planner-entry-list,
|
.planner-entry-list,
|
||||||
.week-entry-stack,
|
.week-entry-stack,
|
||||||
.week-slot-stack {
|
.week-slot-stack {
|
||||||
@@ -832,6 +1493,18 @@ h3 {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planner-day-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planner-day-main,
|
||||||
|
.planner-day-sidebar {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.dashboard-spaced-panel > .panel-head + * {
|
.dashboard-spaced-panel > .panel-head + * {
|
||||||
margin-top: 0.45rem;
|
margin-top: 0.45rem;
|
||||||
}
|
}
|
||||||
@@ -2239,6 +2912,46 @@ legend {
|
|||||||
mask-image: url("../icons/fa/leaf.svg");
|
mask-image: url("../icons/fa/leaf.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-component-protein {
|
||||||
|
-webkit-mask-image: url("../icons/components/protein.svg");
|
||||||
|
mask-image: url("../icons/components/protein.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-component-carb {
|
||||||
|
-webkit-mask-image: url("../icons/components/carb.svg");
|
||||||
|
mask-image: url("../icons/components/carb.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-component-veg {
|
||||||
|
-webkit-mask-image: url("../icons/components/veg.svg");
|
||||||
|
mask-image: url("../icons/components/veg.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-component-fruit {
|
||||||
|
-webkit-mask-image: url("../icons/components/fruit.svg");
|
||||||
|
mask-image: url("../icons/components/fruit.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-component-dairy {
|
||||||
|
-webkit-mask-image: url("../icons/components/dairy.svg");
|
||||||
|
mask-image: url("../icons/components/dairy.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-component-nuts {
|
||||||
|
-webkit-mask-image: url("../icons/components/nuts.svg");
|
||||||
|
mask-image: url("../icons/components/nuts.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-component-seeds {
|
||||||
|
-webkit-mask-image: url("../icons/components/seeds.svg");
|
||||||
|
mask-image: url("../icons/components/seeds.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-component-neutral {
|
||||||
|
-webkit-mask-image: url("../icons/components/neutral.svg");
|
||||||
|
mask-image: url("../icons/components/neutral.svg");
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-sheet-backdrop {
|
.mobile-sheet-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -2321,6 +3034,7 @@ legend {
|
|||||||
|
|
||||||
.stats-grid,
|
.stats-grid,
|
||||||
.two-column,
|
.two-column,
|
||||||
|
.planner-day-layout,
|
||||||
.template-library-grid,
|
.template-library-grid,
|
||||||
.settings-grid,
|
.settings-grid,
|
||||||
.inline-form,
|
.inline-form,
|
||||||
@@ -2434,12 +3148,23 @@ legend {
|
|||||||
grid-template-columns: 1fr;
|
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,
|
.simple-list li,
|
||||||
.list-row,
|
.list-row,
|
||||||
.planner-entry-top,
|
.planner-entry-top,
|
||||||
.week-nav,
|
.week-nav,
|
||||||
.row-actions,
|
.row-actions,
|
||||||
.item-actions,
|
|
||||||
.hero-actions,
|
.hero-actions,
|
||||||
.more-actions,
|
.more-actions,
|
||||||
.filter-actions {
|
.filter-actions {
|
||||||
@@ -2449,12 +3174,19 @@ legend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.row-actions > *,
|
.row-actions > *,
|
||||||
.item-actions > *,
|
|
||||||
.hero-actions > *,
|
.hero-actions > *,
|
||||||
.more-actions > * {
|
.more-actions > * {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions > .primary-action {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.quick-add-row {
|
.quick-add-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M240 112L304 112L304 144L240 144L240 112zM256 176L304 176L304 208L240 208L240 176L256 176zM112 240L160 240L160 272L96 272L96 240L112 240zM192 495.8L192 512L448 512L448 475.6L466.3 466.9C510 446.1 540.9 402.8 543.8 352L96.2 352C99.1 402.8 130 446.2 173.7 466.9L192 475.6L192 495.8zM160 495.8C105.5 469.9 67.2 415.6 64.2 352C64.1 349.4 64 346.7 64 344L64 320L576 320L576 344C576 346.7 575.9 349.4 575.8 352C572.8 415.6 534.5 469.9 480 495.8L480 544L160 544L160 495.8zM288 240L352 240L352 272L288 272L288 240zM192 240L256 240L256 272L192 272L192 240zM160 176L208 176L208 208L144 208L144 176L160 176zM384 240L448 240L448 272L384 272L384 240zM352 176L400 176L400 208L336 208L336 176L352 176zM480 240L544 240L544 272L480 272L480 240zM448 176L496 176L496 208L432 208L432 176L448 176zM352 112L400 112L400 144L336 144L336 112L352 112z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M96 512L544 512L544 352L96 352L96 512zM116.1 320L544 320C544 217.5 463.7 133.8 362.6 128.3L116.1 320zM64 320L352 96C475.7 96 576 196.3 576 320L576 544L64 544L64 320z"/></svg>
|
||||||
|
After Width: | Height: | Size: 433 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M306.3 192L288 192L288 144C288 99.8 323.8 64 368 64L416 64L416 112C416 156.2 380.2 192 336 192L306.3 192zM368 96C341.5 96 320 117.5 320 144L320 160L336 160C362.5 160 384 138.5 384 112L384 96L368 96zM208 192L320 224L432 192C508.3 192 544 275.7 544 352C544 480 464 576 384 576L320 560L256 576C176 576 96 480 96 352C96 275.7 131.7 192 208 192zM328.8 254.8L320 257.3L311.2 254.8L203.9 224.2C181.6 225.5 164.1 237.9 150.7 260.1C136 284.4 127.9 318.3 127.9 352.1C127.9 409.6 145.9 458.7 171.5 492.9C196.4 526 226.2 542.6 252.3 544L312.1 529.1L319.9 527.2L327.7 529.1L387.5 544C413.6 542.6 443.5 526 468.3 492.9C493.9 458.7 511.9 409.6 511.9 352.1C511.9 318.3 503.9 284.4 489.1 260.1C475.7 238 458.2 225.6 435.9 224.2L328.8 254.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 991 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M267.8 146.2L260 162.3L244.9 152.7C236.1 147.1 226 144 215.1 144C187.3 144 163 165.2 157.3 195L154.8 208.4L141.2 208C140.8 208 140.5 208 140.1 208C116.8 208 96.1 228.5 96.1 256L96.1 272L64.1 272L64.1 256C64 216.6 91.5 182.5 128.9 176.8C141 139.6 174.5 112 215 112C226.4 112 237.3 114.2 247.3 118.2C263.8 95.2 290 80 320 80C350 80 376.2 95.2 392.7 118.2C402.7 114.2 413.6 112 425 112C465.5 112 499 139.6 511.1 176.8C548.5 182.5 576 216.6 576 256L576 272L544 272L544 256C544 228.5 523.3 208 500 208C499.6 208 499.3 208 498.9 208L485.3 208.4L482.8 195C477.1 165.2 452.8 144 425 144C414.2 144 404 147.2 395.2 152.7L380.1 162.3L372.3 146.2C362.2 125.5 342.2 112 320 112C297.8 112 277.9 125.5 267.8 146.2zM192 495.8L192 512L448 512L448 475.6L466.3 466.9C510 446.1 540.9 402.8 543.8 352L96.2 352C99.1 402.8 130 446.2 173.7 466.9L192 475.6L192 495.8zM160 495.8C105.5 469.9 67.2 415.6 64.2 352C64.1 349.4 64 346.7 64 344L64 320L576 320L576 344C576 346.7 575.9 349.4 575.8 352C572.8 415.6 534.5 469.9 480 495.8L480 544L160 544L160 495.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M256 256L202.8 264.9C174.8 269.6 148.5 281.6 126.7 299.8L121.6 304C85.1 334.4 64 379.5 64 426.9L64 576L205.7 576C248.1 576 288.8 559.1 318.8 529.1L331.7 516.2C355.3 492.6 370.9 462.3 376.4 429.4L384 384L424.4 377.3C460.6 371.3 494 354.1 519.9 328.1L524.5 323.5C557.5 290.5 576 245.7 576 199L576 64L440.9 64C394.2 64 349.5 82.5 316.4 115.5L311.8 120.1C285.9 146 268.7 179.4 262.6 215.6L256 256zM544 96L544 199.1C544 237.3 528.8 273.9 501.8 300.9L497.2 305.5C476 326.7 448.7 340.8 419 345.7C389.1 350.7 368.1 354.2 356.1 356.2C354.2 367.4 350.5 390.1 344.8 424.2C340.4 450.5 327.9 474.8 309.1 493.7L296.2 506.6C272.2 530.6 239.6 544.1 205.7 544.1L96 544L96 426.9C96 388.9 112.9 352.9 142.1 328.6L147.2 324.4C164.7 309.8 185.7 300.2 208.1 296.5C248.7 289.7 273.9 285.5 283.9 283.9C285.9 271.8 289.4 250.9 294.4 221C299.3 191.4 313.4 164.1 334.6 142.8L339.2 138.2C366.2 111.2 402.8 96 441 96L544 96zM211.2 480C211.2 469.4 202.6 460.8 192 460.8C181.4 460.8 172.8 469.4 172.8 480C172.8 490.6 181.4 499.2 192 499.2C202.6 499.2 211.2 490.6 211.2 480zM416 275.2C426.6 275.2 435.2 266.6 435.2 256C435.2 245.4 426.6 236.8 416 236.8C405.4 236.8 396.8 245.4 396.8 256C396.8 266.6 405.4 275.2 416 275.2zM275.2 480C275.2 469.4 266.6 460.8 256 460.8C245.4 460.8 236.8 469.4 236.8 480C236.8 490.6 245.4 499.2 256 499.2C266.6 499.2 275.2 490.6 275.2 480zM480 275.2C490.6 275.2 499.2 266.6 499.2 256C499.2 245.4 490.6 236.8 480 236.8C469.4 236.8 460.8 245.4 460.8 256C460.8 266.6 469.4 275.2 480 275.2zM275.2 416C275.2 405.4 266.6 396.8 256 396.8C245.4 396.8 236.8 405.4 236.8 416C236.8 426.6 245.4 435.2 256 435.2C266.6 435.2 275.2 426.6 275.2 416zM480 211.2C490.6 211.2 499.2 202.6 499.2 192C499.2 181.4 490.6 172.8 480 172.8C469.4 172.8 460.8 181.4 460.8 192C460.8 202.6 469.4 211.2 480 211.2z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M301.3 384L400 384C479.5 384 544 319.5 544 240C544 160.5 479.5 96 400 96C320.5 96 256 160.5 256 240L256 338.7L301.3 384zM224 345.4L224 240C224 142.8 302.8 64 400 64C497.2 64 576 142.8 576 240C576 337.2 497.2 416 400 416L294.6 416L254.1 456.5C265.2 469.2 272 485.8 272 504C272 543.8 239.8 576 200 576C162.8 576 132.2 547.8 128.4 511.6C92.2 507.8 64 477.2 64 440C64 400.2 96.2 368 136 368C154.2 368 170.8 374.8 183.5 385.9L224 345.4zM243.3 371.3C204.6 410 183.6 431 180.2 434.4L169.4 418C162.2 407.1 149.9 400 136 400C113.9 400 96 417.9 96 440C96 462.1 113.9 480 136 480L160 480L160 504C160 526.1 177.9 544 200 544C222.1 544 240 526.1 240 504C240 490 232.9 477.8 222 470.6L205.6 459.8C209 456.4 230 435.4 268.7 396.7L243.3 371.3z"/></svg>
|
||||||
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M576 64L576 112L575.7 123.5C569.9 238 478 329.9 363.5 335.7L352 336L352 576L320 576L320 384L288 384L276.5 383.7C158.1 377.7 64 279.8 64 160L64 128L128 128L139.5 128.3C219.3 132.3 287.9 178.1 324.3 244.2C344.7 141.4 435.3 64 544 64L576 64zM96 160C96 266 182 352 288 352L320 352C320 246 234 160 128 160L96 160zM544 96C438 96 352 182 352 288L352 304C458 304 544 218 544 112L544 96z"/></svg>
|
||||||
|
After Width: | Height: | Size: 646 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path fill="currentColor" d="M435.4 44.7L424.1 33.4C418.6 38.9 407 50.5 389.4 68.1C363.5 94 355.5 130.9 365.3 163.7C354.7 161.3 343.7 160 332.4 160C273.3 160 219.9 195.2 196.6 249.5L82.7 515.4C74.7 534 64.2 558.5 51.2 588.9C79.9 576.6 175.4 535.7 390.6 443.4C444.9 420.1 480.1 366.7 480.1 307.6C480.1 296.3 478.8 285.3 476.4 274.7C509.1 284.5 546.1 276.5 572 250.6C589.6 233 601.2 221.4 606.7 215.9C601.2 210.4 589.6 198.8 572 181.2C548.4 157.6 515.5 148.8 485.1 155C491.2 124.5 482.5 91.7 458.9 68.1L435.4 44.7zM458.8 203.9L458.8 203.9C483.8 178.9 524.3 178.9 549.3 203.9L561.4 216L549.3 228.1C524.3 253.1 483.8 253.1 458.8 228.1L446.7 216L458.8 203.9zM436.5 181L424.1 193.4L412 181.3C387 156.3 387 115.8 412 90.8L424.1 78.7L436.2 90.8C461.1 115.7 461.2 156 436.5 181zM332.4 192C396.3 192 448.1 243.8 448.1 307.7C448.1 354 420.5 395.8 378 414.1L284.7 454.1C284.3 453.6 283.9 453.2 283.5 452.7C258.7 427.9 244.3 413.5 240.2 409.4L217.5 432C222.9 437.4 234.7 449.2 253.1 467.6L112.1 528L226.1 262.1C227.4 259 228.9 255.9 230.5 253C267.2 289.7 286.4 308.9 288.2 310.7L310.8 288.1L251.5 228.8C250.9 228.2 250.2 227.6 249.5 227.1C270.9 205.1 300.6 192.1 332.4 192.1z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1,4 +1,62 @@
|
|||||||
(() => {
|
(() => {
|
||||||
|
const scrollStorageKey = () => `nouri-scroll:${window.location.pathname}${window.location.search}`;
|
||||||
|
|
||||||
|
const initPostFormScrollMemory = () => {
|
||||||
|
const restoreFromUrl = () => {
|
||||||
|
const currentUrl = new URL(window.location.href);
|
||||||
|
const rawScroll = currentUrl.searchParams.get("_scroll");
|
||||||
|
if (!rawScroll) return false;
|
||||||
|
|
||||||
|
const scrollValue = Number.parseInt(rawScroll, 10);
|
||||||
|
currentUrl.searchParams.delete("_scroll");
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (Number.isFinite(scrollValue)) {
|
||||||
|
window.scrollTo({ top: scrollValue, left: 0, behavior: "auto" });
|
||||||
|
}
|
||||||
|
window.history.replaceState({}, "", currentUrl.toString());
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreFromStorage = () => {
|
||||||
|
const savedScroll = sessionStorage.getItem(scrollStorageKey());
|
||||||
|
if (!savedScroll) return;
|
||||||
|
|
||||||
|
sessionStorage.removeItem(scrollStorageKey());
|
||||||
|
const scrollValue = Number.parseInt(savedScroll, 10);
|
||||||
|
if (!Number.isFinite(scrollValue)) return;
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.scrollTo({ top: scrollValue, left: 0, behavior: "auto" });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!restoreFromUrl()) {
|
||||||
|
restoreFromStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("submit", (event) => {
|
||||||
|
const form = event.target;
|
||||||
|
if (!(form instanceof HTMLFormElement)) return;
|
||||||
|
|
||||||
|
const method = (form.getAttribute("method") || "get").toLowerCase();
|
||||||
|
if (method !== "post") return;
|
||||||
|
|
||||||
|
const scrollValue = String(Math.round(window.scrollY));
|
||||||
|
sessionStorage.setItem(scrollStorageKey(), scrollValue);
|
||||||
|
|
||||||
|
let scrollInput = form.querySelector('input[name="_scroll"]');
|
||||||
|
if (!(scrollInput instanceof HTMLInputElement)) {
|
||||||
|
scrollInput = document.createElement("input");
|
||||||
|
scrollInput.type = "hidden";
|
||||||
|
scrollInput.name = "_scroll";
|
||||||
|
form.appendChild(scrollInput);
|
||||||
|
}
|
||||||
|
scrollInput.value = scrollValue;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const initMobileSheet = () => {
|
const initMobileSheet = () => {
|
||||||
const sheet = document.querySelector("[data-mobile-sheet]");
|
const sheet = document.querySelector("[data-mobile-sheet]");
|
||||||
const navStack = document.querySelector("[data-mobile-nav-stack]");
|
const navStack = document.querySelector("[data-mobile-nav-stack]");
|
||||||
@@ -153,9 +211,66 @@
|
|||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initDialogs = () => {
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const openButton = event.target.closest("[data-dialog-open]");
|
||||||
|
if (openButton instanceof HTMLElement) {
|
||||||
|
const dialogId = openButton.getAttribute("data-dialog-open");
|
||||||
|
if (!dialogId) return;
|
||||||
|
const dialog = document.getElementById(dialogId);
|
||||||
|
if (dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeButton = event.target.closest("[data-dialog-close]");
|
||||||
|
if (closeButton instanceof HTMLElement) {
|
||||||
|
const dialog = closeButton.closest("dialog");
|
||||||
|
if (dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLElement)) return;
|
||||||
|
const openButton = target.closest("[data-dialog-open]");
|
||||||
|
if (!(openButton instanceof HTMLElement)) return;
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
event.preventDefault();
|
||||||
|
const dialogId = openButton.getAttribute("data-dialog-open");
|
||||||
|
if (!dialogId) return;
|
||||||
|
const dialog = document.getElementById(dialogId);
|
||||||
|
if (dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.showModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll("dialog").forEach((dialog) => {
|
||||||
|
dialog.addEventListener("click", (event) => {
|
||||||
|
if (event.target === dialog && dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Escape") return;
|
||||||
|
document.querySelectorAll("dialog[open]").forEach((dialog) => {
|
||||||
|
if (dialog instanceof HTMLDialogElement) {
|
||||||
|
dialog.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
initPostFormScrollMemory();
|
||||||
initMobileSheet();
|
initMobileSheet();
|
||||||
initFilterInputs();
|
initFilterInputs();
|
||||||
initIosPullToRefresh();
|
initIosPullToRefresh();
|
||||||
|
initDialogs();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -45,7 +45,8 @@
|
|||||||
<input type="password" name="password_repeat" autocomplete="new-password" {% if not user %}required{% endif %}>
|
<input type="password" name="password_repeat" autocomplete="new-password" {% if not user %}required{% endif %}>
|
||||||
</label>
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit" name="save_mode" value="stay">Speichern</button>
|
||||||
|
<button class="secondary" type="submit" name="save_mode" value="close">Speichern und schließen</button>
|
||||||
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Zurück</a>
|
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Archiv</p>
|
<p class="eyebrow">Archiv</p>
|
||||||
<h1>Frühere Ideen bleiben greifbar</h1>
|
<h1>Frühere Ideen bleiben greifbar</h1>
|
||||||
<p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder einplanen oder einkaufen.</p>
|
<p class="lead">Archiv bedeutet bewusst ausgeblendet, nicht verbraucht. Von hier aus lassen sich Dinge jederzeit wieder aktivieren.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -93,13 +93,15 @@
|
|||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button type="submit">Wieder einkaufen</button>
|
<button type="submit">Auf Einkaufsliste</button>
|
||||||
</form>
|
</form>
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
{% if item.can_edit %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
||||||
|
{% endif %}
|
||||||
{% if item.can_edit %}
|
{% if item.can_edit %}
|
||||||
<form method="post" action="{{ url_for('main.item_restore', item_id=item.id) }}">
|
<form method="post" action="{{ url_for('main.item_restore', item_id=item.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Zur aktiven Liste</button>
|
<button class="ghost-button" type="submit">Wieder aktivieren</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,7 +41,10 @@
|
|||||||
E-Mail
|
E-Mail
|
||||||
<input type="email" name="email" value="{{ g.user.email or '' }}" autocomplete="email">
|
<input type="email" name="email" value="{{ g.user.email or '' }}" autocomplete="email">
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Speichern</button>
|
<div class="form-actions">
|
||||||
|
<button type="submit" name="save_mode" value="stay">Speichern</button>
|
||||||
|
<button class="secondary" type="submit" name="save_mode" value="close">Speichern und schließen</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
<span class="chip">{{ entry.for_label }}</span>
|
<span class="chip">{{ entry.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if entry.availability_state == 'home' %}
|
{% if entry.is_home %}
|
||||||
<span class="status-pill status-home">zuhause</span>
|
<span class="status-pill status-home">zuhause</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Zuhause</p>
|
<p class="eyebrow">Zuhause</p>
|
||||||
<h1>Was aktuell da ist</h1>
|
<h1>Was aktuell da ist</h1>
|
||||||
<p class="lead">Sichtbar, ruhig und nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, bleibt es später im Archiv greifbar.</p>
|
<p class="lead">Hier erscheinen aktive Lebensmittel und Mahlzeitenideen, die gerade wirklich da sind. Wenn etwas leer ist, wird es einfach als gerade nicht da markiert.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -124,17 +124,27 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<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.kind == 'meal' %}
|
||||||
{% if item.can_edit %}
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
||||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
{% 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() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Verbraucht / nicht mehr da</button>
|
<button type="submit">Auf Einkaufsliste</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if item.can_edit %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
||||||
|
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="ghost-button" type="submit">Archivieren</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -210,15 +210,39 @@
|
|||||||
<p class="helper-text">Schon ausgewählt</p>
|
<p class="helper-text">Schon ausgewählt</p>
|
||||||
<div class="selected-components-grid">
|
<div class="selected-components-grid">
|
||||||
{% for component in selected_components %}
|
{% for component in selected_components %}
|
||||||
|
{% set component_icon_class = {
|
||||||
|
'protein': 'icon-component-protein',
|
||||||
|
'carb': 'icon-component-carb',
|
||||||
|
'veg': 'icon-component-veg',
|
||||||
|
'fruit': 'icon-component-fruit',
|
||||||
|
'dairy': 'icon-component-dairy',
|
||||||
|
'nuts': 'icon-component-nuts',
|
||||||
|
'seeds': 'icon-component-seeds',
|
||||||
|
'neutral': 'icon-component-neutral',
|
||||||
|
}.get(component.primary_builder_key or component.base_type, 'icon-component-neutral') %}
|
||||||
<article class="selected-component-card">
|
<article class="selected-component-card">
|
||||||
<input type="hidden" name="component_ids" value="{{ component.id }}">
|
<input type="hidden" name="component_ids" value="{{ component.id }}">
|
||||||
|
<button class="selected-component-remove" type="submit" name="remove_component_id" value="{{ component.id }}">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
<span class="sr-only">{{ component.name }} entfernen</span>
|
||||||
|
</button>
|
||||||
|
<div class="selected-component-visual">
|
||||||
|
{% if component.photo_filename %}
|
||||||
|
<img
|
||||||
|
src="{{ image_url(component.photo_filename, 'md') }}"
|
||||||
|
srcset="{{ image_srcset(component.photo_filename) }}"
|
||||||
|
sizes="{{ image_sizes('grid') }}"
|
||||||
|
alt="{{ component.name }}"
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<span class="selected-component-fallback">
|
||||||
|
<span class="ui-icon {{ component_icon_class }}"></span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
<div class="selected-component-main">
|
<div class="selected-component-main">
|
||||||
<strong>{{ component.name }}</strong>
|
<strong>{{ component.name }}</strong>
|
||||||
<small>{{ component.base_type_label }} · {{ component.visibility_label }}</small>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="ghost-button selected-component-remove" type="submit" name="remove_component_id" value="{{ component.id }}">
|
|
||||||
Entfernen
|
|
||||||
</button>
|
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -327,7 +351,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" name="form_action" value="save_item">Speichern</button>
|
<button type="submit" name="save_mode" value="stay">Speichern</button>
|
||||||
|
<button class="secondary" type="submit" name="save_mode" value="close">Speichern und schließen</button>
|
||||||
<a class="ghost-button" href="{{ url_for('main.item_list', kind=kind) }}">Zurück</a>
|
<a class="ghost-button" href="{{ url_for('main.item_list', kind=kind) }}">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,9 +5,18 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||||
<h1>{{ item_kind_labels[kind] }}</h1>
|
<h1>{{ item_kind_labels[kind] }}</h1>
|
||||||
<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>
|
</div>
|
||||||
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel compact-form-panel">
|
<section class="panel compact-form-panel">
|
||||||
@@ -51,40 +60,97 @@
|
|||||||
{% if items %}
|
{% if items %}
|
||||||
<section class="card-grid">
|
<section class="card-grid">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<article class="item-card">
|
{% if item.kind == 'food' %}
|
||||||
<div class="item-media">
|
{% set item_icon_class = {
|
||||||
{% if item.photo_filename %}
|
'protein': 'icon-component-protein',
|
||||||
<img
|
'carb': 'icon-component-carb',
|
||||||
src="{{ image_url(item.photo_filename, 'md') }}"
|
'veg': 'icon-component-veg',
|
||||||
srcset="{{ image_srcset(item.photo_filename) }}"
|
'fruit': 'icon-component-fruit',
|
||||||
sizes="{{ image_sizes('grid') }}"
|
'dairy': 'icon-component-dairy',
|
||||||
alt="{{ item.name }}"
|
'nuts': 'icon-component-nuts',
|
||||||
loading="lazy">
|
'seeds': 'icon-component-seeds',
|
||||||
{% else %}
|
'neutral': 'icon-component-neutral',
|
||||||
<div class="placeholder-tile">{{ item.name[:1] }}</div>
|
}.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 %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
<div class="item-body">
|
<div class="item-media item-media-food">
|
||||||
<div class="item-topline">
|
{% if item.photo_filename %}
|
||||||
|
<img
|
||||||
|
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||||
|
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||||
|
sizes="{{ image_sizes('grid') }}"
|
||||||
|
alt="{{ item.name }}"
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<div class="placeholder-icon-tile">
|
||||||
|
<span class="ui-icon {{ item_icon_class }}"></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-body item-body-food">
|
||||||
<h2>{{ item.name }}</h2>
|
<h2>{{ item.name }}</h2>
|
||||||
<span class="status-pill status-{{ item.availability_state }}">{{ availability_labels[item.availability_state] }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="chip-row">
|
|
||||||
<span class="chip">{{ item.visibility_label }}</span>
|
<div class="item-card-hover-meta" aria-hidden="true">
|
||||||
<span class="chip status-soft">{{ item.owner_label }}</span>
|
|
||||||
<span class="chip">{{ item.for_label }}</span>
|
|
||||||
</div>
|
|
||||||
{% if item.kind == 'food' %}
|
|
||||||
<div class="chip-row">
|
<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.base_type_label }}</span>
|
||||||
<span class="chip">{{ item.suggestion_role_label }}</span>
|
<span class="chip">{{ item.suggestion_role_label }}</span>
|
||||||
<span class="chip">{{ item.suggestion_priority_label }}</span>
|
<span class="chip">{{ item.suggestion_priority_label }}</span>
|
||||||
{% if item.can_be_meal_core %}
|
{% if item.can_be_meal_core %}
|
||||||
<span class="chip status-okay">Trägt gut eine Mahlzeit</span>
|
<span class="chip status-okay">Trägt gut eine Mahlzeit</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if item.is_on_shopping_list %}
|
||||||
|
<span class="chip status-idea">Auf Einkaufsliste</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.dayparts %}
|
||||||
|
{% for daypart in item.dayparts %}
|
||||||
|
<span class="chip">{{ daypart }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if item.note %}
|
||||||
|
<p>{{ item.note }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
<article class="item-card">
|
||||||
|
<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>
|
||||||
|
<span class="chip">{{ item.for_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}</p>
|
|
||||||
{% else %}
|
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
<span class="chip">{{ item.meal_type_label }}</span>
|
<span class="chip">{{ item.meal_type_label }}</span>
|
||||||
<span class="chip">{{ energy_density_labels[item.energy_density] }}</span>
|
<span class="chip">{{ energy_density_labels[item.energy_density] }}</span>
|
||||||
@@ -92,8 +158,7 @@
|
|||||||
<span class="chip">{{ tag }}</span>
|
<span class="chip">{{ tag }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% if item.kind != 'food' and item.dayparts %}
|
||||||
{% if item.dayparts %}
|
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
{% for daypart in item.dayparts %}
|
{% for daypart in item.dayparts %}
|
||||||
<span class="chip">{{ daypart }}</span>
|
<span class="chip">{{ daypart }}</span>
|
||||||
@@ -103,33 +168,28 @@
|
|||||||
{% if item.components %}
|
{% if item.components %}
|
||||||
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.note %}
|
{% if item.kind != 'food' and item.note %}
|
||||||
<p>{{ item.note }}</p>
|
<p>{{ item.note }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
{% if item.can_edit %}
|
{% if item.can_edit %}
|
||||||
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
||||||
{% endif %}
|
{% 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>
|
<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) }}">
|
<form class="primary-action" method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||||
{{ csrf_input() }}
|
|
||||||
<button type="submit">Auf Einkaufsliste</button>
|
|
||||||
</form>
|
|
||||||
{% if item.availability_state != 'home' and item.can_edit %}
|
|
||||||
<form method="post" action="{{ url_for('main.item_set_home', item_id=item.id) }}">
|
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="secondary" type="submit">Als Zuhause markieren</button>
|
<button type="submit">Auf Einkaufsliste</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% if item.can_edit %}
|
||||||
{% if item.availability_state != 'archived' and item.can_edit %}
|
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
||||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
{{ csrf_input() }}
|
||||||
{{ csrf_input() }}
|
<button class="ghost-button" type="submit">Archivieren</button>
|
||||||
<button class="ghost-button" type="submit">Ins Archiv</button>
|
</form>
|
||||||
</form>
|
{% endif %}
|
||||||
{% endif %}
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</article>
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Schnell anlegen | Nouri{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Lebensmittel</p>
|
||||||
|
<h1>Schnell anlegen</h1>
|
||||||
|
<p class="lead">Hier kannst du mehrere Lebensmittel in einem Schritt anlegen. Sie landen zuerst als ruhige Platzhalter auf der Einkaufsliste und lassen sich später einzeln weiter ordnen.</p>
|
||||||
|
</div>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_list', kind='food') }}">Zurück</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<form method="post" class="stack-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Namen untereinander
|
||||||
|
<textarea name="names_text" rows="10" placeholder="Erdbeeren Himbeeren Blaubeeren" required>{{ form_data.names_text }}</textarea>
|
||||||
|
<small class="helper-text">Jede Zeile wird als eigenes Lebensmittel angelegt.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="dual-grid">
|
||||||
|
<label>
|
||||||
|
Sichtbarkeit
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Für wen?
|
||||||
|
<select name="target_user_id">
|
||||||
|
{% for option in target_user_options %}
|
||||||
|
<option value="{{ option.value }}" {% if form_data.target_user_raw == option.value %}selected{% endif %}>{{ option.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dual-grid">
|
||||||
|
<label>
|
||||||
|
Baustein
|
||||||
|
<select name="base_type">
|
||||||
|
{% for value, label in builder_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.base_type == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Geschmacksrichtung
|
||||||
|
<select name="flavor_profile">
|
||||||
|
{% for value, label in food_flavor_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.flavor_profile == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dual-grid">
|
||||||
|
<label>
|
||||||
|
Rolle in Vorschlägen
|
||||||
|
<select name="suggestion_role">
|
||||||
|
{% for value, label in food_role_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.suggestion_role == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Wird eher vorgeschlagen
|
||||||
|
<select name="suggestion_priority">
|
||||||
|
{% for value, label in suggestion_priority_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.suggestion_priority == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dual-grid">
|
||||||
|
<label>
|
||||||
|
Energiedichte
|
||||||
|
<select name="energy_density">
|
||||||
|
{% for value, label in energy_density_options %}
|
||||||
|
<option value="{{ value }}" {% if form_data.energy_density == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="inline-check">
|
||||||
|
<input type="checkbox" name="can_be_meal_core" value="1" {% if form_data.can_be_meal_core %}checked{% endif %}>
|
||||||
|
<span>Kann gut eine Mahlzeit tragen</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Passende Tageszeiten</legend>
|
||||||
|
<div class="checkbox-grid daypart-option-grid">
|
||||||
|
{% for daypart in dayparts %}
|
||||||
|
<label class="daypart-option">
|
||||||
|
<input type="checkbox" name="daypart_ids" value="{{ daypart.id }}" {% if daypart.id in form_data.daypart_ids %}checked{% endif %}>
|
||||||
|
<span class="daypart-option-card">
|
||||||
|
<span class="daypart-option-icon">
|
||||||
|
<span class="ui-icon {{ daypart_icon_class(daypart.slug) }}"></span>
|
||||||
|
</span>
|
||||||
|
<span class="daypart-option-label">{{ daypart.name }}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Gemeinsame Notiz
|
||||||
|
<textarea name="note" rows="3" placeholder="Optional, wenn diese Gruppe etwas gemeinsam braucht.">{{ form_data.note }}</textarea>
|
||||||
|
<small class="helper-text">Die Einträge starten in „Unsortiert“, sind zunächst nicht zuhause und tauchen erst nach einer späteren Einordnung im Alltag regulär auf.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">In Unsortiert anlegen</button>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_list', kind='food') }}">Abbrechen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -85,7 +85,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit" name="save_mode" value="stay">Speichern</button>
|
||||||
|
<button class="secondary" type="submit" name="save_mode" value="close">Speichern und schließen</button>
|
||||||
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
|
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -38,6 +38,61 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if selected_items %}
|
||||||
|
<fieldset>
|
||||||
|
<legend>Schon ausgewählt</legend>
|
||||||
|
{% for item_id in form_data.item_ids %}
|
||||||
|
<input type="hidden" name="item_ids" value="{{ item_id }}">
|
||||||
|
{% endfor %}
|
||||||
|
<div class="selected-components-grid">
|
||||||
|
{% for item in selected_items %}
|
||||||
|
{% if item.kind == 'meal' %}
|
||||||
|
{% set item_icon_class = {
|
||||||
|
'breakfast': 'icon-daypart-breakfast',
|
||||||
|
'lunch': 'icon-daypart-lunch',
|
||||||
|
'dinner': 'icon-daypart-dinner',
|
||||||
|
'snack': 'icon-daypart-afternoon-snack',
|
||||||
|
}.get(item.meal_type, 'icon-utensils') %}
|
||||||
|
{% else %}
|
||||||
|
{% set item_icon_class = {
|
||||||
|
'protein': 'icon-component-protein',
|
||||||
|
'carb': 'icon-component-carb',
|
||||||
|
'veg': 'icon-component-veg',
|
||||||
|
'fruit': 'icon-component-fruit',
|
||||||
|
'dairy': 'icon-component-dairy',
|
||||||
|
'nuts': 'icon-component-nuts',
|
||||||
|
'seeds': 'icon-component-seeds',
|
||||||
|
'neutral': 'icon-component-neutral',
|
||||||
|
}.get(item.primary_builder_key or item.base_type, 'icon-component-neutral') %}
|
||||||
|
{% endif %}
|
||||||
|
<article class="selected-component-card">
|
||||||
|
<button class="selected-component-remove" type="submit" name="remove_item_id" value="{{ item.id }}">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
<span class="sr-only">{{ item.name }} entfernen</span>
|
||||||
|
</button>
|
||||||
|
<div class="selected-component-visual">
|
||||||
|
{% if item.photo_filename %}
|
||||||
|
<img
|
||||||
|
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||||
|
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||||
|
sizes="{{ image_sizes('grid') }}"
|
||||||
|
alt="{{ item.name }}"
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<span class="selected-component-fallback">
|
||||||
|
<span class="ui-icon {{ item_icon_class }}"></span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="selected-component-main">
|
||||||
|
<strong>{{ item.name }}</strong>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Einträge auswählen</legend>
|
<legend>Einträge auswählen</legend>
|
||||||
<label>
|
<label>
|
||||||
@@ -51,11 +106,46 @@
|
|||||||
<h3>{{ group["title"] }}</h3>
|
<h3>{{ group["title"] }}</h3>
|
||||||
<span>{{ group["items"]|length }} Einträge</span>
|
<span>{{ group["items"]|length }} Einträge</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="checkbox-grid">
|
<div class="checkbox-grid package-option-grid">
|
||||||
{% for item in group["items"] %}
|
{% for item in group["items"] %}
|
||||||
<label class="check-option" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
|
{% if item.kind == 'meal' %}
|
||||||
|
{% set item_icon_class = {
|
||||||
|
'breakfast': 'icon-daypart-breakfast',
|
||||||
|
'lunch': 'icon-daypart-lunch',
|
||||||
|
'dinner': 'icon-daypart-dinner',
|
||||||
|
'snack': 'icon-daypart-afternoon-snack',
|
||||||
|
}.get(item.meal_type, 'icon-utensils') %}
|
||||||
|
{% else %}
|
||||||
|
{% set item_icon_class = {
|
||||||
|
'protein': 'icon-component-protein',
|
||||||
|
'carb': 'icon-component-carb',
|
||||||
|
'veg': 'icon-component-veg',
|
||||||
|
'fruit': 'icon-component-fruit',
|
||||||
|
'dairy': 'icon-component-dairy',
|
||||||
|
'nuts': 'icon-component-nuts',
|
||||||
|
'seeds': 'icon-component-seeds',
|
||||||
|
'neutral': 'icon-component-neutral',
|
||||||
|
}.get(item.primary_builder_key or item.base_type, 'icon-component-neutral') %}
|
||||||
|
{% endif %}
|
||||||
|
<label class="set-item-option" data-filter-label="{{ item.name|lower }} {{ item.category|default('', true)|lower }}">
|
||||||
<input type="checkbox" name="item_ids" value="{{ item.id }}" {% if item.id in form_data.item_ids %}checked{% endif %}>
|
<input type="checkbox" name="item_ids" value="{{ item.id }}" {% if item.id in form_data.item_ids %}checked{% endif %}>
|
||||||
<span>{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }} · {{ item.for_label }}</span>
|
<span class="set-item-option-card">
|
||||||
|
<span class="set-item-option-visual">
|
||||||
|
{% if item.photo_filename %}
|
||||||
|
<img
|
||||||
|
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||||
|
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||||
|
sizes="{{ image_sizes('grid') }}"
|
||||||
|
alt="{{ item.name }}"
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<span class="set-item-option-fallback">
|
||||||
|
<span class="ui-icon {{ item_icon_class }}"></span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="set-item-option-label">{{ item.name }}</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +155,8 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit" name="save_mode" value="stay">Speichern</button>
|
||||||
|
<button class="secondary" type="submit" name="save_mode" value="close">Speichern und schließen</button>
|
||||||
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
|
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -71,7 +71,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit" name="save_mode" value="stay">Speichern</button>
|
||||||
|
<button class="secondary" type="submit" name="save_mode" value="close">Speichern und schließen</button>
|
||||||
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
|
<a class="ghost-button" href="{{ url_for('main.template_library') }}">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -14,90 +14,53 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="two-column">
|
<section class="planner-day-layout">
|
||||||
<article class="panel">
|
<div class="planner-day-main">
|
||||||
<div class="panel-head">
|
<section class="planner-day-stack">
|
||||||
<h2>Tagesvorlagen</h2>
|
{% set hidden_snack_sections = sections | selectattr('is_snack_daypart') | rejectattr('visible_by_default') | list %}
|
||||||
<a href="{{ url_for('main.day_template_create', source_date=selected_date.isoformat()) }}">Als Vorlage speichern</a>
|
{% if hidden_snack_sections %}
|
||||||
</div>
|
<section class="panel compact-form-panel snack-reveal-panel" data-day-snack-actions>
|
||||||
{% if day_templates %}
|
<div class="panel-head">
|
||||||
<div class="stack-sections">
|
<h2>Zwischenmahlzeit hinzufügen</h2>
|
||||||
{% for template in day_templates %}
|
|
||||||
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
|
|
||||||
{{ csrf_input() }}
|
|
||||||
<input type="hidden" name="target_date" value="{{ selected_date.isoformat() }}">
|
|
||||||
<div class="template-card">
|
|
||||||
<strong>{{ template.name }}</strong>
|
|
||||||
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
|
|
||||||
</div>
|
|
||||||
<button type="submit">Vorlage anwenden</button>
|
|
||||||
</form>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-state">Wenn du einen Tag öfter wiederverwenden möchtest, kannst du ihn hier als Tagesvorlage speichern.</p>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
{% if day_hints %}
|
|
||||||
<article class="panel">
|
|
||||||
<div class="panel-head">
|
|
||||||
<h2>Heute im Blick</h2>
|
|
||||||
</div>
|
|
||||||
<div class="hint-list">
|
|
||||||
{% for hint in day_hints %}
|
|
||||||
<p class="hint-chip">{{ hint }}</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="planner-day-stack">
|
|
||||||
{% set hidden_snack_sections = sections | selectattr('is_snack_daypart') | rejectattr('visible_by_default') | list %}
|
|
||||||
{% if hidden_snack_sections %}
|
|
||||||
<section class="panel compact-form-panel snack-reveal-panel" data-day-snack-actions>
|
|
||||||
<div class="panel-head">
|
|
||||||
<h2>Zwischenmahlzeit hinzufügen</h2>
|
|
||||||
</div>
|
|
||||||
<div class="chip-row snack-reveal-actions">
|
|
||||||
{% for section in hidden_snack_sections %}
|
|
||||||
<button
|
|
||||||
class="ghost-button snack-reveal-button"
|
|
||||||
type="button"
|
|
||||||
data-day-snack-open
|
|
||||||
data-target="#daypart-{{ section.daypart.id }}"
|
|
||||||
>
|
|
||||||
{{ section.daypart.name }}
|
|
||||||
</button>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% for section in sections %}
|
|
||||||
<details
|
|
||||||
class="day-tile{% if section.entries %} has-entries{% endif %}{% if section.selected_quick_action %} has-selection{% endif %}"
|
|
||||||
id="daypart-{{ section.daypart.id }}"
|
|
||||||
{% if section.is_snack_daypart and not section.visible_by_default %}hidden data-day-snack-tile{% endif %}
|
|
||||||
{% if section.is_open %}open{% endif %}
|
|
||||||
>
|
|
||||||
<summary class="day-tile-summary">
|
|
||||||
<div class="day-tile-summary-main">
|
|
||||||
<div class="day-tile-icon"><span class="ui-icon {{ daypart_icon_class(section.daypart.slug) }}"></span></div>
|
|
||||||
<div>
|
|
||||||
<h2>{{ section.daypart.name }}</h2>
|
|
||||||
{% if section.summary_items %}
|
|
||||||
<p class="day-tile-summary-text">{{ section.summary_items|join(', ') }}</p>
|
|
||||||
{% else %}
|
|
||||||
<p class="muted">Noch offen. Öffnen, wenn du etwas eintragen möchtest.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="chip-row snack-reveal-actions">
|
||||||
<span class="status-pill{% if section.entries %} status-home{% endif %}">{{ section.entries|length }} geplant</span>
|
{% for section in hidden_snack_sections %}
|
||||||
</summary>
|
<button
|
||||||
|
class="ghost-button snack-reveal-button"
|
||||||
|
type="button"
|
||||||
|
data-day-snack-open
|
||||||
|
data-target="#daypart-{{ section.daypart.id }}"
|
||||||
|
>
|
||||||
|
{{ section.daypart.name }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="day-tile-body">
|
{% for section in sections %}
|
||||||
|
<details
|
||||||
|
class="day-tile{% if section.entries %} has-entries{% endif %}{% if section.selected_quick_action %} has-selection{% endif %}"
|
||||||
|
id="daypart-{{ section.daypart.id }}"
|
||||||
|
{% if section.is_snack_daypart and not section.visible_by_default %}hidden data-day-snack-tile{% endif %}
|
||||||
|
{% if section.is_open %}open{% endif %}
|
||||||
|
>
|
||||||
|
<summary class="day-tile-summary">
|
||||||
|
<div class="day-tile-summary-main">
|
||||||
|
<div class="day-tile-icon"><span class="ui-icon {{ daypart_icon_class(section.daypart.slug) }}"></span></div>
|
||||||
|
<div>
|
||||||
|
<h2>{{ section.daypart.name }}</h2>
|
||||||
|
{% if section.summary_items %}
|
||||||
|
<p class="day-tile-summary-text">{{ section.summary_items|join(', ') }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Noch offen. Öffnen, wenn du etwas eintragen möchtest.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill{% if section.entries %} status-home{% endif %}">{{ section.entries|length }} geplant</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="day-tile-body">
|
||||||
{% if section.selected_quick_action %}
|
{% if section.selected_quick_action %}
|
||||||
<div class="suggestion-card selected-quick-action">
|
<div class="suggestion-card selected-quick-action">
|
||||||
<span class="status-pill status-home">Schon ausgewählt</span>
|
<span class="status-pill status-home">Schon ausgewählt</span>
|
||||||
@@ -164,7 +127,7 @@
|
|||||||
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||||
<button class="quick-add-button compact-button" type="submit">
|
<button class="quick-add-button compact-button" type="submit">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
{% if item.availability_state == 'home' %}<small>zuhause vorhanden</small>{% endif %}
|
{% if item.is_home %}<small>zuhause vorhanden</small>{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -227,7 +190,7 @@
|
|||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<small>
|
<small>
|
||||||
{{ item_kind_labels[item.kind] }}
|
{{ item_kind_labels[item.kind] }}
|
||||||
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
{% if item.is_home %} · zuhause{% endif %}
|
||||||
</small>
|
</small>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -242,7 +205,7 @@
|
|||||||
<div class="planner-entry-top">
|
<div class="planner-entry-top">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ entry.item_name }}</strong>
|
<strong>{{ entry.item_name }}</strong>
|
||||||
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
|
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.is_home %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
<span class="chip">{{ entry.visibility_label }}</span>
|
<span class="chip">{{ entry.visibility_label }}</span>
|
||||||
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||||
@@ -301,8 +264,49 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="planner-day-sidebar">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Tagesvorlagen</h2>
|
||||||
|
<a href="{{ url_for('main.day_template_create', source_date=selected_date.isoformat()) }}">Als Vorlage speichern</a>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
{% if day_templates %}
|
||||||
{% endfor %}
|
<div class="stack-sections">
|
||||||
|
{% for template in day_templates %}
|
||||||
|
<form method="post" action="{{ url_for('main.day_template_apply', template_id=template.id) }}" class="inline-form template-apply-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="target_date" value="{{ selected_date.isoformat() }}">
|
||||||
|
<div class="template-card">
|
||||||
|
<strong>{{ template.name }}</strong>
|
||||||
|
<small>{{ template.visibility_label }} · {{ template.owner_label }}</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit">Vorlage anwenden</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">Wenn du einen Tag öfter wiederverwenden möchtest, kannst du ihn hier als Tagesvorlage speichern.</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% if day_hints %}
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Heute im Blick</h2>
|
||||||
|
</div>
|
||||||
|
<div class="hint-list">
|
||||||
|
{% for hint in day_hints %}
|
||||||
|
<p class="hint-chip">{{ hint }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||||
<button class="quick-add-button compact-button" type="submit">
|
<button class="quick-add-button compact-button" type="submit">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
{% if item.availability_state == 'home' %}<small>Zuhause vorhanden</small>{% endif %}
|
{% if item.is_home %}<small>Zuhause vorhanden</small>{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
<input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}">
|
<input type="time" name="shopping_reminder_time" value="{{ household_settings.shopping_reminder_time }}">
|
||||||
</label>
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit" name="save_mode" value="stay">Speichern</button>
|
||||||
|
<button class="secondary" type="submit" name="save_mode" value="close">Speichern und schließen</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</article>
|
</article>
|
||||||
@@ -151,7 +152,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit" name="save_mode" value="stay">Speichern</button>
|
||||||
|
<button class="secondary" type="submit" name="save_mode" value="close">Speichern und schließen</button>
|
||||||
<a class="ghost-button" href="{{ url_for('auth.profile') }}">Zum Profil</a>
|
<a class="ghost-button" href="{{ url_for('auth.profile') }}">Zum Profil</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -10,19 +10,68 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel compact-form-panel">
|
<section class="panel compact-form-panel">
|
||||||
<form method="post" class="inline-form">
|
<div class="stack-sections">
|
||||||
{{ csrf_input() }}
|
<label>
|
||||||
<select name="item_id">
|
Lebensmittel suchen
|
||||||
<option value="">Bestehenden Eintrag hinzufügen</option>
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nach Lebensmitteln suchen"
|
||||||
|
data-filter-input
|
||||||
|
data-filter-target="#shopping-add-list"
|
||||||
|
data-filter-limit="8"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<div class="shopping-add-grid" id="shopping-add-list">
|
||||||
{% for item in addable_items %}
|
{% for item in addable_items %}
|
||||||
<option value="{{ item.id }}">
|
{% set item_icon_class = {
|
||||||
{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}
|
'protein': 'icon-component-protein',
|
||||||
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
'carb': 'icon-component-carb',
|
||||||
</option>
|
'veg': 'icon-component-veg',
|
||||||
|
'fruit': 'icon-component-fruit',
|
||||||
|
'dairy': 'icon-component-dairy',
|
||||||
|
'nuts': 'icon-component-nuts',
|
||||||
|
'seeds': 'icon-component-seeds',
|
||||||
|
'neutral': 'icon-component-neutral',
|
||||||
|
}.get(item.primary_builder_key or item.base_type, 'icon-component-neutral') %}
|
||||||
|
<form method="post" data-filter-label="{{ item.name|lower }} {{ item.base_type_label|lower }} {{ item.for_label|lower }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||||
|
<button class="shopping-add-card" type="submit">
|
||||||
|
<span class="shopping-add-card-visual">
|
||||||
|
{% if item.photo_filename %}
|
||||||
|
<img
|
||||||
|
src="{{ image_url(item.photo_filename, 'md') }}"
|
||||||
|
srcset="{{ image_srcset(item.photo_filename) }}"
|
||||||
|
sizes="{{ image_sizes('grid') }}"
|
||||||
|
alt=""
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<span class="shopping-add-card-fallback">
|
||||||
|
<span class="ui-icon {{ item_icon_class }}"></span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="shopping-add-card-copy">
|
||||||
|
<strong>{{ item.name }}</strong>
|
||||||
|
<small>
|
||||||
|
{% if item.is_archived %}
|
||||||
|
Archiviert
|
||||||
|
{% elif item.is_quick_added %}
|
||||||
|
Unsortiert
|
||||||
|
{% elif item.is_home %}
|
||||||
|
Zuhause · trotzdem ergänzen
|
||||||
|
{% else %}
|
||||||
|
Gerade nicht da
|
||||||
|
{% endif %}
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="shopping-add-empty muted">Gerade ist nichts zusätzlich offen.</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</div>
|
||||||
<button type="submit">Auf Liste setzen</button>
|
</div>
|
||||||
</form>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if entries %}
|
{% if entries %}
|
||||||
@@ -34,35 +83,117 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="stack-list">
|
<section class="stack-list">
|
||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
<article class="list-row stacked-mobile roomy-row">
|
{% set entry_icon_class = {
|
||||||
<div>
|
'protein': 'icon-component-protein',
|
||||||
<strong>{{ entry.item_name }}</strong>
|
'carb': 'icon-component-carb',
|
||||||
<p class="muted">{{ item_kind_labels[entry.item_kind] }}</p>
|
'veg': 'icon-component-veg',
|
||||||
<div class="chip-row">
|
'fruit': 'icon-component-fruit',
|
||||||
<span class="chip">{{ entry.visibility_label }}</span>
|
'dairy': 'icon-component-dairy',
|
||||||
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
'nuts': 'icon-component-nuts',
|
||||||
<span class="chip">{{ entry.for_label }}</span>
|
'seeds': 'icon-component-seeds',
|
||||||
{% if entry.needed_for_label %}
|
'neutral': 'icon-component-neutral',
|
||||||
<span class="chip status-home">
|
}.get(entry.primary_builder_key or entry.base_type, 'icon-component-neutral') %}
|
||||||
Für {{ entry.needed_for_label }}
|
<article class="shopping-entry-card">
|
||||||
{% if entry.needed_daypart_name %} · {{ entry.needed_daypart_name }}{% endif %}
|
<div class="shopping-entry-row">
|
||||||
</span>
|
<div
|
||||||
{% endif %}
|
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.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>
|
<div class="shopping-entry-actions">
|
||||||
<div class="row-actions">
|
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
||||||
<form method="post" action="{{ url_for('main.shopping_check', entry_id=entry.id) }}">
|
|
||||||
{{ csrf_input() }}
|
|
||||||
<button type="submit">Eingekauft</button>
|
|
||||||
</form>
|
|
||||||
{% if entry.can_edit %}
|
|
||||||
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}">
|
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Entfernen</button>
|
<button type="submit">Eingekauft</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>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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.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_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 %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||