Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36bde02c54 |
@@ -1,16 +1,18 @@
|
|||||||
# Nouri
|
# Nouri
|
||||||
|
|
||||||
Nouri ist eine kleine private Flask-App fuer einen Haushalt, um Essensideen, Einkaeufe, vorhandene Lebensmittel und einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten.
|
Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Einkäufe, vorhandene Lebensmittel und eine einfache Tages- oder Wochenplanung ruhig und alltagsnah festzuhalten.
|
||||||
|
|
||||||
## Merkmale in Version 0.1
|
## Merkmale in Version 0.2
|
||||||
|
|
||||||
- Lebensmittel und Mahlzeitenideen anlegen
|
- Lebensmittel und Mahlzeitenideen anlegen
|
||||||
- Fotos lokal hochladen
|
- Fotos lokal hochladen
|
||||||
- Einkaufsliste mit Abhaken
|
- Einkaufsliste mit Abhaken
|
||||||
- "Zuhause" als sichtbarer Vorrat
|
- „Zuhause“ als sichtbarer Vorrat mit Tageszeit-Filtern
|
||||||
- Archiv zum spaeteren Wiederverwenden
|
- Archiv mit Suche und schneller Wiederaufnahme
|
||||||
- Tages- und Wochenplanung nach Tageszeiten
|
- Tagesplan mit schnellen Vorschlägen je Tageszeit
|
||||||
- einfache Benutzeranmeldung fuer einen Haushalt
|
- Wochenansicht für die nächsten 7 Tage
|
||||||
|
- einfache Suche und Filter für Lebensmittel und Mahlzeitenideen
|
||||||
|
- einfache Benutzeranmeldung für einen Haushalt
|
||||||
|
|
||||||
## Lokal starten
|
## Lokal starten
|
||||||
|
|
||||||
@@ -21,18 +23,22 @@ pip install -r requirements.txt
|
|||||||
flask --app wsgi run --debug
|
flask --app wsgi run --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
Dann `http://127.0.0.1:5000` oeffnen und beim ersten Start einen ersten Haushalt-Benutzer unter `/setup` anlegen.
|
Dann `http://127.0.0.1:5000` öffnen und beim ersten Start einen ersten Haushalt-Benutzer unter `/auth/setup` anlegen.
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
Die App legt Daten standardmaessig unter `./data` ab.
|
Die App legt Daten standardmäßig unter `./data` ab.
|
||||||
|
|
||||||
Wichtige Umgebungsvariablen:
|
Wichtige Umgebungsvariablen:
|
||||||
|
|
||||||
- `NOURI_SECRET_KEY`: Session-Secret fuer Produktion
|
- `NOURI_SECRET_KEY`: Session-Secret für Produktion
|
||||||
- `NOURI_DATA_DIR`: Pfad fuer Datenbank und Uploads, z. B. `/app/data` auf Cloudron
|
- `NOURI_DATA_DIR`: Pfad für Datenbank und Uploads, z. B. `/app/data` auf Cloudron
|
||||||
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
|
- `NOURI_MAX_UPLOAD_MB`: maximales Upload-Limit in MB, Standard `5`
|
||||||
|
|
||||||
|
## Migration von 0.1 auf 0.2
|
||||||
|
|
||||||
|
Beim Start führt Nouri das Schema erneut mit `CREATE ... IF NOT EXISTS` aus und gleicht die festen Tageszeiten ab. Vorhandene Daten bleiben erhalten; neue Indizes und aktualisierte Tageszeit-Namen werden automatisch ergänzt.
|
||||||
|
|
||||||
## Cloudron-Hinweis
|
## Cloudron-Hinweis
|
||||||
|
|
||||||
Fuer Cloudron spaeter `NOURI_DATA_DIR=/app/data` setzen, damit Datenbank und Uploads persistent liegen.
|
Für Cloudron später `NOURI_DATA_DIR=/app/data` setzen, damit Datenbank und Uploads persistent liegen.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, send_from_directory
|
from flask import Flask, send_from_directory
|
||||||
@@ -12,6 +13,10 @@ from .constants import CATEGORIES, DAYPARTS, ITEM_KIND_LABELS, ITEM_KIND_SINGULA
|
|||||||
from .main import main_bp
|
from .main import main_bp
|
||||||
|
|
||||||
|
|
||||||
|
WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||||
|
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> Flask:
|
def create_app() -> Flask:
|
||||||
root_dir = Path(__file__).resolve().parent.parent
|
root_dir = Path(__file__).resolve().parent.parent
|
||||||
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
|
data_dir = Path(os.environ.get("NOURI_DATA_DIR", root_dir / "data")).resolve()
|
||||||
@@ -28,6 +33,7 @@ def create_app() -> Flask:
|
|||||||
DATA_DIR=str(data_dir),
|
DATA_DIR=str(data_dir),
|
||||||
UPLOAD_FOLDER=str(upload_dir),
|
UPLOAD_FOLDER=str(upload_dir),
|
||||||
MAX_CONTENT_LENGTH=int(os.environ.get("NOURI_MAX_UPLOAD_MB", "5")) * 1024 * 1024,
|
MAX_CONTENT_LENGTH=int(os.environ.get("NOURI_MAX_UPLOAD_MB", "5")) * 1024 * 1024,
|
||||||
|
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
|
||||||
)
|
)
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
@@ -43,6 +49,9 @@ def create_app() -> Flask:
|
|||||||
"item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS,
|
"item_kind_singular_labels": ITEM_KIND_SINGULAR_LABELS,
|
||||||
"category_suggestions": CATEGORIES,
|
"category_suggestions": CATEGORIES,
|
||||||
"daypart_suggestions": DAYPARTS,
|
"daypart_suggestions": DAYPARTS,
|
||||||
|
"today": date.today(),
|
||||||
|
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
||||||
|
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/uploads/<path:filename>")
|
@app.get("/uploads/<path:filename>")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import functools
|
import functools
|
||||||
|
import secrets
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint,
|
Blueprint,
|
||||||
@@ -34,7 +35,7 @@ def login_required(view):
|
|||||||
def ensure_csrf_token() -> str:
|
def ensure_csrf_token() -> str:
|
||||||
token = session.get("_csrf_token")
|
token = session.get("_csrf_token")
|
||||||
if not token:
|
if not token:
|
||||||
token = session["_csrf_token"] = __import__("secrets").token_hex(24)
|
token = session["_csrf_token"] = secrets.token_hex(24)
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
@@ -43,7 +44,8 @@ def inject_csrf_input():
|
|||||||
return {
|
return {
|
||||||
"csrf_input": lambda: Markup(
|
"csrf_input": lambda: Markup(
|
||||||
f'<input type="hidden" name="csrf_token" value="{ensure_csrf_token()}">'
|
f'<input type="hidden" name="csrf_token" value="{ensure_csrf_token()}">'
|
||||||
)
|
),
|
||||||
|
"csrf_token_value": ensure_csrf_token(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -87,7 +89,7 @@ def setup():
|
|||||||
elif not password:
|
elif not password:
|
||||||
error = "Bitte ein Passwort vergeben."
|
error = "Bitte ein Passwort vergeben."
|
||||||
elif password != password_repeat:
|
elif password != password_repeat:
|
||||||
error = "Die Passwoerter stimmen nicht ueberein."
|
error = "Die Passwörter stimmen nicht überein."
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
database = get_db()
|
database = get_db()
|
||||||
@@ -115,6 +117,7 @@ def login():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip().lower()
|
username = request.form.get("username", "").strip().lower()
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
|
remember_me = request.form.get("remember_me") == "1"
|
||||||
database = get_db()
|
database = get_db()
|
||||||
user = database.execute(
|
user = database.execute(
|
||||||
"SELECT * FROM users WHERE username = ?",
|
"SELECT * FROM users WHERE username = ?",
|
||||||
@@ -127,6 +130,8 @@ def login():
|
|||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
session.clear()
|
session.clear()
|
||||||
|
# Opt-in long-lived session so the shared household device stays low-friction.
|
||||||
|
session.permanent = remember_me
|
||||||
session["user_id"] = user["id"]
|
session["user_id"] = user["id"]
|
||||||
ensure_csrf_token()
|
ensure_csrf_token()
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
DAYPARTS = [
|
DAYPARTS = [
|
||||||
{"slug": "breakfast", "name": "Fruehstueck", "sort_order": 10},
|
{"slug": "breakfast", "name": "Frühstück", "sort_order": 10},
|
||||||
{"slug": "morning-snack", "name": "Vormittagssnack", "sort_order": 20},
|
{"slug": "morning-snack", "name": "Vormittagssnack", "sort_order": 20},
|
||||||
{"slug": "lunch", "name": "Mittagessen", "sort_order": 30},
|
{"slug": "lunch", "name": "Mittagessen", "sort_order": 30},
|
||||||
{"slug": "afternoon-snack", "name": "Nachmittagssnack", "sort_order": 40},
|
{"slug": "afternoon-snack", "name": "Nachmittagssnack", "sort_order": 40},
|
||||||
{"slug": "dinner", "name": "Abendessen", "sort_order": 50},
|
{"slug": "dinner", "name": "Abendessen", "sort_order": 50},
|
||||||
{"slug": "late-snack", "name": "Spaeter Snack", "sort_order": 60},
|
{"slug": "late-snack", "name": "Später Snack", "sort_order": 60},
|
||||||
]
|
]
|
||||||
|
|
||||||
CATEGORIES = [
|
CATEGORIES = [
|
||||||
"Brot & Getreide",
|
"Brot & Getreide",
|
||||||
"Milchprodukt",
|
"Milchprodukt",
|
||||||
"Obst",
|
"Obst",
|
||||||
"Gemuese",
|
"Gemüse",
|
||||||
"Eiweissquelle",
|
"Eiweißquelle",
|
||||||
"Snack",
|
"Snack",
|
||||||
"Getraenk",
|
"Getränk",
|
||||||
"Vorrat & Basics",
|
"Vorrat & Basics",
|
||||||
"Warmes",
|
"Warmes",
|
||||||
"Kleines Essen",
|
"Kleines Essen",
|
||||||
|
|||||||
@@ -23,20 +23,24 @@ def get_db() -> sqlite3.Connection:
|
|||||||
|
|
||||||
|
|
||||||
def close_db(_error=None) -> None:
|
def close_db(_error=None) -> None:
|
||||||
db = g.pop("db", None)
|
database = g.pop("db", None)
|
||||||
if db is not None:
|
if database is not None:
|
||||||
db.close()
|
database.close()
|
||||||
|
|
||||||
|
|
||||||
|
def apply_schema(database: sqlite3.Connection) -> None:
|
||||||
|
schema_path = Path(__file__).with_name("schema.sql")
|
||||||
|
database.executescript(schema_path.read_text(encoding="utf-8"))
|
||||||
|
sync_dayparts(database)
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
def init_db() -> None:
|
||||||
database = get_db()
|
database = get_db()
|
||||||
schema_path = Path(__file__).with_name("schema.sql")
|
apply_schema(database)
|
||||||
database.executescript(schema_path.read_text(encoding="utf-8"))
|
|
||||||
seed_dayparts(database)
|
|
||||||
database.commit()
|
database.commit()
|
||||||
|
|
||||||
|
|
||||||
def seed_dayparts(database: sqlite3.Connection) -> None:
|
def sync_dayparts(database: sqlite3.Connection) -> None:
|
||||||
for entry in DAYPARTS:
|
for entry in DAYPARTS:
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
@@ -45,22 +49,19 @@ def seed_dayparts(database: sqlite3.Connection) -> None:
|
|||||||
""",
|
""",
|
||||||
(entry["slug"], entry["name"], entry["sort_order"]),
|
(entry["slug"], entry["name"], entry["sort_order"]),
|
||||||
)
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE dayparts
|
||||||
|
SET name = ?, sort_order = ?
|
||||||
|
WHERE slug = ?
|
||||||
|
""",
|
||||||
|
(entry["name"], entry["sort_order"], entry["slug"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def init_db_if_needed(app: Flask) -> None:
|
def init_db_if_needed(app: Flask) -> None:
|
||||||
db_path = Path(app.config["DATABASE_PATH"])
|
|
||||||
needs_init = not db_path.exists()
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
if needs_init:
|
init_db()
|
||||||
init_db()
|
|
||||||
return
|
|
||||||
|
|
||||||
database = get_db()
|
|
||||||
table = database.execute(
|
|
||||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'users'"
|
|
||||||
).fetchone()
|
|
||||||
if table is None:
|
|
||||||
init_db()
|
|
||||||
|
|
||||||
|
|
||||||
def user_count() -> int:
|
def user_count() -> int:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
@@ -11,6 +10,7 @@ from flask import (
|
|||||||
current_app,
|
current_app,
|
||||||
flash,
|
flash,
|
||||||
g,
|
g,
|
||||||
|
jsonify,
|
||||||
redirect,
|
redirect,
|
||||||
render_template,
|
render_template,
|
||||||
request,
|
request,
|
||||||
@@ -31,12 +31,29 @@ from .db import get_db
|
|||||||
main_bp = Blueprint("main", __name__)
|
main_bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
||||||
|
ACTIVE_STATE_OPTIONS = [
|
||||||
|
("", "Alle aktiven"),
|
||||||
|
("home", "Zuhause"),
|
||||||
|
("idea", "Merkliste"),
|
||||||
|
]
|
||||||
|
KIND_FILTER_OPTIONS = [
|
||||||
|
("", "Alles"),
|
||||||
|
("food", "Lebensmittel"),
|
||||||
|
("meal", "Mahlzeitenideen"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_dayparts() -> list:
|
def get_dayparts() -> list:
|
||||||
return get_db().execute("SELECT * FROM dayparts ORDER BY sort_order").fetchall()
|
return get_db().execute("SELECT * FROM dayparts ORDER BY sort_order").fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def get_daypart_by_id(daypart_id: int):
|
||||||
|
return get_db().execute(
|
||||||
|
"SELECT * FROM dayparts WHERE id = ?",
|
||||||
|
(daypart_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
def parse_week_start(raw: str | None) -> date:
|
def parse_week_start(raw: str | None) -> date:
|
||||||
if raw:
|
if raw:
|
||||||
try:
|
try:
|
||||||
@@ -48,6 +65,15 @@ def parse_week_start(raw: str | None) -> date:
|
|||||||
return today - timedelta(days=today.weekday())
|
return today - timedelta(days=today.weekday())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_plan_date(raw: str | None, fallback: date | None = None) -> date:
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(raw, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return fallback or date.today()
|
||||||
|
|
||||||
|
|
||||||
def allowed_file(filename: str) -> bool:
|
def allowed_file(filename: str) -> bool:
|
||||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
|
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS
|
||||||
|
|
||||||
@@ -108,26 +134,34 @@ def attach_dayparts(items: list) -> list[dict]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
database = get_db()
|
database = get_db()
|
||||||
ids = [item["id"] for item in items]
|
item_ids = [item["id"] for item in items]
|
||||||
placeholders = ",".join("?" for _ in ids)
|
placeholders = ",".join("?" for _ in item_ids)
|
||||||
rows = database.execute(
|
rows = database.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT item_dayparts.item_id, dayparts.name
|
SELECT item_dayparts.item_id, dayparts.id, dayparts.slug, dayparts.name
|
||||||
FROM item_dayparts
|
FROM item_dayparts
|
||||||
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
|
JOIN dayparts ON dayparts.id = item_dayparts.daypart_id
|
||||||
WHERE item_dayparts.item_id IN ({placeholders})
|
WHERE item_dayparts.item_id IN ({placeholders})
|
||||||
ORDER BY dayparts.sort_order
|
ORDER BY dayparts.sort_order
|
||||||
""",
|
""",
|
||||||
ids,
|
item_ids,
|
||||||
).fetchall()
|
).fetchall()
|
||||||
grouped = defaultdict(list)
|
grouped = defaultdict(list)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
grouped[row["item_id"]].append(row["name"])
|
grouped[row["item_id"]].append(
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"slug": row["slug"],
|
||||||
|
"name": row["name"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
enriched = []
|
enriched = []
|
||||||
for item in items:
|
for item in items:
|
||||||
entry = dict(item)
|
entry = dict(item)
|
||||||
entry["dayparts"] = grouped.get(item["id"], [])
|
entry["dayparts_meta"] = grouped.get(item["id"], [])
|
||||||
|
entry["dayparts"] = [daypart["name"] for daypart in entry["dayparts_meta"]]
|
||||||
|
entry["primary_daypart_id"] = entry["dayparts_meta"][0]["id"] if entry["dayparts_meta"] else None
|
||||||
enriched.append(entry)
|
enriched.append(entry)
|
||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
@@ -135,6 +169,8 @@ def attach_dayparts(items: list) -> list[dict]:
|
|||||||
def attach_components(items: list[dict]) -> list[dict]:
|
def attach_components(items: list[dict]) -> list[dict]:
|
||||||
meal_ids = [item["id"] for item in items if item["kind"] == "meal"]
|
meal_ids = [item["id"] for item in items if item["kind"] == "meal"]
|
||||||
if not meal_ids:
|
if not meal_ids:
|
||||||
|
for item in items:
|
||||||
|
item["components"] = []
|
||||||
return items
|
return items
|
||||||
|
|
||||||
placeholders = ",".join("?" for _ in meal_ids)
|
placeholders = ",".join("?" for _ in meal_ids)
|
||||||
@@ -157,21 +193,42 @@ def attach_components(items: list[dict]) -> list[dict]:
|
|||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
def fetch_items(kind: str | None = None, availability: str | None = None, include_archived: bool = False):
|
def fetch_items(
|
||||||
|
kind: str | None = None,
|
||||||
|
availability: str | None = None,
|
||||||
|
include_archived: bool = False,
|
||||||
|
query: str | None = None,
|
||||||
|
daypart_id: int | None = None,
|
||||||
|
):
|
||||||
database = get_db()
|
database = get_db()
|
||||||
conditions = []
|
conditions = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if kind:
|
if kind:
|
||||||
conditions.append("kind = ?")
|
conditions.append("items.kind = ?")
|
||||||
params.append(kind)
|
params.append(kind)
|
||||||
if availability:
|
if availability:
|
||||||
conditions.append("availability_state = ?")
|
conditions.append("items.availability_state = ?")
|
||||||
params.append(availability)
|
params.append(availability)
|
||||||
elif not include_archived:
|
elif not include_archived:
|
||||||
conditions.append("availability_state != 'archived'")
|
conditions.append("items.availability_state != 'archived'")
|
||||||
|
if query:
|
||||||
|
conditions.append("LOWER(items.name) LIKE ?")
|
||||||
|
params.append(f"%{query.lower()}%")
|
||||||
|
if daypart_id:
|
||||||
|
conditions.append(
|
||||||
|
"""
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM item_dayparts
|
||||||
|
WHERE item_dayparts.item_id = items.id
|
||||||
|
AND item_dayparts.daypart_id = ?
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
params.append(daypart_id)
|
||||||
|
|
||||||
where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||||
rows = database.execute(
|
rows = database.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT items.*,
|
SELECT items.*,
|
||||||
@@ -181,15 +238,73 @@ def fetch_items(kind: str | None = None, availability: str | None = None, includ
|
|||||||
WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0
|
WHERE shopping_entries.item_id = items.id AND shopping_entries.is_checked = 0
|
||||||
) AS is_on_shopping_list
|
) AS is_on_shopping_list
|
||||||
FROM items
|
FROM items
|
||||||
{where}
|
{where_clause}
|
||||||
ORDER BY LOWER(name)
|
ORDER BY
|
||||||
"""
|
CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END,
|
||||||
, params).fetchall()
|
LOWER(items.name)
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
return attach_components(attach_dayparts(rows))
|
return attach_components(attach_dayparts(rows))
|
||||||
|
|
||||||
|
|
||||||
def fetch_food_options():
|
def fetch_food_options():
|
||||||
return fetch_items(kind="food", include_archived=False)
|
return fetch_items(kind="food", include_archived=True)
|
||||||
|
|
||||||
|
|
||||||
|
def group_items_by_availability(items: list[dict]) -> list[dict]:
|
||||||
|
grouped = defaultdict(list)
|
||||||
|
for item in items:
|
||||||
|
grouped[item["availability_state"]].append(item)
|
||||||
|
|
||||||
|
ordered_states = ["home", "idea", "archived"]
|
||||||
|
result = []
|
||||||
|
for state in ordered_states:
|
||||||
|
entries = grouped.get(state, [])
|
||||||
|
if entries:
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"state": state,
|
||||||
|
"title": AVAILABILITY_LABELS[state],
|
||||||
|
"items": entries,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def extract_item_form_data() -> dict:
|
||||||
|
return {
|
||||||
|
"name": request.form.get("name", "").strip(),
|
||||||
|
"category": request.form.get("category", "").strip(),
|
||||||
|
"note": request.form.get("note", "").strip(),
|
||||||
|
"daypart_ids": [int(value) for value in request.form.getlist("daypart_ids")],
|
||||||
|
"component_ids": [int(value) for value in request.form.getlist("component_ids")],
|
||||||
|
"quick_food_name": request.form.get("quick_food_name", "").strip(),
|
||||||
|
"quick_food_category": request.form.get("quick_food_category", "").strip(),
|
||||||
|
"quick_food_note": request.form.get("quick_food_note", "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_quick_food_from_form(form_data: dict) -> int:
|
||||||
|
database = get_db()
|
||||||
|
# Inline item creation keeps the meal-idea flow intact instead of forcing a detour.
|
||||||
|
cursor = database.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO items (kind, name, category, note, created_by, updated_by)
|
||||||
|
VALUES ('food', ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
form_data["quick_food_name"],
|
||||||
|
form_data["quick_food_category"],
|
||||||
|
form_data["quick_food_note"],
|
||||||
|
g.user["id"],
|
||||||
|
g.user["id"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
food_id = cursor.lastrowid
|
||||||
|
sync_item_dayparts(food_id, form_data["daypart_ids"])
|
||||||
|
database.commit()
|
||||||
|
return food_id
|
||||||
|
|
||||||
|
|
||||||
def add_to_shopping_list(item_id: int, user_id: int) -> bool:
|
def add_to_shopping_list(item_id: int, user_id: int) -> bool:
|
||||||
@@ -215,6 +330,14 @@ def add_to_shopping_list(item_id: int, user_id: int) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_planned_item_is_shopped(item_id: int, user_id: int) -> bool:
|
||||||
|
item = get_item(item_id)
|
||||||
|
if item["availability_state"] == "home":
|
||||||
|
return False
|
||||||
|
# Planning something that is not at home should create a gentle follow-up on the shopping list.
|
||||||
|
return add_to_shopping_list(item_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
def sync_item_dayparts(item_id: int, daypart_ids: list[int]) -> None:
|
def sync_item_dayparts(item_id: int, daypart_ids: list[int]) -> None:
|
||||||
database = get_db()
|
database = get_db()
|
||||||
database.execute("DELETE FROM item_dayparts WHERE item_id = ?", (item_id,))
|
database.execute("DELETE FROM item_dayparts WHERE item_id = ?", (item_id,))
|
||||||
@@ -239,7 +362,7 @@ def sync_meal_components(meal_id: int, food_ids: list[int]) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_shopping_entries():
|
def fetch_shopping_entries():
|
||||||
rows = get_db().execute(
|
return get_db().execute(
|
||||||
"""
|
"""
|
||||||
SELECT shopping_entries.*,
|
SELECT shopping_entries.*,
|
||||||
items.name AS item_name,
|
items.name AS item_name,
|
||||||
@@ -255,42 +378,202 @@ def fetch_shopping_entries():
|
|||||||
ORDER BY shopping_entries.added_at DESC
|
ORDER BY shopping_entries.added_at DESC
|
||||||
"""
|
"""
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_archive_items():
|
def fetch_plan_entries_for_range(start_date: date, end_date: date):
|
||||||
return fetch_items(availability="archived", include_archived=True)
|
|
||||||
|
|
||||||
|
|
||||||
def planner_entries_for_week(week_start: date):
|
|
||||||
week_end = week_start + timedelta(days=6)
|
|
||||||
rows = get_db().execute(
|
rows = get_db().execute(
|
||||||
"""
|
"""
|
||||||
SELECT plan_entries.*,
|
SELECT plan_entries.*,
|
||||||
items.name AS item_name,
|
items.name AS item_name,
|
||||||
items.kind AS item_kind,
|
items.kind AS item_kind,
|
||||||
items.photo_filename,
|
items.photo_filename,
|
||||||
|
items.availability_state,
|
||||||
dayparts.name AS daypart_name,
|
dayparts.name AS daypart_name,
|
||||||
dayparts.slug AS daypart_slug
|
dayparts.slug AS daypart_slug,
|
||||||
|
dayparts.sort_order
|
||||||
FROM plan_entries
|
FROM plan_entries
|
||||||
JOIN items ON items.id = plan_entries.item_id
|
JOIN items ON items.id = plan_entries.item_id
|
||||||
JOIN dayparts ON dayparts.id = plan_entries.daypart_id
|
JOIN dayparts ON dayparts.id = plan_entries.daypart_id
|
||||||
WHERE plan_date BETWEEN ? AND ?
|
WHERE plan_date BETWEEN ? AND ?
|
||||||
ORDER BY plan_date, dayparts.sort_order, items.name
|
ORDER BY plan_date, dayparts.sort_order, items.name
|
||||||
""",
|
""",
|
||||||
(week_start.isoformat(), week_end.isoformat()),
|
(start_date.isoformat(), end_date.isoformat()),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
grouped = defaultdict(list)
|
grouped = defaultdict(list)
|
||||||
for row in rows:
|
for row in rows:
|
||||||
grouped[(row["plan_date"], row["daypart_id"])].append(row)
|
grouped[(row["plan_date"], row["daypart_id"])].append(dict(row))
|
||||||
return grouped
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_day_plan_entries(selected_date: date):
|
||||||
|
return fetch_plan_entries_for_range(selected_date, selected_date)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_week_cards(week_start: date):
|
||||||
|
days = [week_start + timedelta(days=index) for index in range(7)]
|
||||||
|
dayparts = get_dayparts()
|
||||||
|
grouped_entries = fetch_plan_entries_for_range(week_start, week_start + timedelta(days=6))
|
||||||
|
cards = []
|
||||||
|
for current_day in days:
|
||||||
|
filled_dayparts = []
|
||||||
|
planned_count = 0
|
||||||
|
preview_items = []
|
||||||
|
slots = []
|
||||||
|
for daypart in dayparts:
|
||||||
|
slot_entries = grouped_entries.get((current_day.isoformat(), daypart["id"]), [])
|
||||||
|
slots.append(
|
||||||
|
{
|
||||||
|
"daypart": dict(daypart),
|
||||||
|
"entries": slot_entries,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if slot_entries:
|
||||||
|
filled_dayparts.append(
|
||||||
|
{
|
||||||
|
"id": daypart["id"],
|
||||||
|
"name": daypart["name"],
|
||||||
|
"count": len(slot_entries),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
planned_count += len(slot_entries)
|
||||||
|
preview_items.extend(entry["item_name"] for entry in slot_entries[:2])
|
||||||
|
cards.append(
|
||||||
|
{
|
||||||
|
"date": current_day,
|
||||||
|
"filled_dayparts": filled_dayparts,
|
||||||
|
"planned_count": planned_count,
|
||||||
|
"preview_items": preview_items[:4],
|
||||||
|
"slots": slots,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return cards
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_items(items: list[dict], limit: int = 6) -> list[dict]:
|
||||||
|
seen_ids = set()
|
||||||
|
result = []
|
||||||
|
for item in items:
|
||||||
|
if item["id"] in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(item["id"])
|
||||||
|
result.append(item)
|
||||||
|
if len(result) >= limit:
|
||||||
|
break
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_recent_plan_items(daypart_id: int, limit: int = 6):
|
||||||
|
rows = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT items.id, items.name, items.kind, items.photo_filename, items.availability_state
|
||||||
|
FROM plan_entries
|
||||||
|
JOIN items ON items.id = plan_entries.item_id
|
||||||
|
WHERE plan_entries.daypart_id = ?
|
||||||
|
ORDER BY plan_entries.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(daypart_id, limit * 3),
|
||||||
|
).fetchall()
|
||||||
|
return attach_components(attach_dayparts(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_plan_candidates(daypart_id: int, query: str | None = None):
|
||||||
|
params = [daypart_id]
|
||||||
|
conditions = ["items.availability_state != 'archived'"]
|
||||||
|
if query:
|
||||||
|
conditions.append("LOWER(items.name) LIKE ?")
|
||||||
|
params.append(f"%{query.lower()}%")
|
||||||
|
|
||||||
|
where_clause = f"WHERE {' AND '.join(conditions)}"
|
||||||
|
rows = get_db().execute(
|
||||||
|
f"""
|
||||||
|
SELECT items.*,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM item_dayparts
|
||||||
|
WHERE item_dayparts.item_id = items.id AND item_dayparts.daypart_id = ?
|
||||||
|
) AS matches_daypart
|
||||||
|
FROM items
|
||||||
|
{where_clause}
|
||||||
|
ORDER BY
|
||||||
|
CASE items.availability_state WHEN 'home' THEN 0 WHEN 'idea' THEN 1 ELSE 2 END,
|
||||||
|
matches_daypart DESC,
|
||||||
|
LOWER(items.name)
|
||||||
|
""",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return attach_components(attach_dayparts(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def build_home_sections(items: list[dict], dayparts: list, selected_daypart_id: int | None):
|
||||||
|
sections = []
|
||||||
|
if selected_daypart_id:
|
||||||
|
selected_daypart = next((daypart for daypart in dayparts if daypart["id"] == selected_daypart_id), None)
|
||||||
|
matching_items = [item for item in items if any(dp["id"] == selected_daypart_id for dp in item["dayparts_meta"])]
|
||||||
|
sections.append(
|
||||||
|
{
|
||||||
|
"title": selected_daypart["name"] if selected_daypart else "Ausgewählte Tageszeit",
|
||||||
|
"items": matching_items,
|
||||||
|
"slug": selected_daypart["slug"] if selected_daypart else "selected",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sections
|
||||||
|
|
||||||
|
for daypart in dayparts:
|
||||||
|
matching_items = [item for item in items if any(dp["id"] == daypart["id"] for dp in item["dayparts_meta"])]
|
||||||
|
sections.append(
|
||||||
|
{
|
||||||
|
"title": daypart["name"],
|
||||||
|
"items": matching_items,
|
||||||
|
"slug": daypart["slug"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
anytime_items = [item for item in items if not item["dayparts_meta"]]
|
||||||
|
if anytime_items:
|
||||||
|
sections.append(
|
||||||
|
{
|
||||||
|
"title": "Ohne feste Tageszeit",
|
||||||
|
"items": anytime_items,
|
||||||
|
"slug": "anytime",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def build_day_planner_sections(selected_date: date, selected_item_id: int | None, selected_daypart_id: int | None):
|
||||||
|
dayparts = get_dayparts()
|
||||||
|
day_entries = fetch_day_plan_entries(selected_date)
|
||||||
|
sections = []
|
||||||
|
|
||||||
|
for daypart in dayparts:
|
||||||
|
candidates = fetch_plan_candidates(daypart["id"])
|
||||||
|
home_candidates = [item for item in candidates if item["availability_state"] == "home"]
|
||||||
|
matching_candidates = [
|
||||||
|
item for item in candidates
|
||||||
|
if any(meta["id"] == daypart["id"] for meta in item["dayparts_meta"])
|
||||||
|
]
|
||||||
|
recent_candidates = fetch_recent_plan_items(daypart["id"])
|
||||||
|
quick_items = dedupe_items(home_candidates + recent_candidates + matching_candidates, limit=6)
|
||||||
|
sections.append(
|
||||||
|
{
|
||||||
|
"daypart": daypart,
|
||||||
|
"entries": day_entries.get((selected_date.isoformat(), daypart["id"]), []),
|
||||||
|
"candidates": candidates,
|
||||||
|
"quick_items": quick_items,
|
||||||
|
"selected_item_id": selected_item_id if selected_daypart_id == daypart["id"] else None,
|
||||||
|
"is_open": selected_daypart_id == daypart["id"],
|
||||||
|
"summary_items": [entry["item_name"] for entry in day_entries.get((selected_date.isoformat(), daypart["id"]), [])][:2],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
@main_bp.get("/")
|
@main_bp.get("/")
|
||||||
@login_required
|
@login_required
|
||||||
def dashboard():
|
def dashboard():
|
||||||
database = get_db()
|
database = get_db()
|
||||||
today = date.today().isoformat()
|
today = date.today()
|
||||||
home_count = database.execute(
|
home_count = database.execute(
|
||||||
"SELECT COUNT(*) AS count FROM items WHERE availability_state = 'home'"
|
"SELECT COUNT(*) AS count FROM items WHERE availability_state = 'home'"
|
||||||
).fetchone()["count"]
|
).fetchone()["count"]
|
||||||
@@ -305,6 +588,7 @@ def dashboard():
|
|||||||
SELECT plan_entries.id,
|
SELECT plan_entries.id,
|
||||||
items.name AS item_name,
|
items.name AS item_name,
|
||||||
items.kind AS item_kind,
|
items.kind AS item_kind,
|
||||||
|
items.availability_state,
|
||||||
dayparts.name AS daypart_name
|
dayparts.name AS daypart_name
|
||||||
FROM plan_entries
|
FROM plan_entries
|
||||||
JOIN items ON items.id = plan_entries.item_id
|
JOIN items ON items.id = plan_entries.item_id
|
||||||
@@ -312,8 +596,9 @@ def dashboard():
|
|||||||
WHERE plan_entries.plan_date = ?
|
WHERE plan_entries.plan_date = ?
|
||||||
ORDER BY dayparts.sort_order, items.name
|
ORDER BY dayparts.sort_order, items.name
|
||||||
""",
|
""",
|
||||||
(today,),
|
(today.isoformat(),),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
week_cards = fetch_week_cards(today - timedelta(days=today.weekday()))
|
||||||
home_items = fetch_items(availability="home")
|
home_items = fetch_items(availability="home")
|
||||||
return render_template(
|
return render_template(
|
||||||
"dashboard.html",
|
"dashboard.html",
|
||||||
@@ -323,6 +608,7 @@ def dashboard():
|
|||||||
today_entries=today_entries,
|
today_entries=today_entries,
|
||||||
home_items=home_items[:8],
|
home_items=home_items[:8],
|
||||||
today=today,
|
today=today,
|
||||||
|
week_cards=week_cards[:3],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -331,12 +617,29 @@ def dashboard():
|
|||||||
def item_list(kind: str):
|
def item_list(kind: str):
|
||||||
if kind not in ITEM_KIND_LABELS:
|
if kind not in ITEM_KIND_LABELS:
|
||||||
return redirect(url_for("main.dashboard"))
|
return redirect(url_for("main.dashboard"))
|
||||||
items = fetch_items(kind=kind)
|
|
||||||
|
query = request.args.get("q", "").strip()
|
||||||
|
state = request.args.get("state", "").strip()
|
||||||
|
raw_daypart_id = request.args.get("daypart_id", "").strip()
|
||||||
|
daypart_id = int(raw_daypart_id) if raw_daypart_id.isdigit() else None
|
||||||
|
|
||||||
|
items = fetch_items(
|
||||||
|
kind=kind,
|
||||||
|
availability=state or None,
|
||||||
|
query=query or None,
|
||||||
|
daypart_id=daypart_id,
|
||||||
|
)
|
||||||
return render_template(
|
return render_template(
|
||||||
"items/list.html",
|
"items/list.html",
|
||||||
kind=kind,
|
kind=kind,
|
||||||
items=items,
|
items=items,
|
||||||
availability_labels=AVAILABILITY_LABELS,
|
availability_labels=AVAILABILITY_LABELS,
|
||||||
|
query=query,
|
||||||
|
selected_state=state,
|
||||||
|
selected_daypart_id=daypart_id,
|
||||||
|
dayparts=get_dayparts(),
|
||||||
|
state_options=ACTIVE_STATE_OPTIONS,
|
||||||
|
today=date.today(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -349,35 +652,55 @@ def item_create(kind: str):
|
|||||||
database = get_db()
|
database = get_db()
|
||||||
dayparts = get_dayparts()
|
dayparts = get_dayparts()
|
||||||
foods = fetch_food_options()
|
foods = fetch_food_options()
|
||||||
|
food_groups = group_items_by_availability(foods)
|
||||||
form_data = {
|
form_data = {
|
||||||
"name": "",
|
"name": "",
|
||||||
"category": "",
|
"category": "",
|
||||||
"note": "",
|
"note": "",
|
||||||
"daypart_ids": [],
|
"daypart_ids": [],
|
||||||
"component_ids": [],
|
"component_ids": [],
|
||||||
|
"quick_food_name": "",
|
||||||
|
"quick_food_category": "",
|
||||||
|
"quick_food_note": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
name = request.form.get("name", "").strip()
|
form_action = request.form.get("form_action", "save_item")
|
||||||
category = request.form.get("category", "").strip()
|
form_data.update(extract_item_form_data())
|
||||||
note = request.form.get("note", "").strip()
|
name = form_data["name"]
|
||||||
daypart_ids = [int(value) for value in request.form.getlist("daypart_ids")]
|
category = form_data["category"]
|
||||||
component_ids = [int(value) for value in request.form.getlist("component_ids")]
|
note = form_data["note"]
|
||||||
form_data.update(
|
daypart_ids = form_data["daypart_ids"]
|
||||||
{
|
component_ids = form_data["component_ids"]
|
||||||
"name": name,
|
|
||||||
"category": category,
|
if kind == "meal" and form_action == "quick_add_food":
|
||||||
"note": note,
|
if not form_data["quick_food_name"]:
|
||||||
"daypart_ids": daypart_ids,
|
flash("Bitte einen Namen für das neue Lebensmittel eintragen.", "error")
|
||||||
"component_ids": component_ids,
|
else:
|
||||||
}
|
new_food_id = create_quick_food_from_form(form_data)
|
||||||
)
|
if new_food_id not in form_data["component_ids"]:
|
||||||
|
form_data["component_ids"].append(new_food_id)
|
||||||
|
form_data["component_ids"] = sorted(form_data["component_ids"])
|
||||||
|
form_data["quick_food_name"] = ""
|
||||||
|
form_data["quick_food_category"] = ""
|
||||||
|
form_data["quick_food_note"] = ""
|
||||||
|
foods = fetch_food_options()
|
||||||
|
food_groups = group_items_by_availability(foods)
|
||||||
|
flash("Das neue Lebensmittel wurde angelegt und direkt zur Mahlzeitenidee hinzugefügt.", "success")
|
||||||
|
return render_template(
|
||||||
|
"items/form.html",
|
||||||
|
kind=kind,
|
||||||
|
item=None,
|
||||||
|
dayparts=dayparts,
|
||||||
|
foods=foods,
|
||||||
|
food_groups=food_groups,
|
||||||
|
categories=CATEGORIES,
|
||||||
|
form_data=form_data,
|
||||||
|
)
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
if not name:
|
if not name:
|
||||||
error = "Bitte einen Namen eintragen."
|
error = "Bitte einen Namen eintragen."
|
||||||
elif kind == "meal" and not component_ids:
|
|
||||||
error = "Bitte mindestens ein Lebensmittel fuer die Mahlzeitenidee waehlen."
|
|
||||||
|
|
||||||
photo_filename = None
|
photo_filename = None
|
||||||
if error is None:
|
if error is None:
|
||||||
@@ -410,6 +733,7 @@ def item_create(kind: str):
|
|||||||
item=None,
|
item=None,
|
||||||
dayparts=dayparts,
|
dayparts=dayparts,
|
||||||
foods=foods,
|
foods=foods,
|
||||||
|
food_groups=food_groups,
|
||||||
categories=CATEGORIES,
|
categories=CATEGORIES,
|
||||||
form_data=form_data,
|
form_data=form_data,
|
||||||
)
|
)
|
||||||
@@ -423,35 +747,55 @@ def item_edit(item_id: int):
|
|||||||
kind = item["kind"]
|
kind = item["kind"]
|
||||||
dayparts = get_dayparts()
|
dayparts = get_dayparts()
|
||||||
foods = fetch_food_options()
|
foods = fetch_food_options()
|
||||||
|
food_groups = group_items_by_availability(foods)
|
||||||
form_data = {
|
form_data = {
|
||||||
"name": item["name"],
|
"name": item["name"],
|
||||||
"category": item["category"] or "",
|
"category": item["category"] or "",
|
||||||
"note": item["note"] or "",
|
"note": item["note"] or "",
|
||||||
"daypart_ids": get_item_daypart_ids(item_id),
|
"daypart_ids": get_item_daypart_ids(item_id),
|
||||||
"component_ids": get_meal_component_ids(item_id) if kind == "meal" else [],
|
"component_ids": get_meal_component_ids(item_id) if kind == "meal" else [],
|
||||||
|
"quick_food_name": "",
|
||||||
|
"quick_food_category": "",
|
||||||
|
"quick_food_note": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
name = request.form.get("name", "").strip()
|
form_action = request.form.get("form_action", "save_item")
|
||||||
category = request.form.get("category", "").strip()
|
form_data.update(extract_item_form_data())
|
||||||
note = request.form.get("note", "").strip()
|
name = form_data["name"]
|
||||||
daypart_ids = [int(value) for value in request.form.getlist("daypart_ids")]
|
category = form_data["category"]
|
||||||
component_ids = [int(value) for value in request.form.getlist("component_ids")]
|
note = form_data["note"]
|
||||||
form_data.update(
|
daypart_ids = form_data["daypart_ids"]
|
||||||
{
|
component_ids = form_data["component_ids"]
|
||||||
"name": name,
|
|
||||||
"category": category,
|
if kind == "meal" and form_action == "quick_add_food":
|
||||||
"note": note,
|
if not form_data["quick_food_name"]:
|
||||||
"daypart_ids": daypart_ids,
|
flash("Bitte einen Namen für das neue Lebensmittel eintragen.", "error")
|
||||||
"component_ids": component_ids,
|
else:
|
||||||
}
|
new_food_id = create_quick_food_from_form(form_data)
|
||||||
)
|
if new_food_id not in form_data["component_ids"]:
|
||||||
|
form_data["component_ids"].append(new_food_id)
|
||||||
|
form_data["component_ids"] = sorted(form_data["component_ids"])
|
||||||
|
form_data["quick_food_name"] = ""
|
||||||
|
form_data["quick_food_category"] = ""
|
||||||
|
form_data["quick_food_note"] = ""
|
||||||
|
foods = fetch_food_options()
|
||||||
|
food_groups = group_items_by_availability(foods)
|
||||||
|
flash("Das neue Lebensmittel wurde angelegt und direkt zur Mahlzeitenidee hinzugefügt.", "success")
|
||||||
|
return render_template(
|
||||||
|
"items/form.html",
|
||||||
|
kind=kind,
|
||||||
|
item=item,
|
||||||
|
dayparts=dayparts,
|
||||||
|
foods=foods,
|
||||||
|
food_groups=food_groups,
|
||||||
|
categories=CATEGORIES,
|
||||||
|
form_data=form_data,
|
||||||
|
)
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
if not name:
|
if not name:
|
||||||
error = "Bitte einen Namen eintragen."
|
error = "Bitte einen Namen eintragen."
|
||||||
elif kind == "meal" and not component_ids:
|
|
||||||
error = "Bitte mindestens ein Lebensmittel fuer die Mahlzeitenidee waehlen."
|
|
||||||
|
|
||||||
photo_filename = item["photo_filename"]
|
photo_filename = item["photo_filename"]
|
||||||
if error is None:
|
if error is None:
|
||||||
@@ -484,6 +828,7 @@ def item_edit(item_id: int):
|
|||||||
item=item,
|
item=item,
|
||||||
dayparts=dayparts,
|
dayparts=dayparts,
|
||||||
foods=foods,
|
foods=foods,
|
||||||
|
food_groups=food_groups,
|
||||||
categories=CATEGORIES,
|
categories=CATEGORIES,
|
||||||
form_data=form_data,
|
form_data=form_data,
|
||||||
)
|
)
|
||||||
@@ -533,7 +878,7 @@ def item_archive(item_id: int):
|
|||||||
(g.user["id"], item_id),
|
(g.user["id"], item_id),
|
||||||
)
|
)
|
||||||
database.commit()
|
database.commit()
|
||||||
flash(f"{item['name']} liegt jetzt im Archiv und bleibt spaeter leicht wiederfindbar.", "info")
|
flash(f"{item['name']} liegt jetzt im Archiv und bleibt später leicht wiederfindbar.", "info")
|
||||||
return redirect(request.referrer or url_for("main.archive_view"))
|
return redirect(request.referrer or url_for("main.archive_view"))
|
||||||
|
|
||||||
|
|
||||||
@@ -563,7 +908,7 @@ def shopping_list():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
selected_item_id = request.form.get("item_id", "").strip()
|
selected_item_id = request.form.get("item_id", "").strip()
|
||||||
if not selected_item_id:
|
if not selected_item_id:
|
||||||
flash("Bitte zuerst etwas auswaehlen.", "error")
|
flash("Bitte zuerst etwas auswählen.", "error")
|
||||||
else:
|
else:
|
||||||
item = get_item(int(selected_item_id))
|
item = get_item(int(selected_item_id))
|
||||||
added = add_to_shopping_list(item["id"], g.user["id"])
|
added = add_to_shopping_list(item["id"], g.user["id"])
|
||||||
@@ -636,89 +981,162 @@ def shopping_remove(entry_id: int):
|
|||||||
@main_bp.get("/home")
|
@main_bp.get("/home")
|
||||||
@login_required
|
@login_required
|
||||||
def home_view():
|
def home_view():
|
||||||
items = fetch_items(availability="home")
|
query = request.args.get("q", "").strip()
|
||||||
grouped = defaultdict(list)
|
raw_daypart_id = request.args.get("daypart_id", "").strip()
|
||||||
for item in items:
|
daypart_id = int(raw_daypart_id) if raw_daypart_id.isdigit() else None
|
||||||
key = item["dayparts"][0] if item["dayparts"] else "Ohne feste Tageszeit"
|
dayparts = get_dayparts()
|
||||||
grouped[key].append(item)
|
items = fetch_items(
|
||||||
return render_template("home/list.html", grouped=grouped)
|
availability="home",
|
||||||
|
query=query or None,
|
||||||
|
daypart_id=daypart_id,
|
||||||
|
)
|
||||||
|
sections = build_home_sections(items, dayparts, daypart_id)
|
||||||
|
return render_template(
|
||||||
|
"home/list.html",
|
||||||
|
sections=sections,
|
||||||
|
query=query,
|
||||||
|
dayparts=dayparts,
|
||||||
|
selected_daypart_id=daypart_id,
|
||||||
|
today=date.today(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@main_bp.get("/archive")
|
@main_bp.get("/archive")
|
||||||
@login_required
|
@login_required
|
||||||
def archive_view():
|
def archive_view():
|
||||||
items = fetch_archive_items()
|
query = request.args.get("q", "").strip()
|
||||||
return render_template("archive/list.html", items=items)
|
selected_kind = request.args.get("kind", "").strip()
|
||||||
|
kind = selected_kind if selected_kind in ITEM_KIND_LABELS else None
|
||||||
|
items = fetch_items(
|
||||||
|
kind=kind,
|
||||||
|
availability="archived",
|
||||||
|
include_archived=True,
|
||||||
|
query=query or None,
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
"archive/list.html",
|
||||||
|
items=items,
|
||||||
|
query=query,
|
||||||
|
selected_kind=selected_kind,
|
||||||
|
kind_options=KIND_FILTER_OPTIONS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/planner", methods=("GET", "POST"))
|
@main_bp.get("/planner")
|
||||||
@login_required
|
@login_required
|
||||||
def planner():
|
def planner():
|
||||||
database = get_db()
|
week_start = parse_week_start(request.args.get("week"))
|
||||||
week_start = parse_week_start(request.values.get("week"))
|
return render_template(
|
||||||
|
"planner/week.html",
|
||||||
|
week_start=week_start,
|
||||||
|
week_end=week_start + timedelta(days=6),
|
||||||
|
prev_week=week_start - timedelta(days=7),
|
||||||
|
next_week=week_start + timedelta(days=7),
|
||||||
|
week_cards=fetch_week_cards(week_start),
|
||||||
|
today=date.today(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/planner/day", methods=("GET", "POST"))
|
||||||
|
@login_required
|
||||||
|
def planner_day():
|
||||||
|
selected_date = parse_plan_date(request.values.get("date"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
try:
|
item_id_raw = request.form.get("item_id", "").strip()
|
||||||
selected_date = datetime.strptime(request.form.get("plan_date", ""), "%Y-%m-%d").date()
|
daypart_id_raw = request.form.get("daypart_id", "").strip()
|
||||||
except ValueError:
|
|
||||||
selected_date = None
|
|
||||||
|
|
||||||
item_id = request.form.get("item_id", "").strip()
|
|
||||||
daypart_id = request.form.get("daypart_id", "").strip()
|
|
||||||
note = request.form.get("note", "").strip()
|
note = request.form.get("note", "").strip()
|
||||||
|
selected_date = parse_plan_date(request.form.get("plan_date"))
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
if selected_date is None:
|
if not item_id_raw:
|
||||||
error = "Bitte einen gueltigen Tag auswaehlen."
|
error = "Bitte etwas für den Tagesplan auswählen."
|
||||||
elif not item_id:
|
elif not daypart_id_raw:
|
||||||
error = "Bitte etwas fuer den Plan waehlen."
|
error = "Bitte eine Tageszeit auswählen."
|
||||||
elif not daypart_id:
|
|
||||||
error = "Bitte eine Tageszeit waehlen."
|
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
database.execute(
|
item_id = int(item_id_raw)
|
||||||
|
daypart_id = int(daypart_id_raw)
|
||||||
|
get_db().execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO plan_entries (plan_date, daypart_id, item_id, note, created_by)
|
INSERT INTO plan_entries (plan_date, daypart_id, item_id, note, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(selected_date.isoformat(), int(daypart_id), int(item_id), note, g.user["id"]),
|
(selected_date.isoformat(), daypart_id, item_id, note, g.user["id"]),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
if ensure_planned_item_is_shopped(item_id, g.user["id"]):
|
||||||
|
flash("Der Eintrag ist noch nicht zuhause und wurde zusätzlich auf die Einkaufsliste gesetzt.", "info")
|
||||||
|
flash("Der Eintrag wurde in den Tagesplan gelegt.", "success")
|
||||||
|
return redirect(
|
||||||
|
f"{url_for('main.planner_day', date=selected_date.isoformat(), daypart_id=daypart_id)}#daypart-{daypart_id}"
|
||||||
)
|
)
|
||||||
database.commit()
|
|
||||||
flash("Der Eintrag wurde in den Wochenplan gelegt.", "success")
|
|
||||||
else:
|
|
||||||
flash(error, "error")
|
|
||||||
|
|
||||||
return redirect(url_for("main.planner", week=week_start.isoformat()))
|
flash(error, "error")
|
||||||
|
|
||||||
days = [week_start + timedelta(days=index) for index in range(7)]
|
selected_item_raw = request.args.get("item_id", "").strip()
|
||||||
dayparts = get_dayparts()
|
selected_daypart_raw = request.args.get("daypart_id", "").strip()
|
||||||
entries = planner_entries_for_week(week_start)
|
selected_item_id = int(selected_item_raw) if selected_item_raw.isdigit() else None
|
||||||
selectable_items = database.execute(
|
selected_daypart_id = int(selected_daypart_raw) if selected_daypart_raw.isdigit() else None
|
||||||
"""
|
sections = build_day_planner_sections(selected_date, selected_item_id, selected_daypart_id)
|
||||||
SELECT id, name, kind, availability_state
|
|
||||||
FROM items
|
|
||||||
WHERE availability_state != 'archived'
|
|
||||||
ORDER BY CASE availability_state WHEN 'home' THEN 0 ELSE 1 END, LOWER(name)
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"planner/week.html",
|
"planner/day.html",
|
||||||
week_start=week_start,
|
selected_date=selected_date,
|
||||||
prev_week=week_start - timedelta(days=7),
|
previous_day=selected_date - timedelta(days=1),
|
||||||
next_week=week_start + timedelta(days=7),
|
next_day=selected_date + timedelta(days=1),
|
||||||
days=days,
|
sections=sections,
|
||||||
dayparts=dayparts,
|
today=date.today(),
|
||||||
entries=entries,
|
|
||||||
selectable_items=selectable_items,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@main_bp.post("/planner/<int:entry_id>/remove")
|
@main_bp.post("/planner/<int:entry_id>/remove")
|
||||||
@login_required
|
@login_required
|
||||||
def planner_remove(entry_id: int):
|
def planner_remove(entry_id: int):
|
||||||
database = get_db()
|
selected_date = request.args.get("date", "")
|
||||||
week = request.args.get("week")
|
get_db().execute("DELETE FROM plan_entries WHERE id = ?", (entry_id,))
|
||||||
database.execute("DELETE FROM plan_entries WHERE id = ?", (entry_id,))
|
get_db().commit()
|
||||||
database.commit()
|
|
||||||
flash("Der Planeintrag wurde entfernt.", "info")
|
flash("Der Planeintrag wurde entfernt.", "info")
|
||||||
return redirect(url_for("main.planner", week=week))
|
if selected_date:
|
||||||
|
return redirect(url_for("main.planner_day", date=selected_date))
|
||||||
|
return redirect(url_for("main.planner"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.post("/planner/<int:entry_id>/move")
|
||||||
|
@login_required
|
||||||
|
def planner_move(entry_id: int):
|
||||||
|
target_date = parse_plan_date(request.form.get("target_date"))
|
||||||
|
target_daypart_raw = request.form.get("target_daypart_id", "").strip()
|
||||||
|
|
||||||
|
if not target_daypart_raw.isdigit():
|
||||||
|
return jsonify({"ok": False, "error": "Ungültige Tageszeit"}), 400
|
||||||
|
|
||||||
|
database = get_db()
|
||||||
|
entry = database.execute(
|
||||||
|
"SELECT * FROM plan_entries WHERE id = ?",
|
||||||
|
(entry_id,),
|
||||||
|
).fetchone()
|
||||||
|
if entry is None:
|
||||||
|
return jsonify({"ok": False, "error": "Eintrag nicht gefunden"}), 404
|
||||||
|
|
||||||
|
target_daypart_id = int(target_daypart_raw)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE plan_entries
|
||||||
|
SET plan_date = ?, daypart_id = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(target_date.isoformat(), target_daypart_id, entry_id),
|
||||||
|
)
|
||||||
|
database.commit()
|
||||||
|
|
||||||
|
# Reuse the same shopping safeguard as the day planner after drag-and-drop moves.
|
||||||
|
was_added_to_shopping = ensure_planned_item_is_shopped(entry["item_id"], g.user["id"])
|
||||||
|
if was_added_to_shopping:
|
||||||
|
flash("Der verschobene Eintrag ist noch nicht zuhause und wurde auf die Einkaufsliste gesetzt.", "info")
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"added_to_shopping": was_added_to_shopping,
|
||||||
|
"redirect_url": url_for("main.planner", week=parse_week_start(target_date.isoformat()).isoformat()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -76,3 +76,15 @@ CREATE TABLE IF NOT EXISTS plan_entries (
|
|||||||
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_kind_name
|
||||||
|
ON items (kind, name);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_availability_name
|
||||||
|
ON items (availability_state, name);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item
|
||||||
|
ON item_dayparts (daypart_id, item_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_entries_plan_date_daypart
|
||||||
|
ON plan_entries (plan_date, daypart_id);
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffd7be"/>
|
||||||
|
<stop offset="100%" stop-color="#e39a63"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#b5dfc8"/>
|
||||||
|
<stop offset="100%" stop-color="#72a98b"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="18" fill="url(#bg)"/>
|
||||||
|
<path d="M16 34c0-3 2.4-5.4 5.4-5.4h21.2c3 0 5.4 2.4 5.4 5.4 0 9.2-7.4 16.6-16.6 16.6h-.8C23.4 50.6 16 43.2 16 34z" fill="#fff9f4"/>
|
||||||
|
<path d="M21 25c2.4-6.7 7.2-10.6 11-10.6S40.6 18.3 43 25" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="4"/>
|
||||||
|
<path d="M40 12c5 .4 9 5 9 10.1 0 .5 0 1-.1 1.5-.8-.7-1.8-1.2-2.9-1.5-2.7-.8-4.3-3.3-4.2-6.1 0-1.3-.6-2.6-1.8-4z" fill="url(#leaf)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 914 B |
@@ -0,0 +1,21 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title">
|
||||||
|
<title>Nouri</title>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffd7be"/>
|
||||||
|
<stop offset="55%" stop-color="#f5b17a"/>
|
||||||
|
<stop offset="100%" stop-color="#d58c57"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="leaf" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#b5dfc8"/>
|
||||||
|
<stop offset="100%" stop-color="#70aa87"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="256" height="256" rx="64" fill="url(#bg)"/>
|
||||||
|
<circle cx="128" cy="128" r="96" fill="rgba(255,255,255,0.16)"/>
|
||||||
|
<path d="M68 132c0-9.9 8.1-18 18-18h84c9.9 0 18 8.1 18 18 0 31.8-25.8 57.6-57.6 57.6h-4.8C93.8 189.6 68 163.8 68 132z" fill="#fff9f4"/>
|
||||||
|
<path d="M84 105c7-21.3 24-34 44-34s37 12.7 44 34" fill="none" stroke="#fff9f4" stroke-linecap="round" stroke-width="14"/>
|
||||||
|
<path d="M156 55c15 1 27 14.7 27 30.6 0 1.6-.1 3.1-.3 4.6-1.9-2.4-4.5-4.2-7.5-5.1-8-2.5-13.1-10.2-12.7-18.5.1-4.1-1.8-8-5.2-11.6z" fill="url(#leaf)"/>
|
||||||
|
<path d="M129 143h41" stroke="#f0a46c" stroke-linecap="round" stroke-width="10"/>
|
||||||
|
<path d="M92 96l-10 62" stroke="#fff9f4" stroke-linecap="round" stroke-width="10"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,37 +1,43 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #f5f1e8;
|
--bg: #fff5ee;
|
||||||
--bg-elevated: rgba(255, 252, 246, 0.9);
|
--bg-elevated: rgba(255, 249, 244, 0.78);
|
||||||
--surface: #fffaf2;
|
--surface: rgba(255, 255, 255, 0.82);
|
||||||
--surface-strong: #ffffff;
|
--surface-strong: #fffdfa;
|
||||||
--surface-soft: #efe7d7;
|
--surface-soft: #fff0e3;
|
||||||
--line: rgba(74, 78, 72, 0.12);
|
--line: rgba(133, 113, 95, 0.12);
|
||||||
--text: #243028;
|
--text: #342e2d;
|
||||||
--muted: #66736a;
|
--muted: #7c716d;
|
||||||
--accent: #6a8b78;
|
--accent: #f0a46c;
|
||||||
--accent-strong: #476654;
|
--accent-strong: #dd8d52;
|
||||||
--accent-soft: rgba(106, 139, 120, 0.12);
|
--accent-soft: rgba(240, 164, 108, 0.18);
|
||||||
--warning-soft: rgba(196, 136, 92, 0.16);
|
--mint-soft: rgba(174, 214, 193, 0.24);
|
||||||
--shadow: 0 18px 40px rgba(44, 56, 46, 0.08);
|
--peach-soft: rgba(255, 210, 179, 0.24);
|
||||||
--radius: 20px;
|
--sky-soft: rgba(194, 213, 235, 0.2);
|
||||||
|
--rose-soft: rgba(237, 196, 205, 0.22);
|
||||||
|
--shadow: 0 20px 50px rgba(125, 92, 68, 0.10);
|
||||||
|
--radius: 22px;
|
||||||
--font-body: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
|
--font-body: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||||
--font-heading: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
|
--font-heading: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #1b211d;
|
--bg: #201d1d;
|
||||||
--bg-elevated: rgba(27, 33, 29, 0.92);
|
--bg-elevated: rgba(32, 29, 29, 0.82);
|
||||||
--surface: #222925;
|
--surface: rgba(45, 39, 39, 0.82);
|
||||||
--surface-strong: #29312c;
|
--surface-strong: #383131;
|
||||||
--surface-soft: #323b36;
|
--surface-soft: #433a39;
|
||||||
--line: rgba(224, 229, 223, 0.1);
|
--line: rgba(226, 232, 225, 0.1);
|
||||||
--text: #edf1ea;
|
--text: #f4efec;
|
||||||
--muted: #b6c0b6;
|
--muted: #cabeb7;
|
||||||
--accent: #9dbf9d;
|
--accent: #f2b07d;
|
||||||
--accent-strong: #b8d5b1;
|
--accent-strong: #ffc190;
|
||||||
--accent-soft: rgba(157, 191, 157, 0.15);
|
--accent-soft: rgba(242, 176, 125, 0.18);
|
||||||
--warning-soft: rgba(201, 148, 108, 0.22);
|
--mint-soft: rgba(155, 198, 175, 0.20);
|
||||||
|
--peach-soft: rgba(224, 161, 128, 0.18);
|
||||||
|
--sky-soft: rgba(146, 171, 201, 0.18);
|
||||||
|
--rose-soft: rgba(189, 133, 145, 0.20);
|
||||||
--shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
|
--shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,9 +54,10 @@ body {
|
|||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(178, 197, 168, 0.28), transparent 26rem),
|
radial-gradient(circle at top left, rgba(255, 205, 174, 0.42), transparent 24rem),
|
||||||
radial-gradient(circle at top right, rgba(238, 210, 177, 0.25), transparent 28rem),
|
radial-gradient(circle at 90% 8%, rgba(190, 226, 203, 0.34), transparent 24rem),
|
||||||
linear-gradient(180deg, var(--bg), color-mix(in srgb, var(--bg) 84%, #000 16%));
|
radial-gradient(circle at 40% 100%, rgba(255, 228, 205, 0.32), transparent 28rem),
|
||||||
|
linear-gradient(180deg, var(--bg), color-mix(in srgb, var(--bg) 92%, #f6decb 8%));
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -76,7 +83,7 @@ button,
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
padding: 0.8rem 1.1rem;
|
padding: 0.82rem 1.1rem;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
@@ -106,7 +113,7 @@ button.secondary:hover,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-shell {
|
.page-shell {
|
||||||
width: min(1200px, calc(100% - 2rem));
|
width: min(1320px, calc(100% - 2rem));
|
||||||
margin: 1rem auto 2rem;
|
margin: 1rem auto 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,9 +129,9 @@ button.secondary:hover,
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 24px;
|
border-radius: 28px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(26px) saturate(1.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
@@ -146,20 +153,84 @@ h1, h2, h3, .planner-label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
width: 2.5rem;
|
width: 2.7rem;
|
||||||
height: 2.5rem;
|
height: 2.7rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 0.9rem;
|
border-radius: 1rem;
|
||||||
background: linear-gradient(135deg, var(--accent), #d1b48f);
|
background: linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(255, 236, 219, 0.92));
|
||||||
color: white;
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.8);
|
||||||
font-weight: 700;
|
}
|
||||||
|
|
||||||
|
.brand-mark img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
display: inline-block;
|
||||||
|
background: currentColor;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
mask-position: center;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-house {
|
||||||
|
-webkit-mask-image: url("../icons/fa/house.svg");
|
||||||
|
mask-image: url("../icons/fa/house.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-utensils {
|
||||||
|
-webkit-mask-image: url("../icons/fa/utensils.svg");
|
||||||
|
mask-image: url("../icons/fa/utensils.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-bowl-food {
|
||||||
|
-webkit-mask-image: url("../icons/fa/bowl-food.svg");
|
||||||
|
mask-image: url("../icons/fa/bowl-food.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-cart-shopping {
|
||||||
|
-webkit-mask-image: url("../icons/fa/cart-shopping.svg");
|
||||||
|
mask-image: url("../icons/fa/cart-shopping.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-calendar {
|
||||||
|
-webkit-mask-image: url("../icons/fa/calendar.svg");
|
||||||
|
mask-image: url("../icons/fa/calendar.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-calendar-days {
|
||||||
|
-webkit-mask-image: url("../icons/fa/calendar-days.svg");
|
||||||
|
mask-image: url("../icons/fa/calendar-days.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-archive {
|
||||||
|
-webkit-mask-image: url("../icons/fa/archive.svg");
|
||||||
|
mask-image: url("../icons/fa/archive.svg");
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-sparkles {
|
||||||
|
-webkit-mask-image: url("../icons/fa/sparkles.svg");
|
||||||
|
mask-image: url("../icons/fa/sparkles.svg");
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav {
|
.site-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.45rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +244,7 @@ h1, h2, h3, .planner-label {
|
|||||||
.site-nav a:hover {
|
.site-nav a:hover {
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
@@ -193,17 +265,21 @@ h1, h2, h3, .planner-label {
|
|||||||
.stat-card,
|
.stat-card,
|
||||||
.item-card,
|
.item-card,
|
||||||
.list-row,
|
.list-row,
|
||||||
.planner-entry {
|
.planner-entry,
|
||||||
|
.week-card,
|
||||||
|
.week-mini-card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(18px) saturate(1.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero,
|
.hero,
|
||||||
.page-intro,
|
.page-intro,
|
||||||
.panel,
|
.panel,
|
||||||
.auth-card {
|
.auth-card,
|
||||||
|
.week-card {
|
||||||
padding: 1.35rem;
|
padding: 1.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,8 +289,8 @@ h1, h2, h3, .planner-label {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
background:
|
background:
|
||||||
linear-gradient(135deg, rgba(255, 255, 255, 0.35), transparent 45%),
|
linear-gradient(135deg, rgba(255, 255, 255, 0.52), transparent 45%),
|
||||||
linear-gradient(180deg, var(--surface), var(--surface-strong));
|
linear-gradient(180deg, color-mix(in srgb, var(--surface) 86%, #fff 14%), color-mix(in srgb, var(--surface) 80%, #ffe5d2 20%));
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
@@ -260,7 +336,9 @@ h3 {
|
|||||||
.stats-grid,
|
.stats-grid,
|
||||||
.two-column,
|
.two-column,
|
||||||
.card-grid,
|
.card-grid,
|
||||||
.mini-card-grid {
|
.mini-card-grid,
|
||||||
|
.week-mini-grid,
|
||||||
|
.week-overview-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
@@ -270,11 +348,21 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.two-column {
|
.two-column {
|
||||||
grid-template-columns: 1.1fr 0.9fr;
|
grid-template-columns: 1.05fr 0.95fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-mini-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-overview-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
padding: 1.15rem 1.2rem;
|
padding: 1.15rem 1.2rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--surface) 90%, #fff 10%));
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card span {
|
.stat-card span {
|
||||||
@@ -295,7 +383,9 @@ h3 {
|
|||||||
.row-actions,
|
.row-actions,
|
||||||
.hero-actions,
|
.hero-actions,
|
||||||
.form-actions,
|
.form-actions,
|
||||||
.week-nav {
|
.week-nav,
|
||||||
|
.week-card-head,
|
||||||
|
.planner-entry-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -328,13 +418,45 @@ h3 {
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-card {
|
.mini-card,
|
||||||
|
.week-mini-card {
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: var(--surface-strong);
|
background: var(--surface-strong);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.component-group {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-food-panel {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-food-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-food-grid .wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-mini-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
.chip-row {
|
.chip-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -354,19 +476,19 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-home {
|
.status-home {
|
||||||
background: rgba(96, 147, 114, 0.18);
|
background: rgba(121, 176, 144, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-archived {
|
.status-archived {
|
||||||
background: var(--warning-soft);
|
background: var(--peach-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-idea {
|
.status-idea {
|
||||||
background: rgba(130, 146, 151, 0.16);
|
background: var(--sky-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-grid {
|
.card-grid {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(310px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card {
|
.item-card {
|
||||||
@@ -434,12 +556,25 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stack-form label,
|
.stack-form label,
|
||||||
.planner-form label {
|
.planner-entry-form label,
|
||||||
|
.filter-form label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-check {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
color: var(--text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-check input[type="checkbox"] {
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
input[type="date"],
|
input[type="date"],
|
||||||
@@ -493,71 +628,202 @@ legend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inline-form,
|
.inline-form,
|
||||||
.planner-form {
|
.planner-entry-form,
|
||||||
|
.filter-form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.planner-form .wide {
|
.planner-entry-form,
|
||||||
|
.filter-form {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-form .wide,
|
||||||
|
.planner-entry-form .wide {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.list-row {
|
.list-row {
|
||||||
padding: 1rem 1.1rem;
|
padding: 1rem 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-actions {
|
.row-actions {
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack-sections {
|
.stack-sections,
|
||||||
|
.planner-day-stack,
|
||||||
|
.planner-entry-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.planner-grid {
|
.day-tile {
|
||||||
display: grid;
|
border-radius: 24px;
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.planner-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 180px repeat(7, minmax(0, 1fr));
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.planner-label {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: var(--surface-soft);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.planner-cell {
|
|
||||||
min-height: 150px;
|
|
||||||
padding: 0.8rem;
|
|
||||||
border-radius: 18px;
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.planner-date {
|
.day-tile > summary::-webkit-details-marker {
|
||||||
margin-bottom: 0.7rem;
|
display: none;
|
||||||
font-size: 0.9rem;
|
}
|
||||||
|
|
||||||
|
.day-tile summary {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-tile-summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.2rem 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-tile-summary-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-tile-icon {
|
||||||
|
width: 2.8rem;
|
||||||
|
height: 2.8rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: linear-gradient(145deg, rgba(255,255,255,0.92), var(--peach-soft));
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-tile-icon .ui-icon {
|
||||||
|
width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-tile-body {
|
||||||
|
padding: 0 1.25rem 1.25rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add-row form {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add-button {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
min-width: 180px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: color-mix(in srgb, var(--surface-strong) 76%, #fff 24%);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add-button:hover {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add-button small {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.planner-entry-stack {
|
.planner-entry {
|
||||||
display: grid;
|
padding: 0.9rem 1rem;
|
||||||
gap: 0.55rem;
|
border-radius: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.planner-entry {
|
.planner-entry-list .planner-entry {
|
||||||
padding: 0.75rem;
|
background: color-mix(in srgb, var(--surface) 88%, #fff 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-card-count {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
margin: 0.8rem 0 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-card-actions {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-slot-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-slot {
|
||||||
|
padding: 0.85rem;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: color-mix(in srgb, var(--surface-strong) 80%, #fff 20%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-slot.is-drag-over {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 60%, var(--line) 40%);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-slot-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-entry-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-chip {
|
||||||
|
padding: 0.7rem 0.8rem;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(255,246,239,0.92));
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
cursor: grab;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255,255,255,0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-chip:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-chip.is-dragging {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-chip small,
|
||||||
|
.week-slot-empty {
|
||||||
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flash-stack {
|
.flash-stack {
|
||||||
@@ -573,26 +839,27 @@ legend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.flash-success {
|
.flash-success {
|
||||||
background: rgba(111, 161, 122, 0.18);
|
background: rgba(121, 176, 144, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flash-error {
|
.flash-error {
|
||||||
background: rgba(195, 111, 98, 0.18);
|
background: rgba(210, 125, 115, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.flash-info {
|
.flash-info {
|
||||||
background: rgba(125, 150, 164, 0.18);
|
background: rgba(147, 179, 205, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
min-width: 5rem;
|
min-width: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 1080px) {
|
||||||
.site-header,
|
.site-header,
|
||||||
.hero,
|
.hero,
|
||||||
.page-intro,
|
.page-intro,
|
||||||
.panel-head {
|
.panel-head,
|
||||||
|
.week-card-head {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
@@ -604,20 +871,16 @@ legend {
|
|||||||
|
|
||||||
.stats-grid,
|
.stats-grid,
|
||||||
.two-column,
|
.two-column,
|
||||||
.planner-row,
|
|
||||||
.inline-form,
|
.inline-form,
|
||||||
.planner-form {
|
.planner-entry-form,
|
||||||
|
.filter-form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.planner-form .wide {
|
.filter-form .wide,
|
||||||
|
.planner-entry-form .wide {
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.planner-label {
|
|
||||||
position: sticky;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -633,11 +896,18 @@ legend {
|
|||||||
.header-actions,
|
.header-actions,
|
||||||
.item-card,
|
.item-card,
|
||||||
.list-row,
|
.list-row,
|
||||||
.row-actions {
|
.row-actions,
|
||||||
|
.quick-add-row,
|
||||||
|
.filter-actions {
|
||||||
justify-content: start;
|
justify-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card {
|
.item-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.week-nav {
|
||||||
|
align-items: start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M112.2 272.6C120.4 277.3 129.9 280 140 280L532 280C542.1 280 551.6 277.3 559.8 272.6C560 275 560 277.5 560 280L560 476C560 537.9 509.9 588 448 588L224 588C162.1 588 112 537.9 112 476L112 280C112 277.5 112.1 275.1 112.2 272.6zM118.6 242C134.1 198.8 175.5 168 224 168L448 168C496.5 168 537.9 198.9 553.4 242C548.3 248.1 540.6 252 532 252L140 252C131.4 252 123.7 248.1 118.6 242zM196 350C196 357.7 202.3 364 210 364L462 364C469.7 364 476 357.7 476 350C476 342.3 469.7 336 462 336L210 336C202.3 336 196 342.3 196 350z"/><path fill="currentColor" d="M140 112C124.5 112 112 124.5 112 140L112 224C112 239.5 124.5 252 140 252L532 252C547.5 252 560 239.5 560 224L560 140C560 124.5 547.5 112 532 112L140 112zM84 140C84 109.1 109.1 84 140 84L532 84C562.9 84 588 109.1 588 140L588 224C588 254.9 562.9 280 532 280L140 280C109.1 280 84 254.9 84 224L84 140zM210 336L462 336C469.7 336 476 342.3 476 350C476 357.7 469.7 364 462 364L210 364C202.3 364 196 357.7 196 350C196 342.3 202.3 336 210 336z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M96.1 352L305.7 352L189.9 481.4C186.5 475.9 181.5 471.6 175.5 468.9C130 449 97.9 404.3 96.1 352z"/><path fill="currentColor" d="M267.8 146.2C277.9 125.5 297.8 112 320 112C342.2 112 362.2 125.5 372.2 146.2C374.2 150.3 377.8 153.4 382.2 154.6C386.6 155.8 391.3 155.2 395.1 152.7C403.9 147.1 414 144 424.9 144C452.7 144 477 165.2 482.7 195C484.2 202.7 491 208.2 498.8 208C499.2 208 499.5 208 499.9 208C523.2 208 543.9 228.5 543.9 256C543.9 264.8 551.1 272 559.9 272C568.7 272 575.9 264.8 575.9 256C575.9 216.6 548.4 182.5 511 176.8C498.9 139.6 465.4 112 424.9 112C413.5 112 402.6 114.2 392.6 118.2C376.2 95.2 349.9 80 319.9 80C289.9 80 263.7 95.2 247.2 118.2C237.2 114.2 226.3 112 214.9 112C174.4 112 140.9 139.6 128.8 176.8C91.4 182.5 63.9 216.6 63.9 256C63.9 264.8 71.1 272 79.9 272C88.7 272 95.9 264.8 95.9 256C95.9 228.5 116.6 208 139.9 208C140.3 208 140.6 208 141 208C148.8 208.2 155.7 202.7 157.1 195C162.8 165.2 187.1 144 214.9 144C225.7 144 235.9 147.2 244.7 152.7C248.5 155.1 253.3 155.8 257.6 154.6C261.9 153.4 265.6 150.3 267.6 146.2zM193.3 489.1C190.6 480.1 184.1 472.7 175.5 468.9C130 449 97.9 404.3 96.1 352L543.9 352C542.1 404.3 510 449 464.5 468.9C455.9 472.7 449.3 480.1 446.7 489.1C442.8 502.3 430.5 512 416 512L224 512C209.5 512 197.3 502.4 193.3 489.1zM91.4 320C76.3 320 64 332.3 64 347.4C64 414.9 104.6 472.8 162.6 498.3C170.5 524.7 195 544 224 544L416 544C445 544 469.5 524.7 477.4 498.3C535.5 472.9 576 414.9 576 347.5C576 332.4 563.7 320.1 548.6 320.1L91.4 320z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M128 256L128 320L224 320L224 256L128 256zM128 352L128 416L224 416L224 352L128 352zM128 448L128 480C128 497.7 142.3 512 160 512L224 512L224 448L128 448zM256 256L256 320L384 320L384 256L256 256zM256 352L256 416L384 416L384 352L256 352zM256 448L256 512L384 512L384 448L256 448zM416 256L416 320L512 320L512 256L416 256zM416 352L416 416L512 416L512 352L416 352zM416 448L416 512L480 512C497.7 512 512 497.7 512 480L512 448L416 448z"/><path fill="currentColor" d="M208 64C216.8 64 224 71.2 224 80L224 128L416 128L416 80C416 71.2 423.2 64 432 64C440.8 64 448 71.2 448 80L448 128L480 128C515.3 128 544 156.7 544 192L544 480C544 515.3 515.3 544 480 544L160 544C124.7 544 96 515.3 96 480L96 192C96 156.7 124.7 128 160 128L192 128L192 80C192 71.2 199.2 64 208 64zM480 160L160 160C142.3 160 128 174.3 128 192L128 224L512 224L512 192C512 174.3 497.7 160 480 160zM512 256L416 256L416 320L512 320L512 256zM512 352L416 352L416 416L512 416L512 352zM512 448L416 448L416 512L480 512C497.7 512 512 497.7 512 480L512 448zM384 416L384 352L256 352L256 416L384 416zM256 448L256 512L384 512L384 448L256 448zM224 416L224 352L128 352L128 416L224 416zM128 448L128 480C128 497.7 142.3 512 160 512L224 512L224 448L128 448zM128 320L224 320L224 256L128 256L128 320zM256 320L384 320L384 256L256 256L256 320z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M84 224L84 252L588 252L588 224C588 162.1 537.9 112 476 112L469 112L469 140C469 147.7 462.7 154 455 154C447.3 154 441 147.7 441 140L441 112L224 112L224 140C224 147.7 217.7 154 210 154C202.3 154 196 147.7 196 140L196 112C134.1 112 84 162.1 84 224zM84 280L84 476C84 537.9 134.1 588 196 588L476 588C537.9 588 588 537.9 588 476L588 280L84 280z"/><path fill="currentColor" d="M210 56C202.3 56 196 62.3 196 70L196 140C196 147.7 202.3 154 210 154C217.7 154 224 147.7 224 140L224 70C224 62.3 217.7 56 210 56zM455 56C447.3 56 441 62.3 441 70L441 140C441 147.7 447.3 154 455 154C462.7 154 469 147.7 469 140L469 70C469 62.3 462.7 56 455 56zM84 252C76.3 252 70 258.3 70 266C70 273.7 76.3 280 84 280L588 280C595.7 280 602 273.7 602 266C602 258.3 595.7 252 588 252L84 252z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M160.7 140.2L202.6 412.4C203 415 203.6 417.6 204.5 420L499.9 420C535.2 420 566 396 574.6 361.7L614.5 202C615.5 198 616 194 616 189.9C616 162.4 593.7 140 566.1 140L162.1 140C161.6 140 161.1 140.1 160.6 140.2z"/><path fill="currentColor" d="M56 98C56 90.3 62.3 84 70 84L110 84C134.2 84 154.8 101.6 158.4 125.5L202.5 412.3C205.7 432.8 223.3 447.9 244 447.9L546 447.9C553.7 447.9 560 454.2 560 461.9C560 469.6 553.7 475.9 546 475.9L244 475.9C209.4 475.9 180.1 450.7 174.8 416.5L130.7 129.8C129.2 119.6 120.3 112 110 112L70 112C62.3 112 56 105.7 56 98zM252 588C267.5 588 280 575.5 280 560C280 544.5 267.5 532 252 532C236.5 532 224 544.5 224 560C224 575.5 236.5 588 252 588zM252 504C282.9 504 308 529.1 308 560C308 590.9 282.9 616 252 616C221.1 616 196 590.9 196 560C196 529.1 221.1 504 252 504zM532 560C532 544.5 519.5 532 504 532C488.5 532 476 544.5 476 560C476 575.5 488.5 588 504 588C519.5 588 532 575.5 532 560zM448 560C448 529.1 473.1 504 504 504C534.9 504 560 529.1 560 560C560 590.9 534.9 616 504 616C473.1 616 448 590.9 448 560z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M84 303.5L336 101.9L588 303.5L588 476C588 537.9 537.9 588 476 588L448 588L448 434C448 395.3 416.7 364 378 364L294 364C255.3 364 224 395.3 224 434L224 588L196 588C134.1 588 84 537.9 84 476L84 303.5zM252 434C252 410.8 270.8 392 294 392L378 392C401.2 392 420 410.8 420 434L420 588L252 588L252 434z"/><path fill="currentColor" d="M344.7 73.1C339.6 69 332.3 69 327.2 73.1L47.2 297.1C41.2 301.9 40.2 310.7 45 316.8C49.8 322.9 58.6 323.8 64.7 319L336 102L607.3 319C613.3 323.8 622.1 322.9 627 316.8C631.9 310.7 630.9 302 624.8 297.1L344.8 73.1zM252 434C252 410.8 270.8 392 294 392L378 392C401.2 392 420 410.8 420 434L420 588C420 595.7 426.3 602 434 602C441.7 602 448 595.7 448 588L448 434C448 395.3 416.7 364 378 364L294 364C255.3 364 224 395.3 224 434L224 588C224 595.7 230.3 602 238 602C245.7 602 252 595.7 252 588L252 434z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M86.2 320L175.1 361.1C178.6 362.7 181.3 365.5 182.9 368.9L224 457.8L265.1 368.9C266.7 365.5 269.5 362.7 272.9 361.1L361.8 320L272.9 278.9C269.4 277.3 266.7 274.5 265.1 271.1L224 182.2L182.9 271.1C181.3 274.6 178.5 277.3 175.1 278.9L86.2 320z"/><path fill="currentColor" d="M512 48C520.8 48 528 55.2 528 64L528 112L576 112C584.8 112 592 119.2 592 128C592 136.8 584.8 144 576 144L528 144L528 192C528 200.8 520.8 208 512 208C503.2 208 496 200.8 496 192L496 144L448 144C439.2 144 432 136.8 432 128C432 119.2 439.2 112 448 112L496 112L496 64C496 55.2 503.2 48 512 48zM224 128C230.2 128 235.9 131.6 238.5 137.3L291.6 252.3L406.6 305.4C412.3 308 415.9 313.7 415.9 319.9C415.9 326.1 412.3 331.8 406.6 334.4L291.6 387.5L238.5 502.5C235.9 508.2 230.2 511.8 224 511.8C217.8 511.8 212.1 508.2 209.5 502.5L156.4 387.5L41.4 334.4C35.6 331.9 32 326.2 32 320C32 313.8 35.6 308.1 41.3 305.5L156.3 252.4L209.4 137.4C212 131.7 217.7 128.1 223.9 128.1zM224 182.2L182.9 271.1C181.3 274.6 178.5 277.3 175.1 278.9L86.2 320L175.1 361.1C178.6 362.7 181.3 365.5 182.9 368.9L224 457.8L265.1 368.9C266.7 365.5 269.5 362.7 272.9 361.1L361.8 320L272.9 278.9C269.4 277.3 266.7 274.5 265.1 271.1L224 182.2zM496 448L496 496L544 496C552.8 496 560 503.2 560 512C560 520.8 552.8 528 544 528L496 528L496 576C496 584.8 488.8 592 480 592C471.2 592 464 584.8 464 576L464 528L416 528C407.2 528 400 520.8 400 512C400 503.2 407.2 496 416 496L464 496L464 448C464 439.2 471.2 432 480 432C488.8 432 496 439.2 496 448z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 672 672"><!--! Font Awesome Pro 7.0.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2025 Fonticons, Inc. --><path opacity=".4" fill="currentColor" d="M112 112L112 252C112 313.9 162.1 364 224 364L224 350C224 342.3 230.3 336 238 336C245.7 336 252 342.3 252 350L252 364C313.9 364 364 313.9 364 252L364 112C364 81.1 338.9 56 308 56L294 56L294 238C294 245.7 287.7 252 280 252C272.3 252 266 245.7 266 238L266 56L210 56L210 238C210 245.7 203.7 252 196 252C188.3 252 182 245.7 182 238L182 56L168 56C137.1 56 112 81.1 112 112zM420 182L420 392C420 422.9 445.1 448 476 448L532 448L532 70C532 62.4 538.1 56.2 545.7 56C476.3 56.2 420 112.5 420 182z"/><path fill="currentColor" d="M210 56C210 48.3 203.7 42 196 42C188.3 42 182 48.3 182 56L182 238C182 245.7 188.3 252 196 252C203.7 252 210 245.7 210 238L210 56zM294 56C294 48.3 287.7 42 280 42C272.3 42 266 48.3 266 56L266 238C266 245.7 272.3 252 280 252C287.7 252 294 245.7 294 238L294 56zM560 70C560 62.3 553.7 56 546 56C538.3 56 532 62.3 532 70L532 602C532 609.7 538.3 616 546 616C553.7 616 560 609.7 560 602L560 70zM238 336C230.3 336 224 342.3 224 350L224 602C224 609.7 230.3 616 238 616C245.7 616 252 609.7 252 602L252 350C252 342.3 245.7 336 238 336z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,80 @@
|
|||||||
|
(() => {
|
||||||
|
const getCsrfToken = () => {
|
||||||
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
return meta ? meta.getAttribute("content") : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const initWeekDragAndDrop = () => {
|
||||||
|
const board = document.querySelector(".week-board");
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
let draggedEntry = null;
|
||||||
|
|
||||||
|
board.querySelectorAll(".draggable-plan-entry").forEach((entry) => {
|
||||||
|
entry.addEventListener("dragstart", () => {
|
||||||
|
draggedEntry = entry;
|
||||||
|
entry.classList.add("is-dragging");
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.addEventListener("dragend", () => {
|
||||||
|
entry.classList.remove("is-dragging");
|
||||||
|
draggedEntry = null;
|
||||||
|
board.querySelectorAll(".drop-slot").forEach((slot) => slot.classList.remove("is-drag-over"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
board.querySelectorAll(".drop-slot").forEach((slot) => {
|
||||||
|
slot.addEventListener("dragover", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!draggedEntry) return;
|
||||||
|
slot.classList.add("is-drag-over");
|
||||||
|
});
|
||||||
|
|
||||||
|
slot.addEventListener("dragleave", () => {
|
||||||
|
slot.classList.remove("is-drag-over");
|
||||||
|
});
|
||||||
|
|
||||||
|
slot.addEventListener("drop", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
slot.classList.remove("is-drag-over");
|
||||||
|
if (!draggedEntry) return;
|
||||||
|
|
||||||
|
// Keep DnD lightweight: move on the server, then refresh into the canonical rendered state.
|
||||||
|
const moveUrl = draggedEntry.dataset.moveUrl;
|
||||||
|
const payload = new URLSearchParams({
|
||||||
|
csrf_token: getCsrfToken(),
|
||||||
|
target_date: slot.dataset.targetDate,
|
||||||
|
target_daypart_id: slot.dataset.targetDaypartId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(moveUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||||
|
"X-Requested-With": "XMLHttpRequest",
|
||||||
|
},
|
||||||
|
body: payload.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("move failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.redirect_url) {
|
||||||
|
window.location.href = result.redirect_url;
|
||||||
|
} else {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
initWeekDragAndDrop();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -4,11 +4,32 @@
|
|||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Archiv</p>
|
<p class="eyebrow">Archiv</p>
|
||||||
<h1>Fruehere Ideen bleiben greifbar</h1>
|
<h1>Frühere Ideen bleiben greifbar</h1>
|
||||||
<p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder auf die Einkaufsliste setzen.</p>
|
<p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder auf die Einkaufsliste setzen.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel compact-form-panel">
|
||||||
|
<form method="get" class="filter-form">
|
||||||
|
<label class="wide">
|
||||||
|
Suche
|
||||||
|
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Bereich
|
||||||
|
<select name="kind">
|
||||||
|
{% for value, label in kind_options %}
|
||||||
|
<option value="{{ value }}" {% if selected_kind == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button type="submit">Filtern</button>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.archive_view') }}">Zurücksetzen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
{% if items %}
|
{% if items %}
|
||||||
<section class="card-grid">
|
<section class="card-grid">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
@@ -42,6 +63,7 @@
|
|||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button type="submit">Wieder einkaufen</button>
|
<button type="submit">Wieder einkaufen</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>
|
||||||
<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">Zur aktiven Liste</button>
|
||||||
@@ -52,8 +74,8 @@
|
|||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="panel empty-panel">
|
<section class="panel empty-panel">
|
||||||
<h2>Das Archiv ist noch leer</h2>
|
<h2>Keine passenden Archiv-Einträge</h2>
|
||||||
<p>Sobald etwas als verbraucht markiert wird, bleibt es hier als spaetere Erinnerung erhalten.</p>
|
<p>Mit einer kurzen Suche findest du vertraute Dinge meist schnell wieder.</p>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="auth-shell">
|
<section class="auth-shell">
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<p class="eyebrow">Willkommen zurueck</p>
|
<p class="eyebrow">Willkommen zurück</p>
|
||||||
<h1>Ruhig wieder einsteigen</h1>
|
<h1>Ruhig wieder einsteigen</h1>
|
||||||
<p class="lead">Nouri hilft beim Erinnern, Sichtbar-Machen und Planen. Ohne Zahlen, ohne Druck.</p>
|
<p class="lead">Nouri hilft beim Erinnern, Sichtbar-Machen und Planen. Ohne Zahlen, ohne Druck.</p>
|
||||||
|
|
||||||
@@ -17,6 +17,10 @@
|
|||||||
Passwort
|
Passwort
|
||||||
<input type="password" name="password" autocomplete="current-password" required>
|
<input type="password" name="password" autocomplete="current-password" required>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="inline-check">
|
||||||
|
<input type="checkbox" name="remember_me" value="1">
|
||||||
|
<span>Angemeldet bleiben</span>
|
||||||
|
</label>
|
||||||
<button type="submit">Anmelden</button>
|
<button type="submit">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<p class="eyebrow">Erster Start</p>
|
<p class="eyebrow">Erster Start</p>
|
||||||
<h1>Den ersten Haushalt-Zugang anlegen</h1>
|
<h1>Den ersten Haushalt-Zugang anlegen</h1>
|
||||||
<p class="lead">Danach koennt ihr die App gemeinsam nutzen. Die Daten bleiben lokal in dieser Installation.</p>
|
<p class="lead">Danach könnt ihr die App gemeinsam nutzen. Die Daten bleiben lokal in dieser Installation.</p>
|
||||||
|
|
||||||
<form method="post" class="stack-form">
|
<form method="post" class="stack-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
|
|||||||
@@ -4,28 +4,34 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}Nouri{% endblock %}</title>
|
<title>{% block title %}Nouri{% endblock %}</title>
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token_value }}">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='brand/favicon.svg') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
||||||
<script defer src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
<script defer src="{{ url_for('static', filename='js/theme.js') }}"></script>
|
||||||
|
<script defer src="{{ url_for('static', filename='js/planner.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
||||||
<span class="brand-mark">N</span>
|
<span class="brand-mark">
|
||||||
|
<img src="{{ url_for('static', filename='brand/nouri-icon.svg') }}" alt="">
|
||||||
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>Nouri</strong>
|
<strong>Nouri</strong>
|
||||||
<small>freundliches Essensgedaechtnis</small>
|
<small>einfach essen planen</small>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{% if g.user %}
|
{% if g.user %}
|
||||||
<nav class="site-nav">
|
<nav class="site-nav">
|
||||||
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">Heute</a>
|
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-sparkles"></span><span>Heute</span></span></a>
|
||||||
<a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}">Lebensmittel</a>
|
<a href="{{ url_for('main.item_list', kind='food') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'food' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-utensils"></span><span>Lebensmittel</span></span></a>
|
||||||
<a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}">Mahlzeiten</a>
|
<a href="{{ url_for('main.item_list', kind='meal') }}" class="{{ 'active' if request.endpoint == 'main.item_list' and request.view_args and request.view_args.get('kind') == 'meal' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</span></span></a>
|
||||||
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}">Einkaufsliste</a>
|
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-cart-shopping"></span><span>Einkaufsliste</span></span></a>
|
||||||
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}">Zuhause</a>
|
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></a>
|
||||||
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}">Wochenplan</a>
|
<a href="{{ url_for('main.planner_day', date=today.isoformat() if today else None) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar"></span><span>Tagesplan</span></span></a>
|
||||||
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}">Archiv</a>
|
<a href="{{ url_for('main.planner') }}" class="{{ 'active' if request.endpoint == 'main.planner' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar-days"></span><span>Woche</span></span></a>
|
||||||
|
<a href="{{ url_for('main.archive_view') }}" class="{{ 'active' if request.endpoint == 'main.archive_view' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-archive"></span><span>Archiv</span></span></a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="theme-toggle" type="button" data-theme-toggle>Modus</button>
|
<button class="theme-toggle" type="button" data-theme-toggle>Modus</button>
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Heute</p>
|
<p class="eyebrow">Heute</p>
|
||||||
<h1>Ein ruhiger Blick auf das, was gerade hilft</h1>
|
<h1>Ein ruhiger Blick auf das, was gerade hilft</h1>
|
||||||
<p class="lead">Du siehst auf einen Blick, was zuhause da ist, was noch eingekauft werden soll und was heute schon eingeplant ist.</p>
|
<p class="lead">Du siehst auf einen Blick, was zuhause da ist, was schon eingeplant wurde und wo du schnell weitermachen kannst.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="button secondary" href="{{ url_for('main.item_create', kind='food') }}">Lebensmittel anlegen</a>
|
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
|
||||||
<a class="button secondary" href="{{ url_for('main.item_create', kind='meal') }}">Mahlzeitenidee anlegen</a>
|
<a class="button secondary" href="{{ url_for('main.item_create', kind='meal') }}">Mahlzeitenidee anlegen</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<span>Zuhause</span>
|
<span>Zuhause</span>
|
||||||
<strong>{{ home_count }}</strong>
|
<strong>{{ home_count }}</strong>
|
||||||
<small>sichtbare Eintraege</small>
|
<small>sichtbare Einträge</small>
|
||||||
</article>
|
</article>
|
||||||
<article class="stat-card">
|
<article class="stat-card">
|
||||||
<span>Einkaufsliste</span>
|
<span>Einkaufsliste</span>
|
||||||
@@ -35,19 +35,24 @@
|
|||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>Heute im Plan</h2>
|
<h2>Heute im Plan</h2>
|
||||||
<a href="{{ url_for('main.planner') }}">Wochenplan oeffnen</a>
|
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Zum Tagesplan</a>
|
||||||
</div>
|
</div>
|
||||||
{% if today_entries %}
|
{% if today_entries %}
|
||||||
<ul class="simple-list">
|
<ul class="simple-list">
|
||||||
{% for entry in today_entries %}
|
{% for entry in today_entries %}
|
||||||
<li>
|
<li>
|
||||||
<strong>{{ entry.daypart_name }}</strong>
|
<div>
|
||||||
<span>{{ entry.item_name }}</span>
|
<strong>{{ entry.daypart_name }}</strong>
|
||||||
|
<span>{{ entry.item_name }}</span>
|
||||||
|
</div>
|
||||||
|
{% if entry.availability_state == 'home' %}
|
||||||
|
<span class="status-pill status-home">zuhause</span>
|
||||||
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty-state">Fuer heute ist noch nichts fest eingeplant. Das ist vollkommen okay.</p>
|
<p class="empty-state">Für heute ist noch nichts fest eingeplant. Das ist vollkommen okay.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -79,4 +84,25 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Nächste Tage</h2>
|
||||||
|
<a href="{{ url_for('main.planner') }}">Wochenansicht öffnen</a>
|
||||||
|
</div>
|
||||||
|
<div class="week-mini-grid">
|
||||||
|
{% for card in week_cards %}
|
||||||
|
<a class="week-mini-card" href="{{ url_for('main.planner_day', date=card.date.isoformat()) }}">
|
||||||
|
<strong>{{ weekday_short_name(card.date) }} {{ card.date.strftime('%d.%m.') }}</strong>
|
||||||
|
{% if card.filled_dayparts %}
|
||||||
|
<span>{{ card.planned_count }} Einträge</span>
|
||||||
|
<small>{{ card.filled_dayparts | map(attribute='name') | join(', ') }}</small>
|
||||||
|
{% else %}
|
||||||
|
<span>Noch frei</span>
|
||||||
|
<small>sanfter Einstieg für den Tag</small>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,20 +5,42 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Zuhause</p>
|
<p class="eyebrow">Zuhause</p>
|
||||||
<h1>Was aktuell da ist</h1>
|
<h1>Was aktuell da ist</h1>
|
||||||
<p class="lead">Sichtbar, ruhig und nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, wandert es nicht weg, sondern ins Archiv.</p>
|
<p class="lead">Sichtbar, ruhig und besser nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, wandert es nicht weg, sondern ins Archiv.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if grouped %}
|
<section class="panel compact-form-panel">
|
||||||
|
<form method="get" class="filter-form">
|
||||||
|
<label class="wide">
|
||||||
|
Suche
|
||||||
|
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Tageszeit
|
||||||
|
<select name="daypart_id">
|
||||||
|
<option value="">Alle Tageszeiten</option>
|
||||||
|
{% for daypart in dayparts %}
|
||||||
|
<option value="{{ daypart.id }}" {% if selected_daypart_id == daypart.id %}selected{% endif %}>{{ daypart.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button type="submit">Filtern</button>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.home_view') }}">Zurücksetzen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if sections %}
|
||||||
<section class="stack-sections">
|
<section class="stack-sections">
|
||||||
{% for title, items in grouped.items() %}
|
{% for section in sections if section["items"] %}
|
||||||
<article class="panel">
|
<article class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>{{ title }}</h2>
|
<h2>{{ section["title"] }}</h2>
|
||||||
<span>{{ items|length }} Eintraege</span>
|
<span>{{ section["items"]|length }} Einträge</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
{% for item in items %}
|
{% for item in section["items"] %}
|
||||||
<article class="item-card compact">
|
<article class="item-card compact">
|
||||||
<div class="item-media">
|
<div class="item-media">
|
||||||
{% if item.photo_filename %}
|
{% if item.photo_filename %}
|
||||||
@@ -35,6 +57,7 @@
|
|||||||
{% 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>
|
||||||
<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">Verbraucht / nicht mehr da</button>
|
<button class="ghost-button" type="submit">Verbraucht / nicht mehr da</button>
|
||||||
|
|||||||
@@ -58,20 +58,56 @@
|
|||||||
{% if kind == 'meal' %}
|
{% if kind == 'meal' %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Bestandteile der Mahlzeitenidee</legend>
|
<legend>Bestandteile der Mahlzeitenidee</legend>
|
||||||
<div class="checkbox-grid">
|
<p class="muted">Optional: Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus vorhandenen und archivierten Lebensmitteln zusammenklicken.</p>
|
||||||
{% for food in foods %}
|
{% if food_groups %}
|
||||||
<label class="check-option">
|
<div class="stack-sections">
|
||||||
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
|
{% for group in food_groups %}
|
||||||
<span>{{ food.name }}</span>
|
<div class="component-group">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>{{ group["title"] }}</h3>
|
||||||
|
<span>{{ group["items"]|length }} Einträge</span>
|
||||||
|
</div>
|
||||||
|
<div class="checkbox-grid">
|
||||||
|
{% for food in group["items"] %}
|
||||||
|
<label class="check-option">
|
||||||
|
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
|
||||||
|
<span>{{ food.name }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">Lege zuerst ein paar Lebensmittel an, damit du daraus Mahlzeitenideen bauen kannst.</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="quick-food-panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>Neues Lebensmittel direkt anlegen</h3>
|
||||||
|
<span>ohne die Seite zu verlassen</span>
|
||||||
|
</div>
|
||||||
|
<div class="quick-food-grid">
|
||||||
|
<label>
|
||||||
|
Name
|
||||||
|
<input type="text" name="quick_food_name" value="{{ form_data.quick_food_name }}" placeholder="z. B. Hüttenkäse">
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
<label>
|
||||||
|
Kategorie
|
||||||
|
<input type="text" name="quick_food_category" value="{{ form_data.quick_food_category }}" list="category-list" placeholder="z. B. Milchprodukt">
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
Notiz
|
||||||
|
<input type="text" name="quick_food_note" value="{{ form_data.quick_food_note }}" placeholder="Optional">
|
||||||
|
</label>
|
||||||
|
<button type="submit" name="form_action" value="quick_add_food" class="secondary">Lebensmittel anlegen und übernehmen</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit">Speichern</button>
|
<button type="submit" name="form_action" value="save_item">Speichern</button>
|
||||||
<a class="ghost-button" href="{{ url_for('main.item_list', kind=kind) }}">Zurueck</a>
|
<a class="ghost-button" href="{{ url_for('main.item_list', kind=kind) }}">Zurück</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,11 +5,41 @@
|
|||||||
<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">Schnell gepflegte Eintraege mit Foto, Tageszeiten und einem ruhigen Status zwischen Idee, Zuhause und Archiv.</p>
|
<p class="lead">Schnell gepflegte Einträge mit Foto, Tageszeiten und einem ruhigen Status zwischen Merkliste, Zuhause und Archiv.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
|
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neu anlegen</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel compact-form-panel">
|
||||||
|
<form method="get" class="filter-form">
|
||||||
|
<label class="wide">
|
||||||
|
Suche
|
||||||
|
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Status
|
||||||
|
<select name="state">
|
||||||
|
{% for value, label in state_options %}
|
||||||
|
<option value="{{ value }}" {% if selected_state == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Tageszeit
|
||||||
|
<select name="daypart_id">
|
||||||
|
<option value="">Alle Tageszeiten</option>
|
||||||
|
{% for daypart in dayparts %}
|
||||||
|
<option value="{{ daypart.id }}" {% if selected_daypart_id == daypart.id %}selected{% endif %}>{{ daypart.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button type="submit">Filtern</button>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.item_list', kind=kind) }}">Zurücksetzen</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
{% if items %}
|
{% if items %}
|
||||||
<section class="card-grid">
|
<section class="card-grid">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
@@ -47,6 +77,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<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>
|
||||||
|
<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 method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button type="submit">Auf Einkaufsliste</button>
|
<button type="submit">Auf Einkaufsliste</button>
|
||||||
@@ -69,9 +100,9 @@
|
|||||||
</section>
|
</section>
|
||||||
{% else %}
|
{% else %}
|
||||||
<section class="panel empty-panel">
|
<section class="panel empty-panel">
|
||||||
<h2>Noch keine Eintraege</h2>
|
<h2>Keine passenden Einträge</h2>
|
||||||
<p>Der schnellste Start ist ein erstes vertrautes Lebensmittel oder eine einfache Mahlzeitenidee.</p>
|
<p>Mit einer kleinen Suche oder einem anderen Filter findest du meist schnell wieder das Richtige.</p>
|
||||||
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Ersten Eintrag anlegen</a>
|
<a class="button" href="{{ url_for('main.item_create', kind=kind) }}">Neuen Eintrag anlegen</a>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Tagesplan | Nouri{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Tagesplan</p>
|
||||||
|
<h1>{{ weekday_name(selected_date) }}, {{ selected_date.strftime('%d.%m.%Y') }}</h1>
|
||||||
|
<p class="lead">Der Tagesplan bleibt bewusst ruhig. Jede Tageszeit ist eine eigene Kachel und öffnet sich erst, wenn du sie brauchst.</p>
|
||||||
|
</div>
|
||||||
|
<div class="week-nav">
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=previous_day.isoformat()) }}">Vorheriger Tag</a>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.planner') }}">Zur Woche</a>
|
||||||
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=next_day.isoformat()) }}">Nächster Tag</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="planner-day-stack">
|
||||||
|
{% for section in sections %}
|
||||||
|
<details class="day-tile" id="daypart-{{ section.daypart.id }}" {% 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 icon-calendar"></span></div>
|
||||||
|
<div>
|
||||||
|
<h2>{{ section.daypart.name }}</h2>
|
||||||
|
{% if section.summary_items %}
|
||||||
|
<p class="muted">{{ section.summary_items|join(', ') }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">Noch frei. Öffnen, wenn du etwas ergänzen möchtest.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="status-pill">{{ section.entries|length }} geplant</span>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div class="day-tile-body">
|
||||||
|
{% if section.quick_items %}
|
||||||
|
<div class="quick-add-row">
|
||||||
|
{% for item in section.quick_items %}
|
||||||
|
<form method="post" action="{{ url_for('main.planner_day', date=selected_date.isoformat()) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
|
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||||
|
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||||
|
<button class="quick-add-button" type="submit">
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
<small>{{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</small>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" class="planner-entry-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
|
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||||
|
<label class="wide">
|
||||||
|
Eintrag hinzufügen
|
||||||
|
<select name="item_id">
|
||||||
|
<option value="">Etwas für {{ section.daypart.name }} wählen</option>
|
||||||
|
{% for item in section.candidates %}
|
||||||
|
<option value="{{ item.id }}" {% if section.selected_item_id == item.id %}selected{% endif %}>
|
||||||
|
{{ item.name }} · {{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}{% if item.dayparts and section.daypart.name not in item.dayparts %} · auch flexibel{% endif %}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="wide">
|
||||||
|
Notiz
|
||||||
|
<input type="text" name="note" placeholder="Optional, wenn eine kleine Erinnerung hilft">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Eintragen</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if section.entries %}
|
||||||
|
<div class="planner-entry-list">
|
||||||
|
{% for entry in section.entries %}
|
||||||
|
<article class="planner-entry">
|
||||||
|
<div class="planner-entry-top">
|
||||||
|
<div>
|
||||||
|
<strong>{{ entry.item_name }}</strong>
|
||||||
|
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, date=selected_date.isoformat()) }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="ghost-button" type="submit">Entfernen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if entry.note %}
|
||||||
|
<p>{{ entry.note }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">Hier ist noch nichts eingetragen. Ein kleiner Anfang reicht völlig.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,81 +1,71 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Wochenplan | Nouri{% endblock %}
|
{% block title %}Wochenansicht | Nouri{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="page-intro">
|
<section class="page-intro">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Wochenplan</p>
|
<p class="eyebrow">Wochenansicht</p>
|
||||||
<h1>Struktur fuer die naechsten Tage</h1>
|
<h1>Ein sanfter Blick auf die nächsten sieben Tage</h1>
|
||||||
<p class="lead">Der Plan bleibt bewusst leichtgewichtig. Vorhandene Dinge tauchen in der Auswahl zuerst auf.</p>
|
<p class="lead">Du kannst bestehende Einträge zwischen Tagen und Tageszeiten verschieben. Wenn etwas noch nicht zuhause ist, landet es dabei automatisch auf der Einkaufsliste.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="week-nav">
|
<div class="week-nav">
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
|
<a class="ghost-button" href="{{ url_for('main.planner', week=prev_week.isoformat()) }}">Vorige Woche</a>
|
||||||
<span>{{ days[0].strftime('%d.%m.') }} bis {{ days[-1].strftime('%d.%m.%Y') }}</span>
|
<span>{{ week_start.strftime('%d.%m.%Y') }} bis {{ week_end.strftime('%d.%m.%Y') }}</span>
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner', week=next_week.isoformat()) }}">Naechste Woche</a>
|
<a class="ghost-button" href="{{ url_for('main.planner', week=next_week.isoformat()) }}">Nächste Woche</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel compact-form-panel">
|
<section class="week-overview-grid week-board" data-csrf-token="{{ csrf_token_value }}">
|
||||||
<form method="post" class="planner-form">
|
{% for card in week_cards %}
|
||||||
{{ csrf_input() }}
|
<article class="week-card">
|
||||||
<label>
|
<div class="week-card-head">
|
||||||
Tag
|
<div>
|
||||||
<input type="date" name="plan_date" value="{{ days[0].isoformat() }}">
|
<p class="eyebrow">{{ weekday_name(card.date) }}</p>
|
||||||
</label>
|
<h2>{{ card.date.strftime('%d.%m.%Y') }}</h2>
|
||||||
<label>
|
|
||||||
Tageszeit
|
|
||||||
<select name="daypart_id">
|
|
||||||
{% for daypart in dayparts %}
|
|
||||||
<option value="{{ daypart.id }}">{{ daypart.name }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="wide">
|
|
||||||
Eintrag
|
|
||||||
<select name="item_id">
|
|
||||||
<option value="">Etwas fuer den Plan waehlen</option>
|
|
||||||
{% for item in selectable_items %}
|
|
||||||
<option value="{{ item.id }}">{{ item.name }} · {{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label class="wide">
|
|
||||||
Notiz
|
|
||||||
<input type="text" name="note" placeholder="Optional, z. B. zuerst einkaufen">
|
|
||||||
</label>
|
|
||||||
<button type="submit">In den Plan legen</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="planner-grid">
|
|
||||||
{% for daypart in dayparts %}
|
|
||||||
<div class="planner-row">
|
|
||||||
<div class="planner-label">{{ daypart.name }}</div>
|
|
||||||
{% for day in days %}
|
|
||||||
<div class="planner-cell">
|
|
||||||
<div class="planner-date">{{ day.strftime('%a %d.%m.') }}</div>
|
|
||||||
{% set slot_entries = entries.get((day.isoformat(), daypart.id), []) %}
|
|
||||||
{% if slot_entries %}
|
|
||||||
<div class="planner-entry-stack">
|
|
||||||
{% for entry in slot_entries %}
|
|
||||||
<article class="planner-entry">
|
|
||||||
<strong>{{ entry.item_name }}</strong>
|
|
||||||
<small>{{ item_kind_labels[entry.item_kind] }}</small>
|
|
||||||
{% if entry.note %}
|
|
||||||
<p>{{ entry.note }}</p>
|
|
||||||
{% endif %}
|
|
||||||
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, week=week_start.isoformat()) }}">
|
|
||||||
{{ csrf_input() }}
|
|
||||||
<button class="ghost-button" type="submit">Entfernen</button>
|
|
||||||
</form>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<p class="empty-slot">frei</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% if card.date == today %}
|
||||||
</div>
|
<span class="status-pill status-home">heute</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if card.filled_dayparts %}
|
||||||
|
<p class="week-card-count">{{ card.planned_count }} Einträge</p>
|
||||||
|
<div class="chip-row">
|
||||||
|
{% for slot in card.filled_dayparts %}
|
||||||
|
<span class="chip">{{ slot.name }} · {{ slot.count }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="muted">{{ card.preview_items | join(', ') }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty-state">Noch offen. Du kannst den Tag ganz leicht nach und nach füllen.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="week-slot-stack">
|
||||||
|
{% for slot in card.slots %}
|
||||||
|
<div class="week-slot drop-slot" data-target-date="{{ card.date.isoformat() }}" data-target-daypart-id="{{ slot.daypart.id }}">
|
||||||
|
<div class="week-slot-head">
|
||||||
|
<strong>{{ slot.daypart.name }}</strong>
|
||||||
|
<span>{{ slot.entries|length }}</span>
|
||||||
|
</div>
|
||||||
|
{% if slot.entries %}
|
||||||
|
<div class="week-entry-stack">
|
||||||
|
{% for entry in slot.entries %}
|
||||||
|
<article class="plan-chip draggable-plan-entry" draggable="true" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}">
|
||||||
|
<strong>{{ entry.item_name }}</strong>
|
||||||
|
<small>{{ item_kind_labels[entry.item_kind] }}</small>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="week-slot-empty">Hierher ziehen</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="week-card-actions">
|
||||||
|
<a class="button" href="{{ url_for('main.planner_day', date=card.date.isoformat()) }}">Tagesplan öffnen</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<form method="post" class="inline-form">
|
<form method="post" class="inline-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<select name="item_id">
|
<select name="item_id">
|
||||||
<option value="">Bestehenden Eintrag hinzufuegen</option>
|
<option value="">Bestehenden Eintrag hinzufügen</option>
|
||||||
{% for item in addable_items %}
|
{% for item in addable_items %}
|
||||||
<option value="{{ item.id }}">{{ item.name }} · {{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</option>
|
<option value="{{ item.id }}">{{ item.name }} · {{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<article class="list-row">
|
<article class="list-row">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ entry.item_name }}</strong>
|
<strong>{{ entry.item_name }}</strong>
|
||||||
<p class="muted">{{ item_kind_labels[entry.item_kind] }}{% if entry.display_name or entry.username %} · hinzugefuegt von {{ entry.display_name or entry.username }}{% endif %}</p>
|
<p class="muted">{{ item_kind_labels[entry.item_kind] }}{% if entry.display_name or entry.username %} · hinzugefügt von {{ entry.display_name or entry.username }}{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="row-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) }}">
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<section class="panel empty-panel">
|
<section class="panel empty-panel">
|
||||||
<h2>Die Liste ist gerade frei</h2>
|
<h2>Die Liste ist gerade frei</h2>
|
||||||
<p>Eintraege aus Lebensmitteln, Mahlzeitenideen oder dem Archiv lassen sich jederzeit wieder hinzufuegen.</p>
|
<p>Einträge aus Lebensmitteln, Mahlzeitenideen oder dem Archiv lassen sich jederzeit wieder hinzufügen.</p>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||