Compare commits
7 Commits
36bde02c54
...
V0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b68ed62887 | |||
| a4e7292930 | |||
| 9c164dc2e7 | |||
| 0231b28935 | |||
| a8b7eb09df | |||
| 31287813c8 | |||
| 24ebb26ffd |
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
data
|
||||||
|
instance
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"id": "io.hnz.nouri",
|
||||||
|
"title": "Nouri",
|
||||||
|
"author": "Florian Heinz",
|
||||||
|
"description": "Private Flask app for meals, shopping and gentle food planning",
|
||||||
|
"tagline": "einfach essen planen",
|
||||||
|
"version": "0.3.0",
|
||||||
|
"upstreamVersion": "0.3.0",
|
||||||
|
"healthCheckPath": "/",
|
||||||
|
"httpPort": 8000,
|
||||||
|
"manifestVersion": 2,
|
||||||
|
"addons": {
|
||||||
|
"localstorage": {
|
||||||
|
"sqlite": {
|
||||||
|
"paths": [
|
||||||
|
"/app/data/nouri.sqlite3"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
WORKDIR /app/code
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends sqlite3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt /app/code/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . /app/code
|
||||||
|
|
||||||
|
RUN chmod +x /app/code/start.sh
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["/app/code/start.sh"]
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
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.
|
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.2
|
## Merkmale in Version 0.3
|
||||||
|
|
||||||
- Lebensmittel und Mahlzeitenideen anlegen
|
- Lebensmittel und Mahlzeitenideen anlegen
|
||||||
- Fotos lokal hochladen
|
- Fotos lokal hochladen
|
||||||
@@ -12,7 +12,11 @@ Nouri ist eine kleine private Flask-App für einen Haushalt, um Essensideen, Ein
|
|||||||
- Tagesplan mit schnellen Vorschlägen je Tageszeit
|
- Tagesplan mit schnellen Vorschlägen je Tageszeit
|
||||||
- Wochenansicht für die nächsten 7 Tage
|
- Wochenansicht für die nächsten 7 Tage
|
||||||
- einfache Suche und Filter für Lebensmittel und Mahlzeitenideen
|
- einfache Suche und Filter für Lebensmittel und Mahlzeitenideen
|
||||||
- einfache Benutzeranmeldung für einen Haushalt
|
- mehrere Haushaltsnutzer mit Rollen
|
||||||
|
- gemeinsame und persönliche Inhalte
|
||||||
|
- Profilseite und Passwortänderung
|
||||||
|
- kleine Admin-Verwaltung für Nutzer
|
||||||
|
- kompaktere mobile Navigation mit Bottom-Bar
|
||||||
|
|
||||||
## Lokal starten
|
## Lokal starten
|
||||||
|
|
||||||
@@ -35,10 +39,19 @@ Wichtige Umgebungsvariablen:
|
|||||||
- `NOURI_DATA_DIR`: Pfad für 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
|
## Migration von 0.2 auf 0.3
|
||||||
|
|
||||||
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.
|
Beim Start erweitert Nouri das Schema pragmatisch direkt in SQLite: Haushalt, Rollen, Aktiv-Status und Sichtbarkeit (`persönlich` oder `Für alle`) werden ergänzt. Vorhandene 0.2-Daten bleiben erhalten und werden automatisch einem gemeinsamen Haushaltskontext zugeordnet.
|
||||||
|
|
||||||
## Cloudron-Hinweis
|
## Cloudron-Hinweis
|
||||||
|
|
||||||
Für Cloudron später `NOURI_DATA_DIR=/app/data` setzen, damit Datenbank und Uploads persistent liegen.
|
Für Cloudron ist die App jetzt so vorbereitet, dass Datenbank und Uploads unter `/app/data` liegen. Das Startskript setzt `NOURI_DATA_DIR=/app/data`, legt die SQLite-Datei dort an und startet die App per `gunicorn`.
|
||||||
|
|
||||||
|
Lokale Testdaten und produktive Cloudron-Daten bleiben bewusst getrennt:
|
||||||
|
|
||||||
|
- lokal nutzt Nouri ohne gesetzte Variable standardmäßig `./data`
|
||||||
|
- auf Cloudron nutzt Nouri `/app/data`
|
||||||
|
- `data/` ist in `.gitignore` und `.dockerignore` ausgeschlossen und wird weder eingecheckt noch ins Image kopiert
|
||||||
|
- `/app/data` ist auf Cloudron persistent und bleibt bei App-Updates erhalten
|
||||||
|
|
||||||
|
Wenn die App auf Cloudron bereits installiert ist, bitte **kein neues `cloudron install`** ausführen. Stattdessen die bestehende App aktualisieren, also ein neues Image bzw. Paket bauen und dann die vorhandene Installation updaten.
|
||||||
|
|||||||
+37
-3
@@ -5,11 +5,20 @@ import secrets
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, send_from_directory
|
from flask import Flask, g, send_from_directory
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
|
from .admin import admin_bp
|
||||||
from .auth import auth_bp
|
from .auth import auth_bp
|
||||||
from .constants import CATEGORIES, DAYPARTS, ITEM_KIND_LABELS, ITEM_KIND_SINGULAR_LABELS
|
from .constants import (
|
||||||
|
CATEGORIES,
|
||||||
|
DAYPARTS,
|
||||||
|
ITEM_KIND_LABELS,
|
||||||
|
ITEM_KIND_SINGULAR_LABELS,
|
||||||
|
ROLE_LABELS,
|
||||||
|
VISIBILITY_DESCRIPTIONS,
|
||||||
|
VISIBILITY_LABELS,
|
||||||
|
)
|
||||||
from .main import main_bp
|
from .main import main_bp
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +26,24 @@ WEEKDAY_NAMES = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Sam
|
|||||||
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
WEEKDAY_SHORT_NAMES = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
|
||||||
|
|
||||||
|
|
||||||
|
def load_secret_key(data_dir: Path) -> str:
|
||||||
|
env_secret = os.environ.get("NOURI_SECRET_KEY")
|
||||||
|
if env_secret:
|
||||||
|
return env_secret
|
||||||
|
|
||||||
|
secret_path = data_dir / ".secret_key"
|
||||||
|
if secret_path.exists():
|
||||||
|
return secret_path.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
secret_value = secrets.token_hex(24)
|
||||||
|
try:
|
||||||
|
with secret_path.open("x", encoding="utf-8") as handle:
|
||||||
|
handle.write(secret_value)
|
||||||
|
except FileExistsError:
|
||||||
|
return secret_path.read_text(encoding="utf-8").strip()
|
||||||
|
return secret_value
|
||||||
|
|
||||||
|
|
||||||
def 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,18 +55,21 @@ def create_app() -> Flask:
|
|||||||
|
|
||||||
app = Flask(__name__, instance_relative_config=False)
|
app = Flask(__name__, instance_relative_config=False)
|
||||||
app.config.update(
|
app.config.update(
|
||||||
SECRET_KEY=os.environ.get("NOURI_SECRET_KEY", secrets.token_hex(24)),
|
SECRET_KEY=load_secret_key(data_dir),
|
||||||
DATABASE_PATH=str(db_path),
|
DATABASE_PATH=str(db_path),
|
||||||
DATA_DIR=str(data_dir),
|
DATA_DIR=str(data_dir),
|
||||||
UPLOAD_FOLDER=str(upload_dir),
|
UPLOAD_FOLDER=str(upload_dir),
|
||||||
MAX_CONTENT_LENGTH=int(os.environ.get("NOURI_MAX_UPLOAD_MB", "5")) * 1024 * 1024,
|
MAX_CONTENT_LENGTH=int(os.environ.get("NOURI_MAX_UPLOAD_MB", "5")) * 1024 * 1024,
|
||||||
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
|
PERMANENT_SESSION_LIFETIME=timedelta(days=30),
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
)
|
)
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
db.init_db_if_needed(app)
|
db.init_db_if_needed(app)
|
||||||
|
|
||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(admin_bp)
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
@@ -49,9 +79,13 @@ 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,
|
||||||
|
"visibility_labels": VISIBILITY_LABELS,
|
||||||
|
"visibility_descriptions": VISIBILITY_DESCRIPTIONS,
|
||||||
|
"role_labels": ROLE_LABELS,
|
||||||
"today": date.today(),
|
"today": date.today(),
|
||||||
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
"weekday_name": lambda value: WEEKDAY_NAMES[value.weekday()],
|
||||||
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
|
"weekday_short_name": lambda value: WEEKDAY_SHORT_NAMES[value.weekday()],
|
||||||
|
"is_admin": lambda: bool(getattr(g, "user", None)) and g.user["role"] == "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/uploads/<path:filename>")
|
@app.get("/uploads/<path:filename>")
|
||||||
|
|||||||
+180
@@ -0,0 +1,180 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
from .auth import admin_required, can_remove_last_admin, validate_admin_user_form
|
||||||
|
from .constants import ROLE_LABELS
|
||||||
|
from .db import get_db
|
||||||
|
|
||||||
|
|
||||||
|
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||||
|
|
||||||
|
|
||||||
|
def get_household_user(user_id: int):
|
||||||
|
user = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT users.*, households.name AS household_name
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN households ON households.id = users.household_id
|
||||||
|
WHERE users.id = ? AND users.household_id = ?
|
||||||
|
""",
|
||||||
|
(user_id, g.user["household_id"]),
|
||||||
|
).fetchone()
|
||||||
|
if user is None:
|
||||||
|
raise ValueError("Der Nutzer wurde nicht gefunden.")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.get("/users")
|
||||||
|
@admin_required
|
||||||
|
def user_list():
|
||||||
|
users = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM users
|
||||||
|
WHERE household_id = ?
|
||||||
|
ORDER BY is_active DESC, LOWER(COALESCE(display_name, username))
|
||||||
|
""",
|
||||||
|
(g.user["household_id"],),
|
||||||
|
).fetchall()
|
||||||
|
return render_template("admin/users_list.html", users=users, role_labels=ROLE_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/users/new", methods=("GET", "POST"))
|
||||||
|
@admin_required
|
||||||
|
def user_create():
|
||||||
|
form_data = {
|
||||||
|
"display_name": "",
|
||||||
|
"username": "",
|
||||||
|
"email": "",
|
||||||
|
"role": "member",
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
database = get_db()
|
||||||
|
form_data = {
|
||||||
|
"display_name": request.form.get("display_name", "").strip(),
|
||||||
|
"username": request.form.get("username", "").strip().lower(),
|
||||||
|
"email": request.form.get("email", "").strip().lower(),
|
||||||
|
"role": request.form.get("role", "member").strip(),
|
||||||
|
"is_active": request.form.get("is_active", "1") == "1",
|
||||||
|
}
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
password_repeat = request.form.get("password_repeat", "")
|
||||||
|
|
||||||
|
error = validate_admin_user_form(
|
||||||
|
database,
|
||||||
|
username=form_data["username"],
|
||||||
|
email=form_data["email"] or None,
|
||||||
|
role=form_data["role"],
|
||||||
|
is_active=form_data["is_active"],
|
||||||
|
password=password,
|
||||||
|
password_repeat=password_repeat,
|
||||||
|
)
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (household_id, username, email, display_name, role, is_active, password_hash)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
g.user["household_id"],
|
||||||
|
form_data["username"],
|
||||||
|
form_data["email"] or None,
|
||||||
|
form_data["display_name"],
|
||||||
|
form_data["role"],
|
||||||
|
1 if form_data["is_active"] else 0,
|
||||||
|
generate_password_hash(password),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
database.commit()
|
||||||
|
flash("Der Nutzer wurde angelegt.", "success")
|
||||||
|
return redirect(url_for("admin.user_list"))
|
||||||
|
|
||||||
|
flash(error, "error")
|
||||||
|
|
||||||
|
return render_template("admin/user_form.html", user=None, form_data=form_data, role_labels=ROLE_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/users/<int:user_id>/edit", methods=("GET", "POST"))
|
||||||
|
@admin_required
|
||||||
|
def user_edit(user_id: int):
|
||||||
|
try:
|
||||||
|
user = get_household_user(user_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(str(exc), "error")
|
||||||
|
return redirect(url_for("admin.user_list"))
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
"display_name": user["display_name"] or "",
|
||||||
|
"username": user["username"],
|
||||||
|
"email": user["email"] or "",
|
||||||
|
"role": user["role"],
|
||||||
|
"is_active": bool(user["is_active"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
database = get_db()
|
||||||
|
form_data = {
|
||||||
|
"display_name": request.form.get("display_name", "").strip(),
|
||||||
|
"username": request.form.get("username", "").strip().lower(),
|
||||||
|
"email": request.form.get("email", "").strip().lower(),
|
||||||
|
"role": request.form.get("role", "member").strip(),
|
||||||
|
"is_active": request.form.get("is_active", "0") == "1",
|
||||||
|
}
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
password_repeat = request.form.get("password_repeat", "")
|
||||||
|
|
||||||
|
error = validate_admin_user_form(
|
||||||
|
database,
|
||||||
|
username=form_data["username"],
|
||||||
|
email=form_data["email"] or None,
|
||||||
|
role=form_data["role"],
|
||||||
|
is_active=form_data["is_active"],
|
||||||
|
password=password,
|
||||||
|
password_repeat=password_repeat,
|
||||||
|
current_user_id=user_id,
|
||||||
|
)
|
||||||
|
if error is None and can_remove_last_admin(user_id, form_data["role"], form_data["is_active"]):
|
||||||
|
error = "Mindestens ein aktiver Admin sollte im Haushalt bleiben."
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET username = ?,
|
||||||
|
email = ?,
|
||||||
|
display_name = ?,
|
||||||
|
role = ?,
|
||||||
|
is_active = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
form_data["username"],
|
||||||
|
form_data["email"] or None,
|
||||||
|
form_data["display_name"],
|
||||||
|
form_data["role"],
|
||||||
|
1 if form_data["is_active"] else 0,
|
||||||
|
user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if password:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(generate_password_hash(password), user_id),
|
||||||
|
)
|
||||||
|
database.commit()
|
||||||
|
flash("Der Nutzer wurde aktualisiert.", "success")
|
||||||
|
return redirect(url_for("admin.user_list"))
|
||||||
|
|
||||||
|
flash(error, "error")
|
||||||
|
|
||||||
|
return render_template("admin/user_form.html", user=user, form_data=form_data, role_labels=ROLE_LABELS)
|
||||||
+178
-17
@@ -16,7 +16,8 @@ from flask import (
|
|||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
from .db import get_db, user_count
|
from .constants import ROLE_LABELS
|
||||||
|
from .db import active_admin_count, ensure_default_household, get_db, user_count
|
||||||
|
|
||||||
|
|
||||||
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||||
@@ -32,6 +33,19 @@ def login_required(view):
|
|||||||
return wrapped_view
|
return wrapped_view
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(view):
|
||||||
|
@functools.wraps(view)
|
||||||
|
def wrapped_view(**kwargs):
|
||||||
|
if g.user is None:
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
|
if g.user["role"] != "admin":
|
||||||
|
flash("Dieser Bereich ist für Admins gedacht.", "error")
|
||||||
|
return redirect(url_for("main.dashboard"))
|
||||||
|
return view(**kwargs)
|
||||||
|
|
||||||
|
return wrapped_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:
|
||||||
@@ -39,6 +53,32 @@ def ensure_csrf_token() -> str:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_login_value(raw: str) -> str:
|
||||||
|
return raw.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_identity_fields(database, username: str, email: str | None, current_user_id: int | None = None) -> str | None:
|
||||||
|
if not username:
|
||||||
|
return "Bitte einen Benutzernamen eintragen."
|
||||||
|
|
||||||
|
existing_user = database.execute(
|
||||||
|
"SELECT id FROM users WHERE LOWER(username) = LOWER(?)",
|
||||||
|
(username,),
|
||||||
|
).fetchone()
|
||||||
|
if existing_user and int(existing_user["id"]) != current_user_id:
|
||||||
|
return "Dieser Benutzername ist bereits vergeben."
|
||||||
|
|
||||||
|
if email:
|
||||||
|
existing_email = database.execute(
|
||||||
|
"SELECT id FROM users WHERE LOWER(email) = LOWER(?)",
|
||||||
|
(email,),
|
||||||
|
).fetchone()
|
||||||
|
if existing_email and int(existing_email["id"]) != current_user_id:
|
||||||
|
return "Diese E-Mail-Adresse wird bereits verwendet."
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.app_context_processor
|
@auth_bp.app_context_processor
|
||||||
def inject_csrf_input():
|
def inject_csrf_input():
|
||||||
return {
|
return {
|
||||||
@@ -56,9 +96,18 @@ def load_logged_in_user():
|
|||||||
g.user = None
|
g.user = None
|
||||||
else:
|
else:
|
||||||
g.user = get_db().execute(
|
g.user = get_db().execute(
|
||||||
"SELECT * FROM users WHERE id = ?",
|
"""
|
||||||
|
SELECT users.*,
|
||||||
|
households.name AS household_name
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN households ON households.id = users.household_id
|
||||||
|
WHERE users.id = ?
|
||||||
|
""",
|
||||||
(user_id,),
|
(user_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
if g.user is not None and not g.user["is_active"]:
|
||||||
|
session.clear()
|
||||||
|
g.user = None
|
||||||
|
|
||||||
endpoint = request.endpoint or ""
|
endpoint = request.endpoint or ""
|
||||||
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}:
|
if user_count() == 0 and endpoint not in {"auth.setup", "static", "uploaded_file"}:
|
||||||
@@ -78,27 +127,32 @@ def setup():
|
|||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip().lower()
|
household_name = request.form.get("household_name", "").strip() or "Unser Haushalt"
|
||||||
|
username = normalize_login_value(request.form.get("username", ""))
|
||||||
|
email = normalize_login_value(request.form.get("email", "")) or None
|
||||||
display_name = request.form.get("display_name", "").strip()
|
display_name = request.form.get("display_name", "").strip()
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
password_repeat = request.form.get("password_repeat", "")
|
password_repeat = request.form.get("password_repeat", "")
|
||||||
|
database = get_db()
|
||||||
|
|
||||||
error = None
|
error = validate_identity_fields(database, username, email)
|
||||||
if not username:
|
if error is None and not password:
|
||||||
error = "Bitte einen Benutzernamen eintragen."
|
|
||||||
elif not password:
|
|
||||||
error = "Bitte ein Passwort vergeben."
|
error = "Bitte ein Passwort vergeben."
|
||||||
elif password != password_repeat:
|
elif error is None and password != password_repeat:
|
||||||
error = "Die Passwörter stimmen nicht überein."
|
error = "Die Passwörter stimmen nicht überein."
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
database = get_db()
|
database.execute(
|
||||||
|
"INSERT INTO households (name) VALUES (?)",
|
||||||
|
(household_name,),
|
||||||
|
)
|
||||||
|
household_id = ensure_default_household(database)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users (username, display_name, password_hash)
|
INSERT INTO users (household_id, username, email, display_name, role, password_hash)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?, ?, 'admin', ?)
|
||||||
""",
|
""",
|
||||||
(username, display_name, generate_password_hash(password)),
|
(household_id, username, email, display_name, generate_password_hash(password)),
|
||||||
)
|
)
|
||||||
database.commit()
|
database.commit()
|
||||||
flash("Der erste Haushalt-Zugang ist angelegt. Du kannst dich jetzt anmelden.", "success")
|
flash("Der erste Haushalt-Zugang ist angelegt. Du kannst dich jetzt anmelden.", "success")
|
||||||
@@ -115,22 +169,28 @@ def login():
|
|||||||
return redirect(url_for("auth.setup"))
|
return redirect(url_for("auth.setup"))
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip().lower()
|
identity = normalize_login_value(request.form.get("username", ""))
|
||||||
password = request.form.get("password", "")
|
password = request.form.get("password", "")
|
||||||
remember_me = request.form.get("remember_me") == "1"
|
remember_me = request.form.get("remember_me") == "1"
|
||||||
database = get_db()
|
database = get_db()
|
||||||
user = database.execute(
|
user = database.execute(
|
||||||
"SELECT * FROM users WHERE username = ?",
|
"""
|
||||||
(username,),
|
SELECT users.*, households.name AS household_name
|
||||||
|
FROM users
|
||||||
|
LEFT JOIN households ON households.id = users.household_id
|
||||||
|
WHERE LOWER(users.username) = LOWER(?) OR LOWER(COALESCE(users.email, '')) = LOWER(?)
|
||||||
|
""",
|
||||||
|
(identity, identity),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
if user is None or not check_password_hash(user["password_hash"], password):
|
if user is None or not check_password_hash(user["password_hash"], password):
|
||||||
error = "Benutzername oder Passwort passen nicht zusammen."
|
error = "Login oder Passwort passen nicht zusammen."
|
||||||
|
elif not user["is_active"]:
|
||||||
|
error = "Dieser Zugang ist derzeit nicht aktiv."
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
session.clear()
|
session.clear()
|
||||||
# Opt-in long-lived session so the shared household device stays low-friction.
|
|
||||||
session.permanent = remember_me
|
session.permanent = remember_me
|
||||||
session["user_id"] = user["id"]
|
session["user_id"] = user["id"]
|
||||||
ensure_csrf_token()
|
ensure_csrf_token()
|
||||||
@@ -141,8 +201,109 @@ def login():
|
|||||||
return render_template("auth/login.html")
|
return render_template("auth/login.html")
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/profile", methods=("GET", "POST"))
|
||||||
|
@login_required
|
||||||
|
def profile():
|
||||||
|
database = get_db()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
username = normalize_login_value(request.form.get("username", ""))
|
||||||
|
email = normalize_login_value(request.form.get("email", "")) or None
|
||||||
|
display_name = request.form.get("display_name", "").strip()
|
||||||
|
|
||||||
|
error = validate_identity_fields(database, username, email, current_user_id=g.user["id"])
|
||||||
|
if error is None:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET username = ?, email = ?, display_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(username, email, display_name, g.user["id"]),
|
||||||
|
)
|
||||||
|
database.commit()
|
||||||
|
flash("Dein Profil wurde aktualisiert.", "success")
|
||||||
|
return redirect(url_for("auth.profile"))
|
||||||
|
|
||||||
|
flash(error, "error")
|
||||||
|
|
||||||
|
return render_template("auth/profile.html", role_labels=ROLE_LABELS)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.post("/password")
|
||||||
|
@login_required
|
||||||
|
def change_password():
|
||||||
|
current_password = request.form.get("current_password", "")
|
||||||
|
new_password = request.form.get("new_password", "")
|
||||||
|
new_password_repeat = request.form.get("new_password_repeat", "")
|
||||||
|
|
||||||
|
error = None
|
||||||
|
if not check_password_hash(g.user["password_hash"], current_password):
|
||||||
|
error = "Das aktuelle Passwort stimmt nicht."
|
||||||
|
elif not new_password:
|
||||||
|
error = "Bitte ein neues Passwort eintragen."
|
||||||
|
elif new_password != new_password_repeat:
|
||||||
|
error = "Die neuen Passwörter stimmen nicht überein."
|
||||||
|
|
||||||
|
if error is None:
|
||||||
|
get_db().execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(generate_password_hash(new_password), g.user["id"]),
|
||||||
|
)
|
||||||
|
get_db().commit()
|
||||||
|
flash("Dein Passwort wurde geändert.", "success")
|
||||||
|
else:
|
||||||
|
flash(error, "error")
|
||||||
|
|
||||||
|
return redirect(url_for("auth.profile"))
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.post("/logout")
|
@auth_bp.post("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
session.clear()
|
session.clear()
|
||||||
flash("Du bist abgemeldet.", "info")
|
flash("Du bist abgemeldet.", "info")
|
||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
|
def validate_admin_user_form(
|
||||||
|
database,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
email: str | None,
|
||||||
|
role: str,
|
||||||
|
is_active: bool,
|
||||||
|
password: str,
|
||||||
|
password_repeat: str,
|
||||||
|
current_user_id: int | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
error = validate_identity_fields(database, username, email, current_user_id=current_user_id)
|
||||||
|
if error:
|
||||||
|
return error
|
||||||
|
if role not in ROLE_LABELS:
|
||||||
|
return "Bitte eine gültige Rolle auswählen."
|
||||||
|
if current_user_id is None and not password:
|
||||||
|
return "Bitte ein Passwort vergeben."
|
||||||
|
if password and password != password_repeat:
|
||||||
|
return "Die Passwörter stimmen nicht überein."
|
||||||
|
if current_user_id == g.user["id"] and not is_active:
|
||||||
|
return "Du kannst deinen eigenen Zugang hier nicht deaktivieren."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def can_remove_last_admin(target_user_id: int, new_role: str, is_active: bool) -> bool:
|
||||||
|
if g.user is None:
|
||||||
|
return False
|
||||||
|
if target_user_id != g.user["id"] and g.user["role"] == "admin":
|
||||||
|
target = get_db().execute("SELECT * FROM users WHERE id = ?", (target_user_id,)).fetchone()
|
||||||
|
if target is None:
|
||||||
|
return False
|
||||||
|
if target["role"] != "admin" or not target["is_active"]:
|
||||||
|
return False
|
||||||
|
if new_role == "admin" and is_active:
|
||||||
|
return False
|
||||||
|
return active_admin_count(g.user["household_id"]) <= 1
|
||||||
|
return False
|
||||||
|
|||||||
@@ -35,3 +35,18 @@ AVAILABILITY_LABELS = {
|
|||||||
"home": "Zuhause",
|
"home": "Zuhause",
|
||||||
"archived": "Archiv",
|
"archived": "Archiv",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ROLE_LABELS = {
|
||||||
|
"admin": "Admin",
|
||||||
|
"member": "Mitglied",
|
||||||
|
}
|
||||||
|
|
||||||
|
VISIBILITY_LABELS = {
|
||||||
|
"shared": "Für alle",
|
||||||
|
"personal": "Persönlich",
|
||||||
|
}
|
||||||
|
|
||||||
|
VISIBILITY_DESCRIPTIONS = {
|
||||||
|
"shared": "Gemeinsam im Haushalt sichtbar und nutzbar.",
|
||||||
|
"personal": "Nur für dich sichtbar und planbar.",
|
||||||
|
}
|
||||||
|
|||||||
+212
-4
@@ -28,9 +28,195 @@ def close_db(_error=None) -> None:
|
|||||||
database.close()
|
database.close()
|
||||||
|
|
||||||
|
|
||||||
|
def table_columns(database: sqlite3.Connection, table_name: str) -> set[str]:
|
||||||
|
rows = database.execute(f"PRAGMA table_info({table_name})").fetchall()
|
||||||
|
return {row["name"] for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def add_column_if_missing(database: sqlite3.Connection, table_name: str, definition: str) -> None:
|
||||||
|
column_name = definition.split()[0]
|
||||||
|
if column_name not in table_columns(database, table_name):
|
||||||
|
database.execute(f"ALTER TABLE {table_name} ADD COLUMN {definition}")
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_legacy_schema(database: sqlite3.Connection) -> None:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS households (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
existing_tables = {
|
||||||
|
row["name"]
|
||||||
|
for row in database.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
|
||||||
|
if "users" in existing_tables:
|
||||||
|
add_column_if_missing(database, "users", "household_id INTEGER")
|
||||||
|
add_column_if_missing(database, "users", "email TEXT")
|
||||||
|
add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'")
|
||||||
|
add_column_if_missing(database, "users", "is_active INTEGER NOT NULL DEFAULT 1")
|
||||||
|
add_column_if_missing(database, "users", "updated_at TEXT")
|
||||||
|
|
||||||
|
if "items" in existing_tables:
|
||||||
|
add_column_if_missing(database, "items", "household_id INTEGER")
|
||||||
|
add_column_if_missing(database, "items", "owner_user_id INTEGER")
|
||||||
|
add_column_if_missing(database, "items", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||||
|
|
||||||
|
if "shopping_entries" in existing_tables:
|
||||||
|
add_column_if_missing(database, "shopping_entries", "household_id INTEGER")
|
||||||
|
add_column_if_missing(database, "shopping_entries", "owner_user_id INTEGER")
|
||||||
|
add_column_if_missing(database, "shopping_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||||
|
|
||||||
|
if "plan_entries" in existing_tables:
|
||||||
|
add_column_if_missing(database, "plan_entries", "household_id INTEGER")
|
||||||
|
add_column_if_missing(database, "plan_entries", "owner_user_id INTEGER")
|
||||||
|
add_column_if_missing(database, "plan_entries", "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_household(database: sqlite3.Connection) -> int:
|
||||||
|
household = database.execute(
|
||||||
|
"SELECT id FROM households ORDER BY id LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if household:
|
||||||
|
return int(household["id"])
|
||||||
|
|
||||||
|
database.execute(
|
||||||
|
"INSERT INTO households (name) VALUES (?)",
|
||||||
|
("Unser Haushalt",),
|
||||||
|
)
|
||||||
|
return int(
|
||||||
|
database.execute("SELECT id FROM households ORDER BY id LIMIT 1").fetchone()["id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def first_user_id(database: sqlite3.Connection) -> int | None:
|
||||||
|
row = database.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
|
||||||
|
return int(row["id"]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_schema_upgrades(database: sqlite3.Connection) -> None:
|
||||||
|
add_column_if_missing(database, "users", "household_id INTEGER")
|
||||||
|
add_column_if_missing(database, "users", "email TEXT")
|
||||||
|
add_column_if_missing(database, "users", "role TEXT NOT NULL DEFAULT 'member'")
|
||||||
|
add_column_if_missing(database, "users", "is_active INTEGER NOT NULL DEFAULT 1")
|
||||||
|
add_column_if_missing(database, "users", "updated_at TEXT")
|
||||||
|
|
||||||
|
default_household_id = ensure_default_household(database)
|
||||||
|
database.execute(
|
||||||
|
"UPDATE users SET household_id = ? WHERE household_id IS NULL",
|
||||||
|
(default_household_id,),
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"UPDATE users SET role = 'member' WHERE role IS NULL OR role = ''",
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"UPDATE users SET is_active = 1 WHERE is_active IS NULL",
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"UPDATE users SET email = NULL WHERE TRIM(COALESCE(email, '')) = ''",
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"UPDATE users SET updated_at = COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)"
|
||||||
|
)
|
||||||
|
|
||||||
|
admin_row = database.execute(
|
||||||
|
"SELECT id FROM users WHERE role = 'admin' AND is_active = 1 ORDER BY id LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if admin_row is None:
|
||||||
|
first_id = first_user_id(database)
|
||||||
|
if first_id is not None:
|
||||||
|
database.execute(
|
||||||
|
"UPDATE users SET role = 'admin' WHERE id = ?",
|
||||||
|
(first_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
default_owner_id = first_user_id(database)
|
||||||
|
for table_name in ("items", "shopping_entries", "plan_entries"):
|
||||||
|
add_column_if_missing(database, table_name, "household_id INTEGER")
|
||||||
|
add_column_if_missing(database, table_name, "owner_user_id INTEGER")
|
||||||
|
add_column_if_missing(database, table_name, "visibility TEXT NOT NULL DEFAULT 'shared'")
|
||||||
|
|
||||||
|
if default_owner_id is not None:
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE items
|
||||||
|
SET household_id = COALESCE(household_id, ?),
|
||||||
|
owner_user_id = COALESCE(owner_user_id, created_by, ?),
|
||||||
|
visibility = CASE WHEN visibility IS NULL OR visibility = '' THEN 'shared' ELSE visibility END
|
||||||
|
WHERE household_id IS NULL OR owner_user_id IS NULL OR visibility IS NULL OR visibility = ''
|
||||||
|
""",
|
||||||
|
(default_household_id, default_owner_id),
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE shopping_entries
|
||||||
|
SET household_id = COALESCE(household_id, ?),
|
||||||
|
owner_user_id = COALESCE(owner_user_id, added_by, ?),
|
||||||
|
visibility = CASE WHEN visibility IS NULL OR visibility = '' THEN 'shared' ELSE visibility END
|
||||||
|
WHERE household_id IS NULL OR owner_user_id IS NULL OR visibility IS NULL OR visibility = ''
|
||||||
|
""",
|
||||||
|
(default_household_id, default_owner_id),
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
UPDATE plan_entries
|
||||||
|
SET household_id = COALESCE(household_id, ?),
|
||||||
|
owner_user_id = COALESCE(owner_user_id, created_by, ?),
|
||||||
|
visibility = CASE WHEN visibility IS NULL OR visibility = '' THEN 'shared' ELSE visibility END
|
||||||
|
WHERE household_id IS NULL OR owner_user_id IS NULL OR visibility IS NULL OR visibility = ''
|
||||||
|
""",
|
||||||
|
(default_household_id, default_owner_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
database.execute(
|
||||||
|
"UPDATE items SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''"
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"UPDATE shopping_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''"
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"UPDATE plan_entries SET visibility = 'shared' WHERE visibility IS NULL OR visibility = ''"
|
||||||
|
)
|
||||||
|
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
|
||||||
|
ON users (email)
|
||||||
|
WHERE email IS NOT NULL AND email != ''
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
||||||
|
ON items (household_id, visibility, availability_state)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility
|
||||||
|
ON plan_entries (household_id, visibility, plan_date)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
database.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility
|
||||||
|
ON shopping_entries (household_id, visibility, is_checked)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def apply_schema(database: sqlite3.Connection) -> None:
|
def apply_schema(database: sqlite3.Connection) -> None:
|
||||||
|
bootstrap_legacy_schema(database)
|
||||||
schema_path = Path(__file__).with_name("schema.sql")
|
schema_path = Path(__file__).with_name("schema.sql")
|
||||||
database.executescript(schema_path.read_text(encoding="utf-8"))
|
database.executescript(schema_path.read_text(encoding="utf-8"))
|
||||||
|
ensure_schema_upgrades(database)
|
||||||
sync_dayparts(database)
|
sync_dayparts(database)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +255,18 @@ def user_count() -> int:
|
|||||||
return int(row["count"])
|
return int(row["count"])
|
||||||
|
|
||||||
|
|
||||||
|
def active_admin_count(household_id: int) -> int:
|
||||||
|
row = get_db().execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM users
|
||||||
|
WHERE household_id = ? AND role = 'admin' AND is_active = 1
|
||||||
|
""",
|
||||||
|
(household_id,),
|
||||||
|
).fetchone()
|
||||||
|
return int(row["count"])
|
||||||
|
|
||||||
|
|
||||||
@click.command("init-db")
|
@click.command("init-db")
|
||||||
@with_appcontext
|
@with_appcontext
|
||||||
def init_db_command() -> None:
|
def init_db_command() -> None:
|
||||||
@@ -80,15 +278,25 @@ def init_db_command() -> None:
|
|||||||
@click.argument("username")
|
@click.argument("username")
|
||||||
@click.argument("password")
|
@click.argument("password")
|
||||||
@click.option("--display-name", default="", help="Friendly display name.")
|
@click.option("--display-name", default="", help="Friendly display name.")
|
||||||
|
@click.option("--email", default="", help="Optional email address.")
|
||||||
|
@click.option("--role", default="member", type=click.Choice(["admin", "member"]))
|
||||||
@with_appcontext
|
@with_appcontext
|
||||||
def create_user_command(username: str, password: str, display_name: str) -> None:
|
def create_user_command(username: str, password: str, display_name: str, email: str, role: str) -> None:
|
||||||
database = get_db()
|
database = get_db()
|
||||||
|
household_id = ensure_default_household(database)
|
||||||
database.execute(
|
database.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users (username, display_name, password_hash)
|
INSERT INTO users (household_id, username, email, display_name, role, password_hash)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(username.strip().lower(), display_name.strip(), generate_password_hash(password)),
|
(
|
||||||
|
household_id,
|
||||||
|
username.strip().lower(),
|
||||||
|
email.strip().lower() or None,
|
||||||
|
display_name.strip(),
|
||||||
|
role,
|
||||||
|
generate_password_hash(password),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
database.commit()
|
database.commit()
|
||||||
click.echo(f"User '{username}' created.")
|
click.echo(f"User '{username}' created.")
|
||||||
|
|||||||
+469
-215
File diff suppressed because it is too large
Load Diff
+40
-3
@@ -1,13 +1,29 @@
|
|||||||
PRAGMA foreign_keys = ON;
|
PRAGMA foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS households (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member',
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE RESTRICT
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique
|
||||||
|
ON users (email)
|
||||||
|
WHERE email IS NOT NULL AND email != '';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS dayparts (
|
CREATE TABLE IF NOT EXISTS dayparts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
@@ -17,6 +33,9 @@ CREATE TABLE IF NOT EXISTS dayparts (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS items (
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER,
|
||||||
|
owner_user_id INTEGER,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||||
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
|
kind TEXT NOT NULL CHECK (kind IN ('food', 'meal')),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
category TEXT,
|
category TEXT,
|
||||||
@@ -27,6 +46,8 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
updated_by INTEGER,
|
updated_by INTEGER,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
@@ -49,12 +70,17 @@ CREATE TABLE IF NOT EXISTS meal_components (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS shopping_entries (
|
CREATE TABLE IF NOT EXISTS shopping_entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER,
|
||||||
|
owner_user_id INTEGER,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||||
item_id INTEGER NOT NULL,
|
item_id INTEGER NOT NULL,
|
||||||
added_by INTEGER,
|
added_by INTEGER,
|
||||||
checked_by INTEGER,
|
checked_by INTEGER,
|
||||||
is_checked INTEGER NOT NULL DEFAULT 0,
|
is_checked INTEGER NOT NULL DEFAULT 0,
|
||||||
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
checked_at TEXT,
|
checked_at TEXT,
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL,
|
FOREIGN KEY (added_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL
|
FOREIGN KEY (checked_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
@@ -66,12 +92,17 @@ WHERE is_checked = 0;
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS plan_entries (
|
CREATE TABLE IF NOT EXISTS plan_entries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
household_id INTEGER,
|
||||||
|
owner_user_id INTEGER,
|
||||||
|
visibility TEXT NOT NULL DEFAULT 'shared',
|
||||||
plan_date TEXT NOT NULL,
|
plan_date TEXT NOT NULL,
|
||||||
daypart_id INTEGER NOT NULL,
|
daypart_id INTEGER NOT NULL,
|
||||||
item_id INTEGER NOT NULL,
|
item_id INTEGER NOT NULL,
|
||||||
note TEXT,
|
note TEXT,
|
||||||
created_by INTEGER,
|
created_by INTEGER,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (household_id) REFERENCES households(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (owner_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE,
|
FOREIGN KEY (daypart_id) REFERENCES dayparts(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
FOREIGN KEY (item_id) REFERENCES items(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
@@ -80,11 +111,17 @@ CREATE TABLE IF NOT EXISTS plan_entries (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_items_kind_name
|
CREATE INDEX IF NOT EXISTS idx_items_kind_name
|
||||||
ON items (kind, name);
|
ON items (kind, name);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_items_availability_name
|
CREATE INDEX IF NOT EXISTS idx_items_household_visibility
|
||||||
ON items (availability_state, name);
|
ON items (household_id, visibility, availability_state);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item
|
CREATE INDEX IF NOT EXISTS idx_item_dayparts_daypart_item
|
||||||
ON item_dayparts (daypart_id, item_id);
|
ON item_dayparts (daypart_id, item_id);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_plan_entries_plan_date_daypart
|
CREATE INDEX IF NOT EXISTS idx_plan_entries_plan_date_daypart
|
||||||
ON plan_entries (plan_date, daypart_id);
|
ON plan_entries (plan_date, daypart_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_entries_household_visibility
|
||||||
|
ON plan_entries (household_id, visibility, plan_date);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shopping_entries_household_visibility
|
||||||
|
ON shopping_entries (household_id, visibility, is_checked);
|
||||||
|
|||||||
+361
-183
@@ -1,20 +1,21 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--bg: #fff5ee;
|
--bg: #fff6ef;
|
||||||
--bg-elevated: rgba(255, 249, 244, 0.78);
|
--bg-elevated: rgba(255, 248, 242, 0.86);
|
||||||
--surface: rgba(255, 255, 255, 0.82);
|
--surface: rgba(255, 255, 255, 0.86);
|
||||||
--surface-strong: #fffdfa;
|
--surface-strong: #fffdf9;
|
||||||
--surface-soft: #fff0e3;
|
--surface-soft: #fff1e4;
|
||||||
--line: rgba(133, 113, 95, 0.12);
|
--line: rgba(126, 104, 85, 0.14);
|
||||||
--text: #342e2d;
|
--text: #352d2b;
|
||||||
--muted: #7c716d;
|
--muted: #7d7069;
|
||||||
--accent: #f0a46c;
|
--accent: #efab72;
|
||||||
--accent-strong: #dd8d52;
|
--accent-strong: #da8b4d;
|
||||||
--accent-soft: rgba(240, 164, 108, 0.18);
|
--accent-soft: rgba(239, 171, 114, 0.18);
|
||||||
--mint-soft: rgba(174, 214, 193, 0.24);
|
--mint-soft: rgba(174, 214, 193, 0.24);
|
||||||
--peach-soft: rgba(255, 210, 179, 0.24);
|
--peach-soft: rgba(255, 210, 179, 0.24);
|
||||||
--sky-soft: rgba(194, 213, 235, 0.2);
|
--sky-soft: rgba(194, 213, 235, 0.22);
|
||||||
--rose-soft: rgba(237, 196, 205, 0.22);
|
--rose-soft: rgba(237, 196, 205, 0.24);
|
||||||
|
--lilac-soft: rgba(199, 176, 224, 0.18);
|
||||||
--shadow: 0 20px 50px rgba(125, 92, 68, 0.10);
|
--shadow: 0 20px 50px rgba(125, 92, 68, 0.10);
|
||||||
--radius: 22px;
|
--radius: 22px;
|
||||||
--font-body: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
|
--font-body: "Avenir Next", "Segoe UI", "Helvetica Neue", sans-serif;
|
||||||
@@ -23,21 +24,22 @@
|
|||||||
|
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--bg: #201d1d;
|
--bg: #211d1c;
|
||||||
--bg-elevated: rgba(32, 29, 29, 0.82);
|
--bg-elevated: rgba(34, 29, 28, 0.86);
|
||||||
--surface: rgba(45, 39, 39, 0.82);
|
--surface: rgba(44, 38, 37, 0.86);
|
||||||
--surface-strong: #383131;
|
--surface-strong: #3a3230;
|
||||||
--surface-soft: #433a39;
|
--surface-soft: #473d3a;
|
||||||
--line: rgba(226, 232, 225, 0.1);
|
--line: rgba(228, 224, 220, 0.10);
|
||||||
--text: #f4efec;
|
--text: #f4efec;
|
||||||
--muted: #cabeb7;
|
--muted: #cbbeb7;
|
||||||
--accent: #f2b07d;
|
--accent: #f3b17d;
|
||||||
--accent-strong: #ffc190;
|
--accent-strong: #ffc28f;
|
||||||
--accent-soft: rgba(242, 176, 125, 0.18);
|
--accent-soft: rgba(243, 177, 125, 0.18);
|
||||||
--mint-soft: rgba(155, 198, 175, 0.20);
|
--mint-soft: rgba(155, 198, 175, 0.20);
|
||||||
--peach-soft: rgba(224, 161, 128, 0.18);
|
--peach-soft: rgba(224, 161, 128, 0.18);
|
||||||
--sky-soft: rgba(146, 171, 201, 0.18);
|
--sky-soft: rgba(146, 171, 201, 0.18);
|
||||||
--rose-soft: rgba(189, 133, 145, 0.20);
|
--rose-soft: rgba(189, 133, 145, 0.20);
|
||||||
|
--lilac-soft: rgba(170, 148, 204, 0.18);
|
||||||
--shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
|
--shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +47,8 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
html,
|
||||||
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
@@ -60,6 +63,10 @@ body {
|
|||||||
linear-gradient(180deg, var(--bg), color-mix(in srgb, var(--bg) 92%, #f6decb 8%));
|
linear-gradient(180deg, var(--bg), color-mix(in srgb, var(--bg) 92%, #f6decb 8%));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.has-mobile-nav {
|
||||||
|
padding-bottom: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -125,31 +132,39 @@ button.secondary:hover,
|
|||||||
grid-template-columns: auto 1fr auto;
|
grid-template-columns: auto 1fr auto;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.2rem;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.15rem;
|
||||||
background: var(--bg-elevated);
|
background: var(--bg-elevated);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
backdrop-filter: blur(26px) saturate(1.2);
|
backdrop-filter: blur(26px) saturate(1.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.85rem;
|
gap: 0.8rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand strong,
|
.brand strong,
|
||||||
h1, h2, h3, .planner-label {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
.planner-label {
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
.brand small {
|
.brand small {
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
margin-top: 0.12rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark {
|
.brand-mark {
|
||||||
@@ -159,7 +174,7 @@ h1, h2, h3, .planner-label {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(255, 236, 219, 0.92));
|
background: linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(255, 236, 219, 0.92));
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.8);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark img {
|
.brand-mark img {
|
||||||
@@ -167,71 +182,12 @@ h1, h2, h3, .planner-label {
|
|||||||
height: 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.45rem;
|
gap: 0.45rem;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav a {
|
.site-nav a {
|
||||||
@@ -244,18 +200,65 @@ 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);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link-inner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip,
|
||||||
|
.mobile-profile-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: color-mix(in srgb, var(--surface-strong) 88%, #fff 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.08rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-profile-link {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-profile-avatar {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-nav {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.25rem;
|
gap: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero,
|
.hero,
|
||||||
@@ -280,7 +283,7 @@ h1, h2, h3, .planner-label {
|
|||||||
.panel,
|
.panel,
|
||||||
.auth-card,
|
.auth-card,
|
||||||
.week-card {
|
.week-card {
|
||||||
padding: 1.35rem;
|
padding: 1.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
.hero {
|
||||||
@@ -301,7 +304,9 @@ h1, h2, h3, .planner-label {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3 {
|
h1,
|
||||||
|
h2,
|
||||||
|
h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,30 +320,42 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
font-size: 1.15rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lead,
|
.lead,
|
||||||
.muted,
|
.muted,
|
||||||
.empty-state,
|
.empty-state,
|
||||||
.empty-slot,
|
|
||||||
.planner-entry p,
|
.planner-entry p,
|
||||||
.simple-list span,
|
.simple-list span,
|
||||||
.simple-list small {
|
.simple-list small,
|
||||||
|
.helper-text {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.lead {
|
.lead {
|
||||||
max-width: 60ch;
|
max-width: 62ch;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.intro-pills,
|
||||||
|
.chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-row {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-grid,
|
.stats-grid,
|
||||||
.two-column,
|
.two-column,
|
||||||
.card-grid,
|
.card-grid,
|
||||||
.mini-card-grid,
|
.mini-card-grid,
|
||||||
.week-mini-grid,
|
.week-mini-grid,
|
||||||
.week-overview-grid {
|
.week-overview-grid,
|
||||||
|
.more-link-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
@@ -351,6 +368,14 @@ h3 {
|
|||||||
grid-template-columns: 1.05fr 0.95fr;
|
grid-template-columns: 1.05fr 0.95fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(310px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-card-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.week-mini-grid {
|
.week-mini-grid {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
}
|
}
|
||||||
@@ -359,10 +384,13 @@ h3 {
|
|||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.more-link-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
padding: 1.15rem 1.2rem;
|
padding: 1.15rem 1.2rem;
|
||||||
background:
|
background: linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--surface) 90%, #fff 10%));
|
||||||
linear-gradient(180deg, var(--surface), color-mix(in srgb, var(--surface) 90%, #fff 10%));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card span {
|
.stat-card span {
|
||||||
@@ -385,7 +413,8 @@ h3 {
|
|||||||
.form-actions,
|
.form-actions,
|
||||||
.week-nav,
|
.week-nav,
|
||||||
.week-card-head,
|
.week-card-head,
|
||||||
.planner-entry-top {
|
.planner-entry-top,
|
||||||
|
.more-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -397,6 +426,8 @@ h3 {
|
|||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.simple-list li,
|
.simple-list li,
|
||||||
@@ -405,39 +436,36 @@ h3 {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem 0;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.simple-list li:last-child {
|
.stacked-mobile {
|
||||||
border-bottom: 0;
|
align-items: flex-start;
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mini-card-grid {
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-card,
|
.mini-card,
|
||||||
.week-mini-card {
|
.week-mini-card,
|
||||||
|
.more-link-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 {
|
.week-mini-card,
|
||||||
padding: 1rem;
|
.more-link-card {
|
||||||
border-radius: 18px;
|
display: grid;
|
||||||
background: rgba(255, 255, 255, 0.4);
|
gap: 0.35rem;
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.more-link-card small {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.component-group,
|
||||||
.quick-food-panel {
|
.quick-food-panel {
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: rgba(255, 255, 255, 0.5);
|
background: rgba(255, 255, 255, 0.42);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,18 +480,6 @@ h3 {
|
|||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-mini-card {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip-row {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.45rem;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip,
|
.chip,
|
||||||
.status-pill {
|
.status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -487,8 +503,8 @@ h3 {
|
|||||||
background: var(--sky-soft);
|
background: var(--sky-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-grid {
|
.status-soft {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(310px, 1fr));
|
background: var(--lilac-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card {
|
.item-card {
|
||||||
@@ -550,14 +566,20 @@ h3 {
|
|||||||
width: min(560px, 100%);
|
width: min(560px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack-form {
|
.stack-form,
|
||||||
|
.stack-sections,
|
||||||
|
.planner-day-stack,
|
||||||
|
.planner-entry-list,
|
||||||
|
.week-entry-stack,
|
||||||
|
.week-slot-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack-form label,
|
.stack-form label,
|
||||||
.planner-entry-form label,
|
.planner-entry-form label,
|
||||||
.filter-form label {
|
.filter-form label,
|
||||||
|
.inline-form label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -576,6 +598,7 @@ h3 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"],
|
input[type="text"],
|
||||||
|
input[type="email"],
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
input[type="date"],
|
input[type="date"],
|
||||||
input[type="file"],
|
input[type="file"],
|
||||||
@@ -641,6 +664,11 @@ legend {
|
|||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.planner-entry-form-wide {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-form > :first-child,
|
||||||
.filter-form .wide,
|
.filter-form .wide,
|
||||||
.planner-entry-form .wide {
|
.planner-entry-form .wide {
|
||||||
grid-column: span 2;
|
grid-column: span 2;
|
||||||
@@ -652,22 +680,6 @@ legend {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-row {
|
|
||||||
padding: 1rem 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row-actions {
|
|
||||||
justify-content: end;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stack-sections,
|
|
||||||
.planner-day-stack,
|
|
||||||
.planner-entry-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.day-tile {
|
.day-tile {
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -706,7 +718,7 @@ legend {
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
background: linear-gradient(145deg, rgba(255,255,255,0.92), var(--peach-soft));
|
background: linear-gradient(145deg, rgba(255, 255, 255, 0.92), var(--peach-soft));
|
||||||
color: var(--accent-strong);
|
color: var(--accent-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,7 +752,7 @@ legend {
|
|||||||
background: color-mix(in srgb, var(--surface-strong) 76%, #fff 24%);
|
background: color-mix(in srgb, var(--surface-strong) 76%, #fff 24%);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.55);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-add-button:hover {
|
.quick-add-button:hover {
|
||||||
@@ -752,12 +764,13 @@ legend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.planner-entry {
|
.planner-entry {
|
||||||
padding: 0.9rem 1rem;
|
padding: 0.95rem 1rem;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
|
background: color-mix(in srgb, var(--surface) 88%, #fff 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.planner-entry-list .planner-entry {
|
.planner-entry-top {
|
||||||
background: color-mix(in srgb, var(--surface) 88%, #fff 12%);
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-card-count {
|
.week-card-count {
|
||||||
@@ -770,12 +783,6 @@ legend {
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-slot-stack {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.week-slot {
|
.week-slot {
|
||||||
padding: 0.85rem;
|
padding: 0.85rem;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
@@ -798,18 +805,18 @@ legend {
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-entry-stack {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.plan-chip {
|
.plan-chip {
|
||||||
padding: 0.7rem 0.8rem;
|
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));
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 246, 239, 0.92));
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
box-shadow: inset 0 1px 0 rgba(255,255,255,0.65);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-chip[draggable="false"] {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-chip:active {
|
.plan-chip:active {
|
||||||
@@ -854,29 +861,87 @@ legend {
|
|||||||
min-width: 5rem;
|
min-width: 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");
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
@media (max-width: 1080px) {
|
||||||
.site-header,
|
.site-header,
|
||||||
.hero,
|
.hero,
|
||||||
.page-intro,
|
.page-intro,
|
||||||
.panel-head,
|
.panel-head,
|
||||||
.week-card-head {
|
.week-card-head {
|
||||||
grid-template-columns: 1fr;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header {
|
.site-header {
|
||||||
position: static;
|
position: static;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats-grid,
|
.stats-grid,
|
||||||
.two-column,
|
.two-column,
|
||||||
.inline-form,
|
.inline-form,
|
||||||
.planner-entry-form,
|
.planner-entry-form,
|
||||||
.filter-form {
|
.planner-entry-form-wide,
|
||||||
|
.filter-form,
|
||||||
|
.quick-food-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-food-grid .wide,
|
||||||
|
.inline-form > :first-child,
|
||||||
.filter-form .wide,
|
.filter-form .wide,
|
||||||
.planner-entry-form .wide {
|
.planner-entry-form .wide {
|
||||||
grid-column: auto;
|
grid-column: auto;
|
||||||
@@ -886,28 +951,141 @@ legend {
|
|||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.page-shell {
|
.page-shell {
|
||||||
width: min(100% - 1rem, 100%);
|
width: min(100% - 1rem, 100%);
|
||||||
|
margin: 0.7rem auto 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-header {
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0.7rem;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desktop-nav,
|
||||||
|
.desktop-actions {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-profile-link {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 2.2rem;
|
||||||
|
height: 2.2rem;
|
||||||
|
border-radius: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand strong {
|
||||||
|
font-size: 1.04rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand small {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.page-intro,
|
||||||
|
.panel,
|
||||||
|
.auth-card,
|
||||||
|
.week-card {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.site-nav,
|
h1 {
|
||||||
.header-actions,
|
font-size: clamp(1.6rem, 7vw, 2rem);
|
||||||
.item-card,
|
}
|
||||||
.list-row,
|
|
||||||
.row-actions,
|
.lead {
|
||||||
.quick-add-row,
|
line-height: 1.45;
|
||||||
.filter-actions {
|
font-size: 0.98rem;
|
||||||
justify-content: start;
|
}
|
||||||
|
|
||||||
|
.stats-grid,
|
||||||
|
.two-column,
|
||||||
|
.card-grid,
|
||||||
|
.mini-card-grid,
|
||||||
|
.week-mini-grid,
|
||||||
|
.week-overview-grid,
|
||||||
|
.more-link-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card {
|
.item-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.week-nav {
|
.simple-list li,
|
||||||
align-items: start;
|
.list-row,
|
||||||
|
.planner-entry-top,
|
||||||
|
.week-nav,
|
||||||
|
.row-actions,
|
||||||
|
.item-actions,
|
||||||
|
.hero-actions,
|
||||||
|
.more-actions,
|
||||||
|
.filter-actions {
|
||||||
|
align-items: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-actions > *,
|
||||||
|
.item-actions > *,
|
||||||
|
.hero-actions > *,
|
||||||
|
.more-actions > * {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-add-button {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
left: 0.75rem;
|
||||||
|
right: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(26px) saturate(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-nav a {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0.28rem;
|
||||||
|
padding: 0.55rem 0.35rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-nav a.active {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-bottom-nav .ui-icon {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
let draggedEntry = null;
|
let draggedEntry = null;
|
||||||
|
|
||||||
board.querySelectorAll(".draggable-plan-entry").forEach((entry) => {
|
board.querySelectorAll(".draggable-plan-entry").forEach((entry) => {
|
||||||
|
if (entry.getAttribute("draggable") !== "true") return;
|
||||||
entry.addEventListener("dragstart", () => {
|
entry.addEventListener("dragstart", () => {
|
||||||
draggedEntry = entry;
|
draggedEntry = entry;
|
||||||
entry.classList.add("is-dragging");
|
entry.classList.add("is-dragging");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const storageKey = "nouri-theme";
|
const storageKey = "nouri-theme";
|
||||||
const toggle = () => document.querySelector("[data-theme-toggle]");
|
const toggles = () => Array.from(document.querySelectorAll("[data-theme-toggle]"));
|
||||||
|
|
||||||
const applyTheme = (theme) => {
|
const applyTheme = (theme) => {
|
||||||
const resolved = theme || localStorage.getItem(storageKey) || "auto";
|
const resolved = theme || localStorage.getItem(storageKey) || "auto";
|
||||||
@@ -11,17 +11,14 @@
|
|||||||
: resolved;
|
: resolved;
|
||||||
root.dataset.theme = finalTheme;
|
root.dataset.theme = finalTheme;
|
||||||
|
|
||||||
const button = toggle();
|
toggles().forEach((button) => {
|
||||||
if (button) {
|
|
||||||
button.textContent = finalTheme === "dark" ? "Hell" : "Dunkel";
|
button.textContent = finalTheme === "dark" ? "Hell" : "Dunkel";
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
applyTheme();
|
applyTheme();
|
||||||
const button = toggle();
|
toggles().forEach((button) => {
|
||||||
if (!button) return;
|
|
||||||
|
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const current = root.dataset.theme === "dark" ? "dark" : "light";
|
const current = root.dataset.theme === "dark" ? "dark" : "light";
|
||||||
const next = current === "dark" ? "light" : "dark";
|
const next = current === "dark" ? "light" : "dark";
|
||||||
@@ -29,4 +26,5 @@
|
|||||||
applyTheme(next);
|
applyTheme(next);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{% if user %}Nutzer bearbeiten{% else %}Nutzer anlegen{% endif %} | Nouri{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Nutzer verwalten</p>
|
||||||
|
<h1>{% if user %}{{ user.display_name or user.username }} bearbeiten{% else %}Neuen Nutzer anlegen{% endif %}</h1>
|
||||||
|
<p class="lead">Wenig Felder, klare Rollen und ein ruhiger Zugang für den gemeinsamen Haushalt.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel form-panel">
|
||||||
|
<form method="post" class="stack-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<label>
|
||||||
|
Anzeigename
|
||||||
|
<input type="text" name="display_name" value="{{ form_data.display_name }}" autocomplete="name">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Benutzername
|
||||||
|
<input type="text" name="username" value="{{ form_data.username }}" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
E-Mail
|
||||||
|
<input type="email" name="email" value="{{ form_data.email }}" autocomplete="email">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Rolle
|
||||||
|
<select name="role">
|
||||||
|
{% for value, label in role_labels.items() %}
|
||||||
|
<option value="{{ value }}" {% if form_data.role == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="inline-check">
|
||||||
|
<input type="checkbox" name="is_active" value="1" {% if form_data.is_active %}checked{% endif %}>
|
||||||
|
<span>Zugang aktiv</span>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{% if user %}Neues Passwort{% else %}Passwort{% endif %}
|
||||||
|
<input type="password" name="password" autocomplete="new-password" {% if not user %}required{% endif %}>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Passwort wiederholen
|
||||||
|
<input type="password" name="password_repeat" autocomplete="new-password" {% if not user %}required{% endif %}>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Zurück</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Nutzer verwalten | Nouri{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Nutzer verwalten</p>
|
||||||
|
<h1>Haushaltszugänge ruhig pflegen</h1>
|
||||||
|
<p class="lead">Admins können hier weitere Mitglieder anlegen, Rollen anpassen und Zugänge bei Bedarf pausieren.</p>
|
||||||
|
</div>
|
||||||
|
<a class="button" href="{{ url_for('admin.user_create') }}">Neuen Nutzer anlegen</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stack-list">
|
||||||
|
{% for user in users %}
|
||||||
|
<article class="list-row stacked-mobile">
|
||||||
|
<div>
|
||||||
|
<strong>{{ user.display_name or user.username }}</strong>
|
||||||
|
<p class="muted">
|
||||||
|
{{ user.username }}
|
||||||
|
{% if user.email %} · {{ user.email }}{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ role_labels[user.role] }}</span>
|
||||||
|
{% if user.is_active %}
|
||||||
|
<span class="chip status-home">Aktiv</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="chip status-archived">Pausiert</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if user.id == g.user.id %}
|
||||||
|
<span class="chip status-soft">Du</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row-actions">
|
||||||
|
<a class="ghost-button" href="{{ url_for('admin.user_edit', user_id=user.id) }}">Bearbeiten</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Archiv</p>
|
<p class="eyebrow">Archiv</p>
|
||||||
<h1>Frühere Ideen bleiben greifbar</h1>
|
<h1>Frühere Ideen bleiben greifbar</h1>
|
||||||
<p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder auf die Einkaufsliste setzen.</p>
|
<p class="lead">Das Archiv ist ein Erinnerungsspeicher. Von hier aus lassen sich vertraute Dinge leicht wieder einplanen oder einkaufen.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -23,6 +23,14 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Sichtbarkeit
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<div class="filter-actions">
|
<div class="filter-actions">
|
||||||
<button type="submit">Filtern</button>
|
<button type="submit">Filtern</button>
|
||||||
<a class="ghost-button" href="{{ url_for('main.archive_view') }}">Zurücksetzen</a>
|
<a class="ghost-button" href="{{ url_for('main.archive_view') }}">Zurücksetzen</a>
|
||||||
@@ -43,6 +51,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="item-body">
|
<div class="item-body">
|
||||||
<h2>{{ item.name }}</h2>
|
<h2>{{ item.name }}</h2>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
|
</div>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
||||||
{% if item.dayparts %}
|
{% if item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
@@ -64,10 +76,12 @@
|
|||||||
<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>
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
||||||
|
{% if item.can_edit %}
|
||||||
<form method="post" action="{{ url_for('main.item_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>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@
|
|||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<p class="eyebrow">Willkommen zurück</p>
|
<p class="eyebrow">Willkommen zurück</p>
|
||||||
<h1>Ruhig wieder einsteigen</h1>
|
<h1>Ruhig wieder einsteigen</h1>
|
||||||
<p class="lead">Nouri hilft beim Erinnern, Sichtbar-Machen und Planen. Ohne Zahlen, ohne Druck.</p>
|
<p class="lead">Nouri bleibt ein kleiner, freundlicher Ort für euren Alltag rund um Essen, Einkauf und Planung.</p>
|
||||||
|
|
||||||
<form method="post" class="stack-form">
|
<form method="post" class="stack-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<label>
|
<label>
|
||||||
Benutzername
|
Benutzername oder E-Mail
|
||||||
<input type="text" name="username" autocomplete="username" required>
|
<input type="text" name="username" autocomplete="username" required>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Mein Profil | Nouri{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Mein Profil</p>
|
||||||
|
<h1>{{ g.user.display_name or g.user.username }}</h1>
|
||||||
|
<p class="lead">Dein Zugang bleibt bewusst schlicht. Hier kannst du Namen, Login-Daten und Passwort pflegen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="intro-pills">
|
||||||
|
<span class="status-pill">{{ role_labels[g.user.role] }}</span>
|
||||||
|
{% if g.user.household_name %}
|
||||||
|
<span class="status-pill status-soft">{{ g.user.household_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="two-column">
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Basisdaten</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="stack-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<label>
|
||||||
|
Anzeigename
|
||||||
|
<input type="text" name="display_name" value="{{ g.user.display_name or '' }}" autocomplete="name">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Benutzername
|
||||||
|
<input type="text" name="username" value="{{ g.user.username }}" autocomplete="username" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
E-Mail
|
||||||
|
<input type="email" name="email" value="{{ g.user.email or '' }}" autocomplete="email">
|
||||||
|
</label>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h2>Passwort ändern</h2>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('auth.change_password') }}" class="stack-form">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<label>
|
||||||
|
Aktuelles Passwort
|
||||||
|
<input type="password" name="current_password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Neues Passwort
|
||||||
|
<input type="password" name="new_password" autocomplete="new-password" required>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Neues Passwort wiederholen
|
||||||
|
<input type="password" name="new_password_repeat" autocomplete="new-password" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Passwort ändern</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,14 +5,22 @@
|
|||||||
<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 könnt ihr die App gemeinsam nutzen. Die Daten bleiben lokal in dieser Installation.</p>
|
<p class="lead">Danach könnt ihr Nouri gemeinsam nutzen. Persönliche und gemeinsame Einträge lassen sich später ruhig auseinanderhalten.</p>
|
||||||
|
|
||||||
<form method="post" class="stack-form">
|
<form method="post" class="stack-form">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
|
<label>
|
||||||
|
Haushaltsname
|
||||||
|
<input type="text" name="household_name" autocomplete="organization" placeholder="z. B. Zuhause">
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Benutzername
|
Benutzername
|
||||||
<input type="text" name="username" autocomplete="username" required>
|
<input type="text" name="username" autocomplete="username" required>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
E-Mail
|
||||||
|
<input type="email" name="email" autocomplete="email" placeholder="Optional">
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Anzeigename
|
Anzeigename
|
||||||
<input type="text" name="display_name" autocomplete="name" placeholder="z. B. Heinz">
|
<input type="text" name="display_name" autocomplete="name" placeholder="z. B. Heinz">
|
||||||
|
|||||||
@@ -10,36 +10,49 @@
|
|||||||
<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>
|
<script defer src="{{ url_for('static', filename='js/planner.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="{% if g.user %}has-mobile-nav{% endif %}">
|
||||||
<div class="page-shell">
|
<div class="page-shell">
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
||||||
<span class="brand-mark">
|
<span class="brand-mark">
|
||||||
<img src="{{ url_for('static', filename='brand/nouri-icon.svg') }}" alt="">
|
<img src="{{ url_for('static', filename='brand/nouri-icon.svg') }}" alt="">
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span class="brand-copy">
|
||||||
<strong>Nouri</strong>
|
<strong>Nouri</strong>
|
||||||
<small>einfach essen planen</small>
|
<small>einfach essen planen</small>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{% if g.user %}
|
{% if g.user %}
|
||||||
<nav class="site-nav">
|
<nav class="site-nav desktop-nav">
|
||||||
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-sparkles"></span><span>Heute</span></span></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.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-cart-shopping"></span><span>Einkauf</span></span></a>
|
||||||
|
<a href="{{ url_for('main.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_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}"><span class="nav-link-inner"><span class="ui-icon icon-calendar"></span><span>Plan</span></span></a>
|
||||||
<a href="{{ url_for('main.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='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 '' }}"><span class="nav-link-inner"><span class="ui-icon icon-bowl-food"></span><span>Mahlzeiten</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 '' }}"><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 '' }}"><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 '' }}"><span class="nav-link-inner"><span class="ui-icon icon-house"></span><span>Zuhause</span></span></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.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.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>
|
<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">
|
|
||||||
<button class="theme-toggle" type="button" data-theme-toggle>Modus</button>
|
<div class="header-actions desktop-actions">
|
||||||
|
<button class="theme-toggle ghost-button" type="button" data-theme-toggle>Modus</button>
|
||||||
|
<a class="user-chip" href="{{ url_for('auth.profile') }}">
|
||||||
|
<span class="user-chip-title">{{ g.user.display_name or g.user.username }}</span>
|
||||||
|
<small>{{ role_labels[g.user.role] }}</small>
|
||||||
|
</a>
|
||||||
|
{% if g.user.role == 'admin' %}
|
||||||
|
<a class="ghost-button" href="{{ url_for('admin.user_list') }}">Nutzer verwalten</a>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Abmelden</button>
|
<button class="ghost-button" type="submit">Abmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<a class="mobile-profile-link" href="{{ url_for('main.more_view') }}" aria-label="Mehr">
|
||||||
|
<span class="mobile-profile-avatar">{{ (g.user.display_name or g.user.username)[0]|upper }}</span>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -57,5 +70,30 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if g.user %}
|
||||||
|
<nav class="mobile-bottom-nav" aria-label="Mobile Navigation">
|
||||||
|
<a href="{{ url_for('main.dashboard') }}" class="{{ 'active' if request.endpoint == 'main.dashboard' else '' }}">
|
||||||
|
<span class="ui-icon icon-sparkles"></span>
|
||||||
|
<span>Heute</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('main.shopping_list') }}" class="{{ 'active' if request.endpoint == 'main.shopping_list' else '' }}">
|
||||||
|
<span class="ui-icon icon-cart-shopping"></span>
|
||||||
|
<span>Einkauf</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('main.planner_day', date=today.isoformat()) }}" class="{{ 'active' if request.endpoint == 'main.planner_day' else '' }}">
|
||||||
|
<span class="ui-icon icon-calendar"></span>
|
||||||
|
<span>Plan</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('main.home_view') }}" class="{{ 'active' if request.endpoint == 'main.home_view' else '' }}">
|
||||||
|
<span class="ui-icon icon-house"></span>
|
||||||
|
<span>Zuhause</span>
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('main.more_view') }}" class="{{ 'active' if request.endpoint == 'main.more_view' or request.endpoint == 'auth.profile' or (request.endpoint or '').startswith('admin.') else '' }}">
|
||||||
|
<span class="ui-icon icon-archive"></span>
|
||||||
|
<span>Mehr</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Heute</p>
|
<p class="eyebrow">Heute</p>
|
||||||
<h1>Ein ruhiger Blick auf das, was gerade hilft</h1>
|
<h1>Ein ruhiger Blick auf euren Alltag</h1>
|
||||||
<p class="lead">Du siehst auf einen Blick, was zuhause da ist, was schon eingeplant wurde und wo du schnell weitermachen kannst.</p>
|
<p class="lead">Du siehst schnell, was zuhause da ist, was schon geplant wurde und was gemeinsam oder persönlich vorbereitet ist.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="hero-actions">
|
||||||
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
|
<a class="button" href="{{ url_for('main.planner_day', date=today.isoformat()) }}">Heutigen Tagesplan öffnen</a>
|
||||||
@@ -40,10 +40,14 @@
|
|||||||
{% if today_entries %}
|
{% if today_entries %}
|
||||||
<ul class="simple-list">
|
<ul class="simple-list">
|
||||||
{% for entry in today_entries %}
|
{% for entry in today_entries %}
|
||||||
<li>
|
<li class="stacked-mobile">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ entry.daypart_name }}</strong>
|
<strong>{{ entry.daypart_name }}</strong>
|
||||||
<span>{{ entry.item_name }}</span>
|
<span>{{ entry.item_name }}</span>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ entry.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if entry.availability_state == 'home' %}
|
{% if entry.availability_state == 'home' %}
|
||||||
<span class="status-pill status-home">zuhause</span>
|
<span class="status-pill status-home">zuhause</span>
|
||||||
@@ -52,7 +56,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty-state">Für heute ist noch nichts fest eingeplant. Das ist vollkommen okay.</p>
|
<p class="empty-state">Für heute ist noch nichts fest eingeplant. Ein kleiner Anfang reicht völlig.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
@@ -67,7 +71,8 @@
|
|||||||
<article class="mini-card">
|
<article class="mini-card">
|
||||||
<div class="mini-card-body">
|
<div class="mini-card-body">
|
||||||
<strong>{{ item.name }}</strong>
|
<strong>{{ item.name }}</strong>
|
||||||
<small>{{ item_kind_labels[item.kind] }}</small>
|
<small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}</small>
|
||||||
|
<small>{{ item.owner_label }}</small>
|
||||||
{% if item.dayparts %}
|
{% if item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
{% for daypart in item.dayparts %}
|
{% for daypart in item.dayparts %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Zuhause</p>
|
<p class="eyebrow">Zuhause</p>
|
||||||
<h1>Was aktuell da ist</h1>
|
<h1>Was aktuell da ist</h1>
|
||||||
<p class="lead">Sichtbar, ruhig und besser nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, wandert es nicht weg, sondern ins Archiv.</p>
|
<p class="lead">Sichtbar, ruhig und nach Tageszeiten sortiert. Wenn etwas aufgebraucht ist, bleibt es später im Archiv greifbar.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -15,6 +15,14 @@
|
|||||||
Suche
|
Suche
|
||||||
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
|
<input type="text" name="q" value="{{ query }}" placeholder="Nach Namen suchen">
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Sichtbarkeit
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Tageszeit
|
Tageszeit
|
||||||
<select name="daypart_id">
|
<select name="daypart_id">
|
||||||
@@ -51,6 +59,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="item-body">
|
<div class="item-body">
|
||||||
<h3>{{ item.name }}</h3>
|
<h3>{{ item.name }}</h3>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
|
</div>
|
||||||
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
<p class="muted">{{ item_kind_labels[item.kind] }}{% if item.category %} · {{ item.category }}{% endif %}</p>
|
||||||
{% if item.components %}
|
{% if item.components %}
|
||||||
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
<p class="muted">Mit: {{ item.components|join(', ') }}</p>
|
||||||
@@ -58,10 +70,12 @@
|
|||||||
</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>
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
||||||
|
{% if item.can_edit %}
|
||||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
<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>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button type="submit">Erneut einkaufen</button>
|
<button type="submit">Erneut einkaufen</button>
|
||||||
|
|||||||
@@ -5,8 +5,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
<p class="eyebrow">{{ item_kind_labels[kind] }}</p>
|
||||||
<h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1>
|
<h1>{% if item %}{{ item.name }} bearbeiten{% else %}Neue{% endif %} {{ item_kind_singular_labels[kind] }}</h1>
|
||||||
<p class="lead">Nur das Nötigste: Name, Bild, Tageszeiten und eine kleine Notiz, wenn sie hilft.</p>
|
<p class="lead">Nur das Nötigste: Name, Sichtbarkeit, Bild, Tageszeiten und eine kleine Notiz, wenn sie hilft.</p>
|
||||||
</div>
|
</div>
|
||||||
|
{% if item %}
|
||||||
|
<div class="intro-pills">
|
||||||
|
<span class="status-pill">{{ item.visibility_label }}</span>
|
||||||
|
<span class="status-pill status-soft">{{ item.owner_label }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel form-panel">
|
<section class="panel form-panel">
|
||||||
@@ -18,13 +24,26 @@
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Kategorie
|
Sichtbarkeit
|
||||||
<input type="text" name="category" list="category-list" value="{{ form_data.category }}" placeholder="z. B. Obst, Warmes, Snack">
|
<select name="visibility">
|
||||||
<datalist id="category-list">
|
{% for value, label in visibility_options %}
|
||||||
{% for category in categories %}
|
<option value="{{ value }}" {% if form_data.visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
<option value="{{ category }}"></option>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</datalist>
|
</select>
|
||||||
|
<small class="helper-text">{{ visibility_descriptions[form_data.visibility] }}</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Kategorie
|
||||||
|
<select name="category">
|
||||||
|
<option value="">Ohne Kategorie</option>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category }}" {% if form_data.category == category %}selected{% endif %}>{{ category }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% if form_data.category and form_data.category not in categories %}
|
||||||
|
<option value="{{ form_data.category }}" selected>{{ form_data.category }}</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
@@ -58,7 +77,7 @@
|
|||||||
{% if kind == 'meal' %}
|
{% if kind == 'meal' %}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Bestandteile der Mahlzeitenidee</legend>
|
<legend>Bestandteile der Mahlzeitenidee</legend>
|
||||||
<p class="muted">Optional: Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus vorhandenen und archivierten Lebensmitteln zusammenklicken.</p>
|
<p class="muted">Optional: Du kannst eine Mahlzeit frei als Idee anlegen oder sie aus sichtbaren Lebensmitteln zusammenklicken.</p>
|
||||||
{% if food_groups %}
|
{% if food_groups %}
|
||||||
<div class="stack-sections">
|
<div class="stack-sections">
|
||||||
{% for group in food_groups %}
|
{% for group in food_groups %}
|
||||||
@@ -71,7 +90,7 @@
|
|||||||
{% for food in group["items"] %}
|
{% for food in group["items"] %}
|
||||||
<label class="check-option">
|
<label class="check-option">
|
||||||
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
|
<input type="checkbox" name="component_ids" value="{{ food.id }}" {% if food.id in form_data.component_ids %}checked{% endif %}>
|
||||||
<span>{{ food.name }}</span>
|
<span>{{ food.name }} · {{ food.visibility_label }}</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +112,15 @@
|
|||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Kategorie
|
Kategorie
|
||||||
<input type="text" name="quick_food_category" value="{{ form_data.quick_food_category }}" list="category-list" placeholder="z. B. Milchprodukt">
|
<select name="quick_food_category">
|
||||||
|
<option value="">Ohne Kategorie</option>
|
||||||
|
{% for category in categories %}
|
||||||
|
<option value="{{ category }}" {% if form_data.quick_food_category == category %}selected{% endif %}>{{ category }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
{% if form_data.quick_food_category and form_data.quick_food_category not in categories %}
|
||||||
|
<option value="{{ form_data.quick_food_category }}" selected>{{ form_data.quick_food_category }}</option>
|
||||||
|
{% endif %}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="wide">
|
<label class="wide">
|
||||||
Notiz
|
Notiz
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<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 Einträge mit Foto, Tageszeiten und einem ruhigen Status zwischen Merkliste, Zuhause und Archiv.</p>
|
<p class="lead">Gemeinsame und persönliche Ideen bleiben hier ruhig sortiert und schnell wiederverwendbar.</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>
|
||||||
@@ -24,6 +24,14 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Sichtbarkeit
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if selected_visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Tageszeit
|
Tageszeit
|
||||||
<select name="daypart_id">
|
<select name="daypart_id">
|
||||||
@@ -56,10 +64,13 @@
|
|||||||
<h2>{{ item.name }}</h2>
|
<h2>{{ item.name }}</h2>
|
||||||
<span class="status-pill status-{{ item.availability_state }}">{{ availability_labels[item.availability_state] }}</span>
|
<span class="status-pill status-{{ item.availability_state }}">{{ availability_labels[item.availability_state] }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ item.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ item.owner_label }}</span>
|
||||||
|
</div>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
{% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %}
|
{% if item.category %}{{ item.category }}{% else %}ohne Kategorie{% endif %}
|
||||||
·
|
· {{ item_kind_labels[item.kind] }}
|
||||||
{{ item_kind_labels[item.kind] }}
|
|
||||||
</p>
|
</p>
|
||||||
{% if item.dayparts %}
|
{% if item.dayparts %}
|
||||||
<div class="chip-row">
|
<div class="chip-row">
|
||||||
@@ -76,19 +87,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
|
{% if item.can_edit %}
|
||||||
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
<a class="ghost-button" href="{{ url_for('main.item_edit', item_id=item.id) }}">Bearbeiten</a>
|
||||||
|
{% endif %}
|
||||||
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
<a class="ghost-button" href="{{ url_for('main.planner_day', date=today.isoformat(), item_id=item.id, daypart_id=item.primary_daypart_id) }}">Im Tagesplan öffnen</a>
|
||||||
<form method="post" action="{{ url_for('main.item_add_to_shopping', item_id=item.id) }}">
|
<form 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>
|
||||||
</form>
|
</form>
|
||||||
{% if item.availability_state != 'home' %}
|
{% if item.availability_state != 'home' and item.can_edit %}
|
||||||
<form method="post" action="{{ url_for('main.item_set_home', item_id=item.id) }}">
|
<form method="post" action="{{ url_for('main.item_set_home', item_id=item.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="secondary" type="submit">Als Zuhause markieren</button>
|
<button class="secondary" type="submit">Als Zuhause markieren</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if item.availability_state != 'archived' %}
|
{% if item.availability_state != 'archived' and item.can_edit %}
|
||||||
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
<form method="post" action="{{ url_for('main.item_archive', item_id=item.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Ins Archiv</button>
|
<button class="ghost-button" type="submit">Ins Archiv</button>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Mehr | Nouri{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<section class="page-intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Mehr</p>
|
||||||
|
<h1>Alles Weitere auf einen Blick</h1>
|
||||||
|
<p class="lead">Hier liegen die ruhigeren Nebenwege: Lebensmittel, Mahlzeiten, Woche, Profil und die kleinen Einstellungen.</p>
|
||||||
|
</div>
|
||||||
|
<div class="intro-pills">
|
||||||
|
<span class="status-pill">{{ g.user.display_name or g.user.username }}</span>
|
||||||
|
<span class="status-pill status-soft">{{ role_labels[g.user.role] }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="more-link-grid">
|
||||||
|
<a class="more-link-card" href="{{ url_for('main.item_list', kind='food') }}">
|
||||||
|
<strong>Lebensmittel</strong>
|
||||||
|
<small>Persönliche und gemeinsame Einträge ansehen</small>
|
||||||
|
</a>
|
||||||
|
<a class="more-link-card" href="{{ url_for('main.item_list', kind='meal') }}">
|
||||||
|
<strong>Mahlzeiten</strong>
|
||||||
|
<small>Mahlzeitenideen sammeln und wiederverwenden</small>
|
||||||
|
</a>
|
||||||
|
<a class="more-link-card" href="{{ url_for('main.planner') }}">
|
||||||
|
<strong>Woche</strong>
|
||||||
|
<small>Die nächsten sieben Tage im Blick behalten</small>
|
||||||
|
</a>
|
||||||
|
<a class="more-link-card" href="{{ url_for('main.archive_view') }}">
|
||||||
|
<strong>Archiv</strong>
|
||||||
|
<small>Vertraute Dinge leicht wiederfinden</small>
|
||||||
|
</a>
|
||||||
|
<a class="more-link-card" href="{{ url_for('auth.profile') }}">
|
||||||
|
<strong>Mein Profil</strong>
|
||||||
|
<small>Zugang, Name und Passwort pflegen</small>
|
||||||
|
</a>
|
||||||
|
{% if g.user.role == 'admin' %}
|
||||||
|
<a class="more-link-card" href="{{ url_for('admin.user_list') }}">
|
||||||
|
<strong>Nutzer verwalten</strong>
|
||||||
|
<small>Weitere Haushaltsmitglieder verwalten</small>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="more-actions">
|
||||||
|
<button class="ghost-button" type="button" data-theme-toggle>Modus wechseln</button>
|
||||||
|
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||||
|
{{ csrf_input() }}
|
||||||
|
<button class="ghost-button" type="submit">Abmelden</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -41,16 +41,17 @@
|
|||||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||||
|
<input type="hidden" name="visibility" value="{{ item.visibility }}">
|
||||||
<button class="quick-add-button" type="submit">
|
<button class="quick-add-button" type="submit">
|
||||||
<span>{{ item.name }}</span>
|
<span>{{ item.name }}</span>
|
||||||
<small>{{ item_kind_labels[item.kind] }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</small>
|
<small>{{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}{% if item.availability_state == 'home' %} · zuhause{% endif %}</small>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" class="planner-entry-form">
|
<form method="post" class="planner-entry-form planner-entry-form-wide">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
<input type="hidden" name="plan_date" value="{{ selected_date.isoformat() }}">
|
||||||
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
<input type="hidden" name="daypart_id" value="{{ section.daypart.id }}">
|
||||||
@@ -60,11 +61,19 @@
|
|||||||
<option value="">Etwas für {{ section.daypart.name }} wählen</option>
|
<option value="">Etwas für {{ section.daypart.name }} wählen</option>
|
||||||
{% for item in section.candidates %}
|
{% for item in section.candidates %}
|
||||||
<option value="{{ item.id }}" {% if section.selected_item_id == item.id %}selected{% endif %}>
|
<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 %}
|
{{ item.name }} · {{ item_kind_labels[item.kind] }} · {{ item.visibility_label }}{% if item.availability_state == 'home' %} · zuhause{% endif %}{% if item.dayparts and section.daypart.name not in item.dayparts %} · auch flexibel{% endif %}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Sichtbarkeit
|
||||||
|
<select name="visibility">
|
||||||
|
{% for value, label in visibility_options %}
|
||||||
|
<option value="{{ value }}" {% if section.default_visibility == value %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label class="wide">
|
<label class="wide">
|
||||||
Notiz
|
Notiz
|
||||||
<input type="text" name="note" placeholder="Optional, wenn eine kleine Erinnerung hilft">
|
<input type="text" name="note" placeholder="Optional, wenn eine kleine Erinnerung hilft">
|
||||||
@@ -80,13 +89,19 @@
|
|||||||
<div>
|
<div>
|
||||||
<strong>{{ entry.item_name }}</strong>
|
<strong>{{ entry.item_name }}</strong>
|
||||||
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
|
<small>{{ item_kind_labels[entry.item_kind] }}{% if entry.availability_state == 'home' %} · zuhause{% else %} · bei Bedarf auf Einkaufsliste{% endif %}</small>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ entry.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if entry.can_edit %}
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, date=selected_date.isoformat()) }}">
|
<form method="post" action="{{ url_for('main.planner_remove', entry_id=entry.id, date=selected_date.isoformat()) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Entfernen</button>
|
<button class="ghost-button" type="submit">Entfernen</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if entry.note %}
|
{% if entry.note %}
|
||||||
<p>{{ entry.note }}</p>
|
<p>{{ entry.note }}</p>
|
||||||
|
|||||||
@@ -49,9 +49,9 @@
|
|||||||
{% if slot.entries %}
|
{% if slot.entries %}
|
||||||
<div class="week-entry-stack">
|
<div class="week-entry-stack">
|
||||||
{% for entry in slot.entries %}
|
{% for entry in slot.entries %}
|
||||||
<article class="plan-chip draggable-plan-entry" draggable="true" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}">
|
<article class="plan-chip draggable-plan-entry" draggable="{{ 'true' if entry.can_edit else 'false' }}" data-entry-id="{{ entry.id }}" data-move-url="{{ url_for('main.planner_move', entry_id=entry.id) }}">
|
||||||
<strong>{{ entry.item_name }}</strong>
|
<strong>{{ entry.item_name }}</strong>
|
||||||
<small>{{ item_kind_labels[entry.item_kind] }}</small>
|
<small>{{ entry.visibility_label }} · {{ entry.owner_label }}</small>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Einkaufsliste</p>
|
<p class="eyebrow">Einkaufsliste</p>
|
||||||
<h1>Was noch mitkommen soll</h1>
|
<h1>Was noch mitkommen soll</h1>
|
||||||
<p class="lead">Abhaken legt Dinge automatisch unter Zuhause ab. So wird aus der Liste direkt sichtbarer Vorrat.</p>
|
<p class="lead">Abhaken legt Dinge automatisch unter Zuhause ab. Gemeinsame und persönliche Einträge bleiben dabei klar erkennbar.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -15,7 +15,10 @@
|
|||||||
<select name="item_id">
|
<select name="item_id">
|
||||||
<option value="">Bestehenden Eintrag hinzufügen</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] }} · {{ item.visibility_label }}
|
||||||
|
{% if item.availability_state == 'home' %} · zuhause{% endif %}
|
||||||
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button type="submit">Auf Liste setzen</button>
|
<button type="submit">Auf Liste setzen</button>
|
||||||
@@ -25,20 +28,26 @@
|
|||||||
{% if entries %}
|
{% if entries %}
|
||||||
<section class="stack-list">
|
<section class="stack-list">
|
||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
<article class="list-row">
|
<article class="list-row stacked-mobile">
|
||||||
<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 %} · hinzugefügt von {{ entry.display_name or entry.username }}{% endif %}</p>
|
<p class="muted">{{ item_kind_labels[entry.item_kind] }}</p>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">{{ entry.visibility_label }}</span>
|
||||||
|
<span class="chip status-soft">{{ entry.owner_label }}</span>
|
||||||
|
</div>
|
||||||
</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) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button type="submit">Eingekauft</button>
|
<button type="submit">Eingekauft</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if entry.can_edit %}
|
||||||
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}">
|
<form method="post" action="{{ url_for('main.shopping_remove', entry_id=entry.id) }}">
|
||||||
{{ csrf_input() }}
|
{{ csrf_input() }}
|
||||||
<button class="ghost-button" type="submit">Entfernen</button>
|
<button class="ghost-button" type="submit">Entfernen</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
Flask==3.1.1
|
Flask==3.1.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
export NOURI_DATA_DIR="${NOURI_DATA_DIR:-/app/data}"
|
||||||
|
mkdir -p "${NOURI_DATA_DIR}"
|
||||||
|
touch "${NOURI_DATA_DIR}/nouri.sqlite3"
|
||||||
|
mkdir -p "${NOURI_DATA_DIR}/uploads"
|
||||||
|
|
||||||
|
exec gunicorn \
|
||||||
|
--bind 0.0.0.0:8000 \
|
||||||
|
--workers 2 \
|
||||||
|
--threads 4 \
|
||||||
|
--timeout 60 \
|
||||||
|
wsgi:app
|
||||||
Reference in New Issue
Block a user