diff --git a/API_OPIS.txt b/API_OPIS.txt new file mode 100644 index 0000000..dc5a459 --- /dev/null +++ b/API_OPIS.txt @@ -0,0 +1,33 @@ +API aplikacji Lista Zakupów + +Autoryzacja: +- Authorization: Bearer TWOJ_TOKEN +- albo X-API-Token: TWOJ_TOKEN + +Token ma jednocześnie dwa ograniczenia: +1. zakresy (scopes), np. expenses:read, lists:read, templates:read +2. dozwolone endpointy + +Dostępne endpointy: +- GET /api/ping + Test poprawności tokenu. + +- GET /api/expenses/latest?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&list_id=ID&owner_id=ID&limit=50 + Zwraca ostatnie wydatki wraz z metadanymi listy i właściciela. + +- GET /api/expenses/summary?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD&list_id=ID&owner_id=ID + Zwraca sumę wydatków, liczbę rekordów i agregację po listach. + +- GET /api/lists?owner_id=ID&limit=50 + Zwraca listy z podstawowymi metadanymi. + +- GET /api/lists//expenses?limit=50 + Zwraca wydatki przypisane do konkretnej listy. + +- GET /api/templates?owner_id=ID + Zwraca aktywne szablony. + +Uwagi: +- limit odpowiedzi jest przycinany do max_limit ustawionego na tokenie +- daty przekazuj w formacie YYYY-MM-DD +- endpoint musi być zaznaczony na tokenie, samo posiadanie zakresu nie wystarczy diff --git a/CLI_OPIS.txt b/CLI_OPIS.txt new file mode 100644 index 0000000..f8ce26f --- /dev/null +++ b/CLI_OPIS.txt @@ -0,0 +1,30 @@ +Komendy CLI +=========== + +Admini +------- +flask admins list +flask admins create [--admin/--user] +flask admins promote +flask admins demote +flask admins set-password + +Listy +----- +flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" +flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" --owner admin +flask lists copy-schedule --source-list-id 12 --when "2026-03-20 18:30" --title "Zakupy piatkowe" + +Zasady dzialania +---------------- +- copy-schedule tworzy nowa liste na podstawie istniejacej +- kopiuje pozycje i przypisane kategorie +- ustawia nowy created_at na wartosc z parametru --when +- gdy lista byla tymczasowa i miala expires_at, termin wygasniecia jest przesuwany o ten sam odstep czasu +- wydatki i paragony nie sa kopiowane + + +SZABLONY I HISTORIA: +- Historia zmian listy jest widoczna w widoku listy właściciela. +- Szablon można utworzyć z panelu admina lub z poziomu listy właściciela. +- Admin może szybko utworzyć listę z szablonu i zduplikować listę jednym kliknięciem. diff --git a/Dockerfile b/Dockerfile deleted file mode 120000 index 019363a..0000000 --- a/Dockerfile +++ /dev/null @@ -1 +0,0 @@ -deploy/app/Dockerfile \ No newline at end of file diff --git a/README.md b/README.md index 66ce014..681620d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Prosta aplikacja webowa do zarządzania listami zakupów z obsługą użytkownik - Archiwizacja i udostępnianie list (publiczne/prywatne) - Statystyki wydatków z podziałem na okresy, statystyki dla użytkowników - Panel administracyjny (statystyki, produkty, paragony, zarządzanie, użytkowmicy) +- Tokeny API administratora i endpoint do pobierania ostatnich wydatków +- Ujednolicony UI formularzy, tabel i przycisków oraz drobne usprawnienia UX ## Wymagania @@ -85,4 +87,8 @@ DB_PORT=5432 DB_NAME=myapp DB_USER=user DB_PASSWORD=pass -``` \ No newline at end of file +``` + +## CLI + +Opis komend administracyjnych znajduje sie w pliku `CLI_OPIS.txt`. diff --git a/app.py b/app.py index 02c96de..b6ff207 100644 --- a/app.py +++ b/app.py @@ -1,4722 +1,11 @@ -import os -import secrets -import time -import mimetypes - -import sys -import platform -import psutil -import hashlib -import re -import traceback -import bcrypt -import colorsys -from pillow_heif import register_heif_opener -from datetime import datetime, timedelta, UTC, timezone -from urllib.parse import urlparse, urlunparse - -from flask import ( - Flask, - render_template, - redirect, - url_for, - request, - flash, - Blueprint, - send_from_directory, - request, - abort, - session, - jsonify, - g, -) -from flask_sqlalchemy import SQLAlchemy -from flask_login import ( - LoginManager, - UserMixin, - login_user, - login_required, - logout_user, - current_user, -) -from flask_compress import Compress -from flask_socketio import SocketIO, emit, join_room -from config import Config -from PIL import Image, ExifTags, ImageFilter, ImageOps -from werkzeug.middleware.proxy_fix import ProxyFix -from sqlalchemy import func, extract, inspect, or_, case, text, and_, literal -from sqlalchemy.orm import joinedload, load_only, aliased -from collections import defaultdict, deque -from functools import wraps - -# from flask_talisman import Talisman # import niżej pod warunkiem -from flask_session import Session -from types import SimpleNamespace -from pdf2image import convert_from_bytes -from urllib.parse import urlencode -from typing import Sequence, Any - -# OCR -import pytesseract -from pytesseract import Output -import logging - -app = Flask(__name__) -app.config.from_object(Config) - -# Konfiguracja nagłówków bezpieczeństwa z .env -csp_policy = ( - { - "default-src": "'self'", - "script-src": "'self' 'unsafe-inline'", - "style-src": "'self' 'unsafe-inline'", - "img-src": "'self' data:", - "connect-src": "'self'", - } - if app.config.get("ENABLE_CSP", True) - else None -) - -permissions_policy = {"browsing-topics": "()"} if app.config.get("ENABLE_PP") else None - -talisman_kwargs = { - "force_https": False, - "strict_transport_security": app.config.get("ENABLE_HSTS", True), - "frame_options": "DENY" if app.config.get("ENABLE_XFO", True) else None, - "permissions_policy": permissions_policy, - "content_security_policy": csp_policy, - "x_content_type_options": app.config.get("ENABLE_XCTO", True), - "strict_transport_security_include_subdomains": False, -} - -referrer_policy = app.config.get("REFERRER_POLICY") -if referrer_policy: - talisman_kwargs["referrer_policy"] = referrer_policy - -# jak naglowki wylaczone, nie ładuj talisman z pominięciem referrer_policy -effective_headers = { - k: v - for k, v in talisman_kwargs.items() - if k != "referrer_policy" and v not in (None, False) -} - -if effective_headers: - from flask_talisman import Talisman - - talisman = Talisman( - app, - session_cookie_secure=app.config.get("SESSION_COOKIE_SECURE", True), - **talisman_kwargs, - ) - print("[TALISMAN] Włączony z nagłówkami:", list(effective_headers.keys())) -else: - print("[TALISMAN] Pominięty — wszystkie nagłówki security wyłączone.") - - -register_heif_opener() # pillow_heif dla HEIC -SQLALCHEMY_ECHO = True -ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "heic", "pdf"} - -SYSTEM_PASSWORD = app.config.get("SYSTEM_PASSWORD") -DEFAULT_ADMIN_USERNAME = app.config.get("DEFAULT_ADMIN_USERNAME") -DEFAULT_ADMIN_PASSWORD = app.config.get("DEFAULT_ADMIN_PASSWORD") -UPLOAD_FOLDER = app.config.get("UPLOAD_FOLDER") -AUTHORIZED_COOKIE_VALUE = app.config.get("AUTHORIZED_COOKIE_VALUE") -AUTH_COOKIE_MAX_AGE = app.config.get("AUTH_COOKIE_MAX_AGE") -HEALTHCHECK_TOKEN = app.config.get("HEALTHCHECK_TOKEN") -SESSION_TIMEOUT_MINUTES = int(app.config.get("SESSION_TIMEOUT_MINUTES")) -SESSION_COOKIE_SECURE = app.config.get("SESSION_COOKIE_SECURE") -APP_PORT = int(app.config.get("APP_PORT")) - -app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"] -app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES) - -app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) -DEBUG_MODE = app.config.get("DEBUG_MODE", False) - -os.makedirs(UPLOAD_FOLDER, exist_ok=True) - -failed_login_attempts = defaultdict(deque) -MAX_ATTEMPTS = 10 -TIME_WINDOW = 60 * 60 - -WEBP_SAVE_PARAMS = { - "format": "WEBP", - "lossless": False, # False jeśli chcesz używać quality - "method": 6, - "quality": 95, # tylko jeśli lossless=False -} - -def read_commit(filename="version.txt", root_path=None): - base = root_path or os.path.dirname(os.path.abspath(__file__)) - path = os.path.join(base, filename) - if not os.path.exists(path): - return None - try: - commit = open(path, "r", encoding="utf-8").read().strip() - return commit[:12] if commit else None - except Exception: - return None - -commit = read_commit("version.txt", root_path=os.path.dirname(__file__)) or "dev" -APP_VERSION = commit - -app.config["APP_VERSION"] = APP_VERSION - -db = SQLAlchemy(app) - -# old -#socketio = SocketIO(app, async_mode="eventlet") -# new flask -#socketio = SocketIO(app, async_mode="eventlet", manage_session=False) - -# gevent -socketio = SocketIO(app, async_mode='gevent') - -login_manager = LoginManager(app) -login_manager.login_view = "login" - - -# flask-session -app.config["SESSION_TYPE"] = "sqlalchemy" -app.config["SESSION_SQLALCHEMY"] = db -Session(app) - - -# flask-compress -compress = Compress() -compress.init_app(app) - -static_bp = Blueprint("static_bp", __name__) - -# dla live -active_users = {} - - -def utcnow(): - return datetime.now(timezone.utc) - - -app_start_time = utcnow() - - -class User(UserMixin, db.Model): - id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.String(150), unique=True, nullable=False) - password_hash = db.Column(db.String(512), nullable=False) - is_admin = db.Column(db.Boolean, default=False) - - -# Tabela pośrednia -shopping_list_category = db.Table( - "shopping_list_category", - db.Column( - "shopping_list_id", - db.Integer, - db.ForeignKey("shopping_list.id"), - primary_key=True, - ), - db.Column( - "category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True - ), -) - - -class Category(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(100), unique=True, nullable=False) - - -class ShoppingList(db.Model): - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(150), nullable=False) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - - owner_id = db.Column(db.Integer, db.ForeignKey("user.id")) - owner = db.relationship("User", backref="lists", foreign_keys=[owner_id]) - - is_temporary = db.Column(db.Boolean, default=False) - share_token = db.Column(db.String(64), unique=True, nullable=True) - expires_at = db.Column(db.DateTime(timezone=True), nullable=True) - owner = db.relationship("User", backref="lists", lazy=True) - is_archived = db.Column(db.Boolean, default=False) - is_public = db.Column(db.Boolean, default=False) - - # Relacje - items = db.relationship("Item", back_populates="shopping_list", lazy="select") - receipts = db.relationship( - "Receipt", - back_populates="shopping_list", - cascade="all, delete-orphan", - lazy="select", - ) - expenses = db.relationship("Expense", back_populates="shopping_list", lazy="select") - categories = db.relationship( - "Category", - secondary=shopping_list_category, - backref=db.backref("shopping_lists", lazy="dynamic"), - ) - - -class Item(db.Model): - id = db.Column(db.Integer, primary_key=True) - list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id")) - name = db.Column(db.String(150), nullable=False) - # added_at = db.Column(db.DateTime, default=datetime.utcnow) - added_at = db.Column(db.DateTime, default=utcnow) - added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) - added_by_user = db.relationship( - "User", backref="added_items", lazy="joined", foreign_keys=[added_by] - ) - - purchased = db.Column(db.Boolean, default=False) - purchased_at = db.Column(db.DateTime, nullable=True) - quantity = db.Column(db.Integer, default=1) - note = db.Column(db.Text, nullable=True) - not_purchased = db.Column(db.Boolean, default=False) - not_purchased_reason = db.Column(db.Text, nullable=True) - position = db.Column(db.Integer, default=0) - - shopping_list = db.relationship("ShoppingList", back_populates="items") - - -class SuggestedProduct(db.Model): - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(150), unique=True, nullable=False) - usage_count = db.Column(db.Integer, default=0) - - -class Expense(db.Model): - id = db.Column(db.Integer, primary_key=True) - list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id")) - amount = db.Column(db.Float, nullable=False) - added_at = db.Column(db.DateTime, default=datetime.utcnow) - receipt_filename = db.Column(db.String(255), nullable=True) - - shopping_list = db.relationship("ShoppingList", back_populates="expenses") - - -class Receipt(db.Model): - id = db.Column(db.Integer, primary_key=True) - list_id = db.Column( - db.Integer, - db.ForeignKey("shopping_list.id", ondelete="CASCADE"), - nullable=False, - ) - filename = db.Column(db.String(255), nullable=False) - uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) - filesize = db.Column(db.Integer, nullable=True) - file_hash = db.Column(db.String(64), nullable=True, unique=True) - uploaded_by = db.Column(db.Integer, db.ForeignKey("user.id")) - version_token = db.Column(db.String(32), nullable=True) - - shopping_list = db.relationship("ShoppingList", back_populates="receipts") - uploaded_by_user = db.relationship("User", backref="uploaded_receipts") - - -class ListPermission(db.Model): - __tablename__ = "list_permission" - id = db.Column(db.Integer, primary_key=True) - list_id = db.Column( - db.Integer, - db.ForeignKey("shopping_list.id", ondelete="CASCADE"), - nullable=False, - ) - user_id = db.Column( - db.Integer, - db.ForeignKey("user.id", ondelete="CASCADE"), - nullable=False, - ) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - __table_args__ = (db.UniqueConstraint("list_id", "user_id", name="uq_list_user"),) - - -ShoppingList.permitted_users = db.relationship( - "User", - secondary="list_permission", - backref=db.backref("permitted_lists", lazy="dynamic"), - lazy="dynamic", -) - - -class AppSetting(db.Model): - key = db.Column(db.String(64), primary_key=True) - value = db.Column(db.Text, nullable=True) - - -class CategoryColorOverride(db.Model): - id = db.Column(db.Integer, primary_key=True) - category_id = db.Column( - db.Integer, db.ForeignKey("category.id"), unique=True, nullable=False - ) - color_hex = db.Column(db.String(7), nullable=False) # "#rrggbb" - - -def get_setting(key: str, default: str | None = None) -> str | None: - s = db.session.get(AppSetting, key) - return s.value if s else default - - -def set_setting(key: str, value: str | None): - s = db.session.get(AppSetting, key) - if (value or "").strip() == "": - if s: - db.session.delete(s) - else: - if not s: - s = AppSetting(key=key, value=value) - db.session.add(s) - else: - s.value = value - - -def get_ocr_keywords() -> list[str]: - raw = get_setting("ocr_keywords", None) - if raw: - try: - vals = ( - json.loads(raw) - if raw.strip().startswith("[") - else [v.strip() for v in raw.split(",")] - ) - return [v for v in vals if v] - except Exception: - pass - # domyślne – obecne w kodzie OCR - return [ - "razem do zapłaty", - "do zapłaty", - "suma", - "kwota", - "wartość", - "płatność", - "total", - "amount", - ] - - -# 1) nowa funkcja: tylko frazy użytkownika (bez domyślnych) -def get_user_ocr_keywords_only() -> list[str]: - raw = get_setting("ocr_keywords", None) - if not raw: - return [] - try: - if raw.strip().startswith("["): - vals = json.loads(raw) - else: - vals = [v.strip() for v in raw.split(",")] - return [v for v in vals if v] - except Exception: - return [] - - -_BASE_KEYWORDS_BLOCK = r""" - (?: - razem\s*do\s*zap[łl][aąo0]ty | - do\s*zap[łl][aąo0]ty | - suma | - kwota | - warto[śćs] | - płatno[śćs] | - total | - amount - ) -""" - - -def priority_keywords_pattern() -> re.Pattern: - user_terms = get_user_ocr_keywords_only() - if user_terms: - - escaped = [re.escape(t) for t in user_terms] - user_block = " | ".join(escaped) - combined = rf""" - \b( - {_BASE_KEYWORDS_BLOCK} - | {user_block} - )\b - """ - else: - combined = rf"""\b({_BASE_KEYWORDS_BLOCK})\b""" - return re.compile(combined, re.IGNORECASE | re.VERBOSE) - - -def category_color_for(c: Category) -> str: - ov = CategoryColorOverride.query.filter_by(category_id=c.id).first() - return ov.color_hex if ov else category_to_color(c.name) - - -def color_for_category_label(label: str) -> str: - cat = Category.query.filter(func.lower(Category.name) == label.lower()).first() - return category_color_for(cat) if cat else category_to_color(label) - - -def hash_password(password): - pepper = app.config["BCRYPT_PEPPER"] - peppered = (password + pepper).encode("utf-8") - salt = bcrypt.gensalt() - hashed = bcrypt.hashpw(peppered, salt) - return hashed.decode("utf-8") - - -def get_int_setting(key: str, default: int) -> int: - try: - v = get_setting(key, None) - return int(v) if v is not None and str(v).strip() != "" else default - except Exception: - return default - - -def check_password(stored_hash, password_input): - pepper = app.config["BCRYPT_PEPPER"] - peppered = (password_input + pepper).encode("utf-8") - if stored_hash.startswith("$2b$") or stored_hash.startswith("$2a$"): - try: - return bcrypt.checkpw(peppered, stored_hash.encode("utf-8")) - except Exception: - return False - return False - - -def set_authorized_cookie(response): - secure_flag = app.config["SESSION_COOKIE_SECURE"] - max_age = app.config.get("AUTH_COOKIE_MAX_AGE", 86400) - response.set_cookie( - "authorized", - AUTHORIZED_COOKIE_VALUE, - max_age=max_age, - secure=secure_flag, - httponly=True, - ) - return response - - -if app.config["SQLALCHEMY_DATABASE_URI"].startswith("sqlite:///"): - db_path = app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "", 1) - db_dir = os.path.dirname(db_path) - if db_dir and not os.path.exists(db_dir): - os.makedirs(db_dir, exist_ok=True) - print(f"Utworzono katalog bazy: {db_dir}") - - -with app.app_context(): - db.create_all() - - # --- Tworzenie admina --- - admin_username = DEFAULT_ADMIN_USERNAME - admin_password = DEFAULT_ADMIN_PASSWORD - password_hash = hash_password(admin_password) - - admin = User.query.filter_by(username=admin_username).first() - if admin: - if not admin.is_admin: - admin.is_admin = True - if not check_password(admin.password_hash, admin_password): - admin.password_hash = password_hash - print(f"[INFO] Zmieniono hasło admina '{admin_username}' z konfiguracji.") - db.session.commit() - else: - db.session.add( - User(username=admin_username, password_hash=password_hash, is_admin=True) - ) - db.session.commit() - - default_categories = app.config["DEFAULT_CATEGORIES"] - existing_names = { - c.name for c in Category.query.filter(Category.name.isnot(None)).all() - } - - existing_names_lower = {name.lower() for name in existing_names} - - missing = [ - cat for cat in default_categories if cat.lower() not in existing_names_lower - ] - - if missing: - db.session.add_all(Category(name=cat) for cat in missing) - db.session.commit() - print(f"[INFO] Dodano brakujące kategorie: {', '.join(missing)}") - # else: - # print("[INFO] Wszystkie domyślne kategorie już istnieją") - - -@static_bp.route("/static/js/") -def serve_js(filename): - response = send_from_directory("static/js", filename) - response.headers["Cache-Control"] = app.config["JS_CACHE_CONTROL"] - response.headers.pop("Content-Disposition", None) - return response - - -@static_bp.route("/static/css/") -def serve_css(filename): - response = send_from_directory("static/css", filename) - response.headers["Cache-Control"] = app.config["CSS_CACHE_CONTROL"] - response.headers.pop("Content-Disposition", None) - return response - - -@static_bp.route("/static/lib/js/") -def serve_js_lib(filename): - response = send_from_directory("static/lib/js", filename) - response.headers["Cache-Control"] = app.config["LIB_JS_CACHE_CONTROL"] - response.headers.pop("Content-Disposition", None) - return response - - -@static_bp.route("/static/lib/css/") -def serve_css_lib(filename): - response = send_from_directory("static/lib/css", filename) - response.headers["Cache-Control"] = app.config["LIB_CSS_CACHE_CONTROL"] - response.headers.pop("Content-Disposition", None) - return response - - -app.register_blueprint(static_bp) - - -def allowed_file(filename): - return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS - - -def generate_version_token(): - return secrets.token_hex(8) - - -def get_list_details(list_id): - shopping_list = ShoppingList.query.options( - joinedload(ShoppingList.items).joinedload(Item.added_by_user), - joinedload(ShoppingList.expenses), - joinedload(ShoppingList.receipts), - ).get_or_404(list_id) - - items = sorted(shopping_list.items, key=lambda i: i.position or 0) - expenses = shopping_list.expenses - total_expense = sum(e.amount for e in expenses) if expenses else 0 - receipts = shopping_list.receipts - - return shopping_list, items, receipts, expenses, total_expense - - -def get_total_expense_for_list(list_id, start_date=None, end_date=None): - query = db.session.query(func.sum(Expense.amount)).filter( - Expense.list_id == list_id - ) - - if start_date and end_date: - query = query.filter( - Expense.added_at >= start_date, Expense.added_at < end_date - ) - - return query.scalar() or 0 - - -def update_list_categories_from_form(shopping_list, form): - raw_vals = form.getlist("categories") - candidate_ids = set() - - for v in raw_vals: - if not v: - continue - v = v.strip() - try: - candidate_ids.add(int(v)) - continue - except ValueError: - pass - - cat = Category.query.filter(func.lower(Category.name) == v.lower()).first() - if cat: - candidate_ids.add(cat.id) - shopping_list.categories.clear() - if candidate_ids: - cats = Category.query.filter(Category.id.in_(candidate_ids)).all() - shopping_list.categories.extend(cats) - - -def generate_share_token(length=8): - return secrets.token_hex(length // 2) - - -def check_list_public(shopping_list): - if not shopping_list.is_public: - flash("Ta lista nie jest publicznie dostępna", "danger") - return False - return True - - -def enrich_list_data(l): - counts = ( - db.session.query( - func.count(Item.id), - func.sum(case((Item.purchased == True, 1), else_=0)), - func.sum(Expense.amount), - ) - .outerjoin(Expense, Expense.list_id == Item.list_id) - .filter(Item.list_id == l.id) - .first() - ) - - l.total_count = counts[0] or 0 - l.purchased_count = counts[1] or 0 - l.total_expense = counts[2] or 0 - - return l - - -def get_total_records(): - total = 0 - inspector = inspect(db.engine) - with db.engine.connect() as conn: - for table_name in inspector.get_table_names(): - count = conn.execute(text(f"SELECT COUNT(*) FROM {table_name}")).scalar() - total += count - return total - - -def save_resized_image(file, path): - try: - image = Image.open(file) - image.verify() - file.seek(0) - image = Image.open(file) - except Exception: - raise ValueError("Nieprawidłowy plik graficzny") - - try: - image = ImageOps.exif_transpose(image) - except Exception: - pass - - try: - image.thumbnail((1500, 1500)) - image = image.convert("RGB") - image.info.clear() - - new_path = path.rsplit(".", 1)[0] + ".webp" - # image.save(new_path, **WEBP_SAVE_PARAMS) - image.save(new_path, format="WEBP", method=6, quality=100) - - except Exception as e: - raise ValueError(f"Błąd podczas przetwarzania obrazu: {e}") - - -def redirect_with_flash( - message: str, category: str = "info", endpoint: str = "main_page" -): - flash(message, category) - return redirect(url_for(endpoint)) - - -def can_view_list(sl: ShoppingList) -> bool: - if current_user.is_authenticated: - if sl.owner_id == current_user.id: - return True - if sl.is_public: - return True - return ( - db.session.query(ListPermission.id) - .filter_by(list_id=sl.id, user_id=current_user.id) - .first() - is not None - ) - return bool(sl.is_public) - - -def db_bucket(col, kind: str = "month"): - name = db.engine.name # 'sqlite', 'mysql', 'mariadb', 'postgresql', ... - kind = (kind or "month").lower() - - if kind == "day": - if name == "sqlite": - return func.strftime("%Y-%m-%d", col) - elif name in ("mysql", "mariadb"): - return func.date_format(col, "%Y-%m-%d") - else: - return func.to_char(col, "YYYY-MM-DD") - - if kind == "week": - if name == "sqlite": - return func.printf( - "%s-W%s", func.strftime("%Y", col), func.strftime("%W", col) - ) - elif name in ("mysql", "mariadb"): - return func.date_format(col, "%x-W%v") - else: - return func.to_char(col, 'IYYY-"W"IW') - - if name == "sqlite": - return func.strftime("%Y-%m", col) - elif name in ("mysql", "mariadb"): - return func.date_format(col, "%Y-%m") - else: - return func.to_char(col, "YYYY-MM") - - -def visible_lists_clause_for_expenses(user_id: int, include_shared: bool, now_dt): - perm_subq = user_permission_subq(user_id) - - base = [ - ShoppingList.is_archived == False, - ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now_dt)), - ] - - if include_shared: - base.append( - or_( - ShoppingList.owner_id == user_id, - ShoppingList.is_public == True, - ShoppingList.id.in_(perm_subq), - ) - ) - else: - base.append(ShoppingList.owner_id == user_id) - - return base - - -def user_permission_subq(user_id): - return db.session.query(ListPermission.list_id).filter( - ListPermission.user_id == user_id - ) - - -def admin_required(f): - @wraps(f) - def decorated_function(*args, **kwargs): - if not current_user.is_authenticated or not current_user.is_admin: - return redirect_with_flash("Brak uprawnień do tej sekcji.", "danger") - return f(*args, **kwargs) - - return decorated_function - - -def get_progress(list_id: int) -> tuple[int, int, float]: - result = ( - db.session.query( - func.count(Item.id), - func.sum(case((Item.purchased == True, 1), else_=0)), - ) - .filter(Item.list_id == list_id) - .first() - ) - - if result is None: - total_count = 0 - purchased_count = 0 - else: - total_count = result[0] or 0 - purchased_count = result[1] or 0 - - percent = (purchased_count / total_count * 100) if total_count > 0 else 0 - return purchased_count, total_count, percent - - -def delete_receipts_for_list(list_id): - receipt_pattern = f"list_{list_id}_" - upload_folder = app.config["UPLOAD_FOLDER"] - for filename in os.listdir(upload_folder): - if filename.startswith(receipt_pattern): - try: - os.remove(os.path.join(upload_folder, filename)) - except Exception as e: - print(f"Nie udało się usunąć pliku {filename}: {e}") - - -def receipt_error(message): - if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": - return jsonify({"success": False, "error": message}), 400 - flash(message, "danger") - return redirect(request.referrer or url_for("main_page")) - - -def rotate_receipt_by_id(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - - if not os.path.exists(path): - raise FileNotFoundError("Plik nie istnieje") - - try: - image = Image.open(path) - rotated = image.rotate(-90, expand=True) - - rotated = rotated.convert("RGB") - rotated.info.clear() - - rotated.save(path, format="WEBP", method=6, quality=100) - receipt.version_token = generate_version_token() - recalculate_filesizes(receipt.id) - db.session.commit() - - return receipt - except Exception as e: - app.logger.exception("Błąd podczas rotacji pliku") - raise RuntimeError(f"Błąd podczas rotacji pliku: {e}") - - -def delete_receipt_by_id(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - - if os.path.exists(filepath): - os.remove(filepath) - - db.session.delete(receipt) - db.session.commit() - return receipt - - -def generate_new_receipt_filename(list_id): - timestamp = datetime.now().strftime("%Y%m%d_%H%M") - random_part = secrets.token_hex(3) - return f"list_{list_id}_{timestamp}_{random_part}.webp" - - -def handle_crop_receipt(receipt_id, file): - if not receipt_id or not file: - return {"success": False, "error": "Brak danych"} - - try: - receipt = Receipt.query.get_or_404(receipt_id) - path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - - save_resized_image(file, path) - receipt.version_token = generate_version_token() - recalculate_filesizes(receipt.id) - db.session.commit() - - return {"success": True} - except Exception as e: - app.logger.exception("Błąd podczas przycinania paragonu") - return {"success": False, "error": str(e)} - - -def recalculate_filesizes(receipt_id: int = None): - updated = 0 - not_found = 0 - unchanged = 0 - - if receipt_id is not None: - receipt = db.session.get(Receipt, receipt_id) - receipts = [receipt] if receipt else [] - else: - receipts = db.session.execute(db.select(Receipt)).scalars().all() - - for r in receipts: - if not r: - continue - filepath = os.path.join(app.config["UPLOAD_FOLDER"], r.filename) - if os.path.exists(filepath): - real_size = os.path.getsize(filepath) - if r.filesize != real_size: - r.filesize = real_size - updated += 1 - else: - unchanged += 1 - else: - not_found += 1 - - db.session.commit() - return updated, unchanged, not_found - - -def get_admin_expense_summary(): - now = datetime.now(timezone.utc) - current_year = now.year - current_month = now.month - - def calc_summary(expense_query, list_query): - total = expense_query.scalar() or 0 - year_total = ( - expense_query.filter( - extract("year", ShoppingList.created_at) == current_year - ).scalar() - or 0 - ) - month_total = ( - expense_query.filter( - extract("year", ShoppingList.created_at) == current_year, - extract("month", ShoppingList.created_at) == current_month, - ).scalar() - or 0 - ) - list_count = list_query.count() - avg = round(total / list_count, 2) if list_count else 0 - return { - "total": total, - "year": year_total, - "month": month_total, - "count": list_count, - "avg": avg, - } - - expense_base = db.session.query(func.sum(Expense.amount)).join( - ShoppingList, ShoppingList.id == Expense.list_id - ) - list_base = ShoppingList.query - - all = calc_summary(expense_base, list_base) - - active_condition = and_( - ShoppingList.is_archived == False, - ~( - (ShoppingList.is_temporary == True) - & (ShoppingList.expires_at != None) - & (ShoppingList.expires_at <= now) - ), - ) - active = calc_summary( - expense_base.filter(active_condition), list_base.filter(active_condition) - ) - - archived_condition = ShoppingList.is_archived == True - archived = calc_summary( - expense_base.filter(archived_condition), list_base.filter(archived_condition) - ) - - expired_condition = and_( - ShoppingList.is_archived == False, - ShoppingList.is_temporary == True, - ShoppingList.expires_at != None, - ShoppingList.expires_at <= now, - ) - expired = calc_summary( - expense_base.filter(expired_condition), list_base.filter(expired_condition) - ) - - return { - "all": all, - "active": active, - "archived": archived, - "expired": expired, - } - - -def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str: - # Stabilny hash -> int - hv = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16) - - # Proste mieszanie bitów, by uniknąć lokalnych skupień - def rotl(x, r, bits=128): - r %= bits - return ((x << r) | (x >> (bits - r))) & ((1 << bits) - 1) - - mix = hv ^ rotl(hv, 37) ^ rotl(hv, 73) ^ rotl(hv, 91) - - # Pełne pokrycie koła barw 0..360 - hue_deg = mix % 360 - - # Odpychanie lokalne po hue, by podobne nazwy nie lądowały zbyt blisko - gap = (rotl(mix, 17) % (2 * min_hue_gap_deg)) - min_hue_gap_deg # [-gap, +gap] - hue_deg = (hue_deg + gap) % 360 - - # DARK profil: niższa jasność i nieco mniejsza saturacja - s = 0.70 - l = 0.45 - - # Wąska wariacja, żeby uniknąć „neonów” i zachować spójność - s_var = ((rotl(mix, 29) % 5) - 2) / 100.0 # ±0.02 - l_var = ((rotl(mix, 53) % 7) - 3) / 100.0 # ±0.03 - s = min(0.76, max(0.62, s + s_var)) - l = min(0.50, max(0.40, l + l_var)) - - # Konwersja HLS->RGB (colorsys: H,L,S w [0..1]) - h = hue_deg / 360.0 - r, g, b = colorsys.hls_to_rgb(h, l, s) - - return f"#{int(round(r*255)):02x}{int(round(g*255)):02x}{int(round(b*255)):02x}" - - -def get_total_expenses_grouped_by_category( - show_all, range_type, start_date, end_date, user_id, category_id=None -): - now = datetime.now(timezone.utc) - lists_q = ShoppingList.query.filter( - ShoppingList.is_archived == False, - ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), - ) - - if show_all: - perm_subq = user_permission_subq(user_id) - lists_q = lists_q.filter( - or_( - ShoppingList.owner_id == user_id, - ShoppingList.is_public == True, - ShoppingList.id.in_(perm_subq), - ) - ) - else: - lists_q = lists_q.filter(ShoppingList.owner_id == user_id) - - if category_id: - if str(category_id) == "none": - lists_q = lists_q.filter(~ShoppingList.categories.any()) - else: - try: - cid = int(category_id) - lists_q = lists_q.join( - shopping_list_category, - shopping_list_category.c.shopping_list_id == ShoppingList.id, - ).filter(shopping_list_category.c.category_id == cid) - except (TypeError, ValueError): - pass - - if start_date and end_date: - try: - dt_start = datetime.strptime(start_date, "%Y-%m-%d") - dt_end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - lists_q = lists_q.filter( - ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end - ) - except Exception: - return {"error": "Błędne daty"} - - lists = lists_q.options(joinedload(ShoppingList.categories)).all() - if not lists: - return {"labels": [], "datasets": []} - - list_ids = [l.id for l in lists] - totals = ( - db.session.query( - Expense.list_id, - func.coalesce(func.sum(Expense.amount), 0).label("total_amount"), - ) - .filter(Expense.list_id.in_(list_ids)) - .group_by(Expense.list_id) - .all() - ) - expense_map = {lid: float(total or 0) for lid, total in totals} - - def bucket_from_dt(ts: datetime) -> str: - if range_type == "daily": - return ts.strftime("%Y-%m-%d") - elif range_type == "weekly": - return f"{ts.isocalendar().year}-W{ts.isocalendar().week:02d}" - elif range_type == "quarterly": - return f"{ts.year}-Q{((ts.month - 1)//3 + 1)}" - elif range_type == "halfyearly": - return f"{ts.year}-H{1 if ts.month <= 6 else 2}" - elif range_type == "yearly": - return str(ts.year) - else: - return ts.strftime("%Y-%m") - - data_map = defaultdict(lambda: defaultdict(float)) - all_labels = set() - - for l in lists: - key = bucket_from_dt(l.created_at) - all_labels.add(key) - total_expense = expense_map.get(l.id, 0.0) - - if str(category_id) == "none": - data_map[key]["Bez kategorii"] += total_expense - continue - - if not l.categories: - data_map[key]["Bez kategorii"] += total_expense - else: - for c in l.categories: - if category_id and str(c.id) != str(category_id): - continue - data_map[key][c.name] += total_expense - - labels = sorted(all_labels) - cats = sorted({cat for b in data_map.values() for cat, v in b.items() if v > 0}) - - datasets = [ - { - "label": cat, - "data": [round(data_map[label].get(cat, 0.0), 2) for label in labels], - "backgroundColor": color_for_category_label(cat), - } - for cat in cats - ] - return {"labels": labels, "datasets": datasets} - - -def get_total_expenses_grouped_by_list_created_at( - user_only=False, - admin=False, - show_all=False, - range_type="monthly", - start_date=None, - end_date=None, - user_id=None, - category_id=None, -): - now = datetime.now(timezone.utc) - lists_q = ShoppingList.query.filter( - ShoppingList.is_archived == False, - ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), - ) - - if admin: - pass - elif user_only: - lists_q = lists_q.filter(ShoppingList.owner_id == user_id) - elif show_all: - perm_subq = user_permission_subq(user_id) - lists_q = lists_q.filter( - or_( - ShoppingList.owner_id == user_id, - ShoppingList.is_public == True, - ShoppingList.id.in_(perm_subq), - ) - ) - else: - lists_q = lists_q.filter(ShoppingList.owner_id == user_id) - - # kategorie (bez ucinania „none”) - if category_id: - if str(category_id) == "none": - lists_q = lists_q.filter(~ShoppingList.categories.any()) - else: - try: - cid = int(category_id) - lists_q = lists_q.join( - shopping_list_category, - shopping_list_category.c.shopping_list_id == ShoppingList.id, - ).filter(shopping_list_category.c.category_id == cid) - except (TypeError, ValueError): - pass - - if start_date and end_date: - try: - dt_start = datetime.strptime(start_date, "%Y-%m-%d") - dt_end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - lists_q = lists_q.filter( - ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end - ) - except Exception: - return {"error": "Błędne daty"} - - lists = lists_q.options(joinedload(ShoppingList.categories)).all() - if not lists: - return {"labels": [], "expenses": []} - - list_ids = [l.id for l in lists] - totals = ( - db.session.query( - Expense.list_id, - func.coalesce(func.sum(Expense.amount), 0).label("total_amount"), - ) - .filter(Expense.list_id.in_(list_ids)) - .group_by(Expense.list_id) - .all() - ) - expense_map = {lid: float(total or 0) for lid, total in totals} - - def bucket_from_dt(ts: datetime) -> str: - if range_type == "daily": - return ts.strftime("%Y-%m-%d") - elif range_type == "weekly": - return f"{ts.isocalendar().year}-W{ts.isocalendar().week:02d}" - elif range_type == "quarterly": - return f"{ts.year}-Q{((ts.month - 1)//3 + 1)}" - elif range_type == "halfyearly": - return f"{ts.year}-H{1 if ts.month <= 6 else 2}" - elif range_type == "yearly": - return str(ts.year) - else: - return ts.strftime("%Y-%m") - - grouped = defaultdict(float) - for sl in lists: - grouped[bucket_from_dt(sl.created_at)] += expense_map.get(sl.id, 0.0) - - labels = sorted(grouped.keys()) - expenses = [round(grouped[l], 2) for l in labels] - return {"labels": labels, "expenses": expenses} - - -def resolve_range(range_type: str): - now = datetime.now(timezone.utc) - sd = ed = None - bucket = "monthly" - - rt = (range_type or "").lower() - if rt in ("last7days", "last_7_days"): - sd = (now - timedelta(days=7)).date().strftime("%Y-%m-%d") - ed = now.date().strftime("%Y-%m-%d") - bucket = "daily" - elif rt in ("last30days", "last_30_days"): - sd = (now - timedelta(days=30)).date().strftime("%Y-%m-%d") - ed = now.date().strftime("%Y-%m-%d") - bucket = "monthly" - elif rt in ("last90days", "last_90_days"): - sd = (now - timedelta(days=90)).date().strftime("%Y-%m-%d") - ed = now.date().strftime("%Y-%m-%d") - bucket = "monthly" - elif rt in ("thismonth", "this_month"): - first = datetime(now.year, now.month, 1, tzinfo=timezone.utc) - sd = first.date().strftime("%Y-%m-%d") - ed = now.date().strftime("%Y-%m-%d") - bucket = "monthly" - elif rt in ( - "currentmonth", - "thismonth", - "this_month", - "monthtodate", - "month_to_date", - "mtd", - ): - first = datetime(now.year, now.month, 1, tzinfo=timezone.utc) - sd = first.date().strftime("%Y-%m-%d") - ed = now.date().strftime("%Y-%m-%d") - bucket = "monthly" - - return sd, ed, bucket - - -def save_pdf_as_webp(file, path): - try: - images = convert_from_bytes(file.read(), dpi=300) - if not images: - raise ValueError("Nie udało się przekonwertować PDF na obraz.") - - total_height = sum(img.height for img in images) - max_width = max(img.width for img in images) - combined = Image.new("RGB", (max_width, total_height), (255, 255, 255)) - - y_offset = 0 - for img in images: - combined.paste(img, (0, y_offset)) - y_offset += img.height - - new_path = path.rsplit(".", 1)[0] + ".webp" - # combined.save(new_path, **WEBP_SAVE_PARAMS) - combined.save(new_path, format="WEBP") - - except Exception as e: - raise ValueError(f"Błąd podczas przetwarzania PDF: {e}") - - -def get_active_months_query(visible_lists_query=None): - if db.engine.name in ("sqlite",): - - def month_expr(col): - return func.strftime("%Y-%m", col) - - elif db.engine.name in ("mysql", "mariadb"): - - def month_expr(col): - return func.date_format(col, "%Y-%m") - - else: # PostgreSQL - - def month_expr(col): - return func.to_char(col, "YYYY-MM") - - if visible_lists_query is not None: - s = visible_lists_query.subquery() - month_sel = month_expr(s.c.created_at).label("month") - inner = ( - db.session.query(month_sel) - .filter(month_sel.isnot(None)) - .distinct() - .subquery() - ) - else: - month_sel = month_expr(ShoppingList.created_at).label("month") - inner = ( - db.session.query(month_sel) - .filter(ShoppingList.created_at.isnot(None)) - .distinct() - .subquery() - ) - - rows = db.session.query(inner.c.month).order_by(inner.c.month).all() - return [r.month for r in rows] - - -def normalize_name(name): - if not name: - return "" - return re.sub(r"\s+", " ", name).strip().lower() - - -def get_valid_item_or_404(item_id: int, list_id: int) -> Item: - item = db.session.get(Item, item_id) - if not item or item.list_id != list_id: - abort(404, description="Nie znaleziono produktu") - return item - - -def paginate_items( - items: Sequence[Any], page: int, per_page: int -) -> tuple[list, int, int]: - total_items = len(items) - total_pages = (total_items + per_page - 1) // per_page - start = (page - 1) * per_page - end = start + per_page - return items[start:end], total_items, total_pages - - -def get_page_args( - default_per_page: int = 100, max_per_page: int = 300 -) -> tuple[int, int]: - page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", default_per_page, type=int) - per_page = max(1, min(per_page, max_per_page)) - return page, per_page - - -############# OCR ########################### - - -def preprocess_image_for_tesseract(image): - # czułość 1..10 (domyślnie 5) - sens = get_int_setting("ocr_sensitivity", 5) - # próg progowy – im wyższa czułość, tym niższy próg (więcej czerni) - base_thresh = 150 - delta = int((sens - 5) * 8) # krok 8 na stopień - thresh = max(90, min(210, base_thresh - delta)) - - image = ImageOps.autocontrast(image) - image = image.point(lambda x: 0 if x < thresh else 255) - image = image.resize((image.width * 2, image.height * 2), Image.BICUBIC) - return image - - -def extract_total_tesseract(image): - - text = pytesseract.image_to_string(image, lang="pol", config="--psm 4") - lines = text.splitlines() - candidates = [] - - blacklist_keywords = re.compile(r"\b(ptu|vat|podatek|stawka)\b", re.IGNORECASE) - - priority_keywords = priority_keywords_pattern() - - for line in lines: - if not line.strip(): - continue - - if blacklist_keywords.search(line): - continue - - is_priority = priority_keywords.search(line) - - matches = re.findall(r"\d{1,4}[.,]\d{2}", line) - for match in matches: - try: - val = float(match.replace(",", ".")) - if 0.1 <= val <= 100000: - candidates.append((val, line, is_priority is not None)) - except: - continue - - if is_priority: - spaced = re.findall(r"\d{1,4}\s\d{2}", line) - for match in spaced: - try: - val = float(match.replace(" ", ".")) - if 0.1 <= val <= 100000: - candidates.append((val, line, True)) - except: - continue - - preferred = [(val, line) for val, line, is_pref in candidates if is_pref] - - if preferred: - best_val = max(preferred, key=lambda x: x[0])[0] - if best_val < 99999: - return round(best_val, 2), lines - - if candidates: - best_val = max(candidates, key=lambda x: x[0])[0] - if best_val < 99999: - return round(best_val, 2), lines - - data = pytesseract.image_to_data( - image, lang="pol", config="--psm 4", output_type=Output.DICT - ) - - font_candidates = [] - for i in range(len(data["text"])): - word = data["text"][i].strip() - if not word or not re.match(r"^\d{1,5}[.,\s]\d{2}$", word): - continue - - try: - val = float(word.replace(",", ".").replace(" ", ".")) - height = data["height"][i] - conf = int(data.get("conf", ["0"] * len(data["text"]))[i]) - if 0.1 <= val <= 100000: - font_candidates.append((val, height, conf)) - except: - continue - - if font_candidates: - best = max(font_candidates, key=lambda x: (x[1], x[2])) - return round(best[0], 2), lines - - return 0.0, lines - - -############# END OCR ####################### - - -# zabezpieczenie logowani do systemu - błędne hasła -def is_ip_blocked(ip): - now = time.time() - attempts = failed_login_attempts[ip] - while attempts and now - attempts[0] > TIME_WINDOW: - attempts.popleft() - max_attempts = get_int_setting("max_login_attempts", 10) - return len(attempts) >= max_attempts - - -def attempts_remaining(ip): - attempts = failed_login_attempts[ip] - max_attempts = get_int_setting("max_login_attempts", 10) - return max(0, max_attempts - len(attempts)) - - -def register_failed_attempt(ip): - now = time.time() - attempts = failed_login_attempts[ip] - while attempts and now - attempts[0] > TIME_WINDOW: - attempts.popleft() - attempts.append(now) - - -def reset_failed_attempts(ip): - failed_login_attempts[ip].clear() - - -#################################################### - - -def get_client_ip(): - for header in ["X-Forwarded-For", "X-Real-IP"]: - if header in request.headers: - ip = request.headers[header].split(",")[0].strip() - if ip: - return ip - return request.remote_addr - - -@login_manager.user_loader -def load_user(user_id): - return db.session.get(User, int(user_id)) - - -@app.context_processor -def inject_version(): - return {"APP_VERSION": app.config["APP_VERSION"]} - - -@app.context_processor -def inject_time(): - return dict(time=time) - - -@app.context_processor -def inject_has_authorized_cookie(): - return {"has_authorized_cookie": "authorized" in request.cookies} - - -@app.context_processor -def inject_is_blocked(): - ip = request.access_route[0] - return {"is_blocked": is_ip_blocked(ip)} - - -@app.before_request -def require_system_password(): - endpoint = request.endpoint - - if endpoint in ( - "static_bp.serve_js", - "static_bp.serve_css", - "static_bp.serve_js_lib", - "static_bp.serve_css_lib", - "favicon", - "favicon_ico", - "uploaded_file", - ): - return - - if endpoint in ("system_auth", "healthcheck", "robots_txt"): - return - - ip = request.access_route[0] - if is_ip_blocked(ip): - abort(403) - - if endpoint is None: - return - - if "authorized" not in request.cookies and not endpoint.startswith("login"): - if request.path == "/": - return redirect(url_for("system_auth")) - - parsed = urlparse(request.url) - fixed_url = urlunparse(parsed._replace(netloc=request.host)) - return redirect(url_for("system_auth", next=fixed_url)) - - -@app.after_request -def apply_headers(response): - # Specjalny endpoint wykresów/API – zawsze no-cache - if request.path == "/expenses_data": - response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" - response.headers["Pragma"] = "no-cache" - response.headers["Expires"] = "0" - return response - - # --- statyczne pliki (nagłówki z .env) --- - if request.path.startswith(("/static/", "/uploads/")): - response.headers.pop('Vary', None) # fix bug with backslash - response.headers['Vary'] = 'Accept-Encoding' - return response - - # --- healthcheck --- - if request.path == '/healthcheck': - response.headers['Cache-Control'] = 'no-store, no-cache' - response.headers.pop('ETag', None) - response.headers.pop('Vary', None) - return response - - # --- redirecty --- - if response.status_code in (301, 302, 303, 307, 308): - response.headers["Cache-Control"] = "no-store" - response.headers.pop("Vary", None) - return response - - # --- błędy 4xx --- - if 400 <= response.status_code < 500: - response.headers["Cache-Control"] = "no-store" - ct = (response.headers.get("Content-Type") or "").lower() - if "application/json" not in ct: - response.headers["Content-Type"] = "text/html; charset=utf-8" - response.headers.pop("Vary", None) - - # --- błędy 5xx --- - elif 500 <= response.status_code < 600: - response.headers["Cache-Control"] = "no-store" - ct = (response.headers.get("Content-Type") or "").lower() - if "application/json" not in ct: - response.headers["Content-Type"] = "text/html; charset=utf-8" - response.headers["Retry-After"] = "120" - response.headers.pop("Vary", None) - - # --- strony dynamiczne (domyślnie) --- - # Wszystko, co nie jest /static/ ani /uploads/ ma być no-store/no-cache - response.headers.setdefault("Cache-Control", "no-cache, no-store") - - return response - - -@app.before_request -def start_timer(): - g.start_time = time.time() - - -@app.after_request -def log_request(response): - if request.path == "/healthcheck": - return response - - ip = get_client_ip() - method = request.method - path = request.path - status = response.status_code - length = response.content_length or "-" - start = getattr(g, "start_time", None) - duration = round((time.time() - start) * 1000, 2) if start else "-" - agent = request.headers.get("User-Agent", "-") - - if status == 304: - app.logger.info( - f'REVALIDATED: {ip} - "{method} {path}" {status} {length} {duration}ms "{agent}"' - ) - else: - app.logger.info( - f'{ip} - "{method} {path}" {status} {length} {duration}ms "{agent}"' - ) - - app.logger.debug(f"Request headers: {dict(request.headers)}") - app.logger.debug(f"Response headers: {dict(response.headers)}") - return response - - -@app.template_filter("filemtime") -def file_mtime_filter(path): - try: - t = os.path.getmtime(path) - return datetime.fromtimestamp(t) - except Exception: - # return datetime.utcnow() - return datetime.now(timezone.utc) - - -@app.template_filter("todatetime") -def to_datetime_filter(s): - return datetime.strptime(s, "%Y-%m-%d") - - -@app.template_filter("filesizeformat") -def filesizeformat_filter(path): - try: - size = os.path.getsize(path) - for unit in ["B", "KB", "MB", "GB"]: - if size < 1024.0: - return f"{size:.1f} {unit}" - size /= 1024.0 - return f"{size:.1f} TB" - except Exception: - return "N/A" - - -@app.errorhandler(404) -def page_not_found(e): - return ( - render_template( - "errors.html", - code=404, - title="Strona nie znaleziona", - message="Ups! Podana strona nie istnieje lub została przeniesiona.", - ), - 404, - ) - - -@app.errorhandler(403) -def forbidden(e): - return ( - render_template( - "errors.html", - code=403, - title="Brak dostępu", - message=( - e.description - if e.description - else "Nie masz uprawnień do wyświetlenia tej strony." - ), - ), - 403, - ) - - -@app.route("/favicon.ico") -def favicon_ico(): - return redirect(url_for("static", filename="favicon.svg")) - - -@app.route("/favicon.svg") -def favicon(): - svg = """ - - 🛒 - - """ - return svg, 200, {"Content-Type": "image/svg+xml"} - - -@app.route("/") -def main_page(): - perm_subq = ( - user_permission_subq(current_user.id) if current_user.is_authenticated else None - ) - - now = datetime.now(timezone.utc) - - month_param = request.args.get("m", None) - start = end = None - - if month_param in (None, ""): - # domyślnie: bieżący miesiąc - month_str = now.strftime("%Y-%m") - start = datetime(now.year, now.month, 1, tzinfo=timezone.utc) - end = (start + timedelta(days=31)).replace(day=1) - elif month_param == "all": - month_str = "all" - start = end = None - else: - month_str = month_param - try: - year, month = map(int, month_str.split("-")) - start = datetime(year, month, 1, tzinfo=timezone.utc) - end = (start + timedelta(days=31)).replace(day=1) - except ValueError: - # jeśli m ma zły format – pokaż wszystko - month_str = "all" - start = end = None - - def date_filter(query): - if start and end: - query = query.filter( - ShoppingList.created_at >= start, ShoppingList.created_at < end - ) - return query - - if current_user.is_authenticated: - user_lists = ( - date_filter( - ShoppingList.query.filter( - ShoppingList.owner_id == current_user.id, - ShoppingList.is_archived == False, - (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), - ) - ) - .order_by(ShoppingList.created_at.desc()) - .all() - ) - - archived_lists = ( - ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=True) - .order_by(ShoppingList.created_at.desc()) - .all() - ) - - # publiczne cudze + udzielone mi (po list_permission) - public_lists = ( - date_filter( - ShoppingList.query.filter( - ShoppingList.owner_id != current_user.id, - ShoppingList.is_archived == False, - (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), - or_( - ShoppingList.is_public == True, - ShoppingList.id.in_(perm_subq), - ), - ) - ) - .order_by(ShoppingList.created_at.desc()) - .all() - ) - accessible_lists = public_lists # alias do szablonu: publiczne + udostępnione - else: - user_lists = [] - archived_lists = [] - public_lists = ( - date_filter( - ShoppingList.query.filter( - ShoppingList.is_public == True, - (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), - ShoppingList.is_archived == False, - ) - ) - .order_by(ShoppingList.created_at.desc()) - .all() - ) - accessible_lists = public_lists # dla gościa = tylko publiczne - - # Zakres miesięcy do selektora - if current_user.is_authenticated: - visible_lists_query = ShoppingList.query.filter( - or_( - ShoppingList.owner_id == current_user.id, - ShoppingList.is_public == True, - ShoppingList.id.in_(perm_subq), - ) - ) - else: - visible_lists_query = ShoppingList.query.filter(ShoppingList.is_public == True) - - month_options = get_active_months_query(visible_lists_query) - - # Statystyki dla wszystkich widocznych sekcji - all_lists = user_lists + accessible_lists + archived_lists - all_ids = [l.id for l in all_lists] - - if all_ids: - stats = ( - db.session.query( - Item.list_id, - func.count(Item.id).label("total_count"), - func.sum(case((Item.purchased == True, 1), else_=0)).label( - "purchased_count" - ), - func.sum(case((Item.not_purchased == True, 1), else_=0)).label( - "not_purchased_count" - ), - ) - .filter(Item.list_id.in_(all_ids)) - .group_by(Item.list_id) - .all() - ) - stats_map = { - s.list_id: ( - s.total_count or 0, - s.purchased_count or 0, - s.not_purchased_count or 0, - ) - for s in stats - } - - latest_expenses_map = dict( - db.session.query( - Expense.list_id, func.coalesce(func.sum(Expense.amount), 0) - ) - .filter(Expense.list_id.in_(all_ids)) - .group_by(Expense.list_id) - .all() - ) - - for l in all_lists: - total_count, purchased_count, not_purchased_count = stats_map.get( - l.id, (0, 0, 0) - ) - l.total_count = total_count - l.purchased_count = purchased_count - l.not_purchased_count = not_purchased_count - l.total_expense = latest_expenses_map.get(l.id, 0) - l.category_badges = [ - {"name": c.name, "color": category_color_for(c)} for c in l.categories - ] - else: - for l in all_lists: - l.total_count = 0 - l.purchased_count = 0 - l.not_purchased_count = 0 - l.total_expense = 0 - l.category_badges = [] - - return render_template( - "main.html", - user_lists=user_lists, - public_lists=public_lists, - accessible_lists=accessible_lists, - archived_lists=archived_lists, - now=now, - timedelta=timedelta, - month_options=month_options, - selected_month=month_str, - ) - - -@app.route("/system-auth", methods=["GET", "POST"]) -def system_auth(): - if ( - current_user.is_authenticated - or request.cookies.get("authorized") == AUTHORIZED_COOKIE_VALUE - ): - flash("Jesteś już zalogowany lub autoryzowany.", "info") - return redirect(url_for("main_page")) - - ip = request.access_route[0] - next_page = request.args.get("next") or url_for("main_page") - - if is_ip_blocked(ip): - flash( - "Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", - "danger", - ) - return render_template("system_auth.html"), 403 - - if request.method == "POST": - if request.form["password"] == SYSTEM_PASSWORD: - reset_failed_attempts(ip) - resp = redirect(next_page) - return set_authorized_cookie(resp) - else: - register_failed_attempt(ip) - if is_ip_blocked(ip): - flash( - "Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", - "danger", - ) - return render_template("system_auth.html"), 403 - remaining = attempts_remaining(ip) - flash(f"Nieprawidłowe hasło. Pozostało {remaining} prób.", "warning") - - return render_template("system_auth.html") - - -@app.route("/edit_my_list/", methods=["GET", "POST"]) -@login_required -def edit_my_list(list_id): - # --- Pobranie listy i weryfikacja właściciela --- - l = db.session.get(ShoppingList, list_id) - if l is None: - abort(404) - if l.owner_id != current_user.id: - abort(403, description="Nie jesteś właścicielem tej listy.") - - # Dane do widoku - receipts = ( - Receipt.query.filter_by(list_id=list_id) - .order_by(Receipt.uploaded_at.desc()) - .all() - ) - categories = Category.query.order_by(Category.name.asc()).all() - selected_categories_ids = {c.id for c in l.categories} - - next_page = request.args.get("next") or request.referrer - wants_json = ( - "application/json" in (request.headers.get("Accept") or "") - or request.headers.get("X-Requested-With") == "fetch" - ) - - if request.method == "POST": - action = request.form.get("action") - - # --- Nadanie dostępu (grant) --- - if action == "grant": - grant_username = (request.form.get("grant_username") or "").strip().lower() - if not grant_username: - if wants_json: - return jsonify(ok=False, error="empty"), 400 - flash("Podaj nazwę użytkownika do nadania dostępu.", "danger") - return redirect(next_page or request.url) - - u = User.query.filter(func.lower(User.username) == grant_username).first() - if not u: - if wants_json: - return jsonify(ok=False, error="not_found"), 404 - flash("Użytkownik nie istnieje.", "danger") - return redirect(next_page or request.url) - if u.id == current_user.id: - if wants_json: - return jsonify(ok=False, error="owner"), 409 - flash("Jesteś właścicielem tej listy.", "info") - return redirect(next_page or request.url) - - exists = ( - db.session.query(ListPermission.id) - .filter( - ListPermission.list_id == l.id, - ListPermission.user_id == u.id, - ) - .first() - ) - if not exists: - db.session.add(ListPermission(list_id=l.id, user_id=u.id)) - db.session.commit() - if wants_json: - return jsonify(ok=True, user={"id": u.id, "username": u.username}) - flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success") - else: - if wants_json: - return jsonify(ok=False, error="exists"), 409 - flash("Ten użytkownik już ma dostęp.", "info") - return redirect(next_page or request.url) - - # --- Odebranie dostępu (revoke) --- - revoke_user_id = request.form.get("revoke_user_id") - if revoke_user_id: - try: - uid = int(revoke_user_id) - except ValueError: - if wants_json: - return jsonify(ok=False, error="bad_id"), 400 - flash("Błędny identyfikator użytkownika.", "danger") - return redirect(next_page or request.url) - - ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete() - db.session.commit() - if wants_json: - return jsonify(ok=True, removed_user_id=uid) - flash("Odebrano dostęp użytkownikowi.", "success") - return redirect(next_page or request.url) - - # --- Przywracanie z archiwum --- - if "unarchive" in request.form: - l.is_archived = False - db.session.commit() - if wants_json: - return jsonify(ok=True, unarchived=True) - flash(f"Lista „{l.title}” została przywrócona.", "success") - return redirect(next_page or request.url) - - # --- Główny zapis pól formularza --- - move_to_month = request.form.get("move_to_month") - if move_to_month: - try: - year, month = map(int, move_to_month.split("-")) - l.created_at = datetime(year, month, 1, tzinfo=timezone.utc) - if not wants_json: - flash( - f"Zmieniono datę utworzenia listy na {l.created_at.strftime('%Y-%m-%d')}", - "success", - ) - except ValueError: - if not wants_json: - flash( - "Nieprawidłowy format miesiąca — zignorowano zmianę miesiąca.", - "danger", - ) - - new_title = (request.form.get("title") or "").strip() - is_public = "is_public" in request.form - is_temporary = "is_temporary" in request.form - is_archived = "is_archived" in request.form - expires_date = request.form.get("expires_date") - expires_time = request.form.get("expires_time") - - if not new_title: - if wants_json: - return jsonify(ok=False, error="title_empty"), 400 - flash("Podaj poprawny tytuł", "danger") - return redirect(next_page or request.url) - - l.title = new_title - l.is_public = is_public - l.is_temporary = is_temporary - l.is_archived = is_archived - - if expires_date and expires_time: - try: - combined = f"{expires_date} {expires_time}" - expires_dt = datetime.strptime(combined, "%Y-%m-%d %H:%M") - l.expires_at = expires_dt.replace(tzinfo=timezone.utc) - except ValueError: - if wants_json: - return jsonify(ok=False, error="bad_expiry"), 400 - flash("Błędna data lub godzina wygasania", "danger") - return redirect(next_page or request.url) - else: - l.expires_at = None - - # Kategorie (używa Twojej pomocniczej funkcji) - update_list_categories_from_form(l, request.form) - - db.session.commit() - if wants_json: - return jsonify(ok=True, saved=True) - flash("Zaktualizowano dane listy", "success") - return redirect(next_page or request.url) - - # GET: użytkownicy z dostępem - permitted_users = ( - db.session.query(User) - .join(ListPermission, ListPermission.user_id == User.id) - .where(ListPermission.list_id == l.id) - .order_by(User.username.asc()) - .all() - ) - - return render_template( - "edit_my_list.html", - list=l, - receipts=receipts, - categories=categories, - selected_categories=selected_categories_ids, - permitted_users=permitted_users, - ) - - -@app.route("/edit_my_list//suggestions", methods=["GET"]) -@login_required -def edit_my_list_suggestions(list_id: int): - # Weryfikacja listy i właściciela (prywatność) - l = db.session.get(ShoppingList, list_id) - if l is None: - abort(404) - if l.owner_id != current_user.id: - abort(403, description="Nie jesteś właścicielem tej listy.") - - q = (request.args.get("q") or "").strip().lower() - - # Historia nadawań uprawnień przez tego właściciela (po wszystkich jego listach) - subq = ( - db.session.query( - ListPermission.user_id.label("uid"), - func.count(ListPermission.id).label("grant_count"), - func.max(ListPermission.id).label("last_grant_id"), - ) - .join(ShoppingList, ShoppingList.id == ListPermission.list_id) - .filter(ShoppingList.owner_id == current_user.id) - .group_by(ListPermission.user_id) - .subquery() - ) - - query = db.session.query( - User.username, subq.c.grant_count, subq.c.last_grant_id - ).join(subq, subq.c.uid == User.id) - if q: - query = query.filter(func.lower(User.username).like(f"{q}%")) - - rows = ( - query.order_by( - subq.c.grant_count.desc(), - subq.c.last_grant_id.desc(), - func.lower(User.username).asc(), - ) - .limit(20) - .all() - ) - - return jsonify({"users": [r.username for r in rows]}) - - -@app.route("/delete_user_list/", methods=["POST"]) -@login_required -def delete_user_list(list_id): - - l = db.session.get(ShoppingList, list_id) - if l is None or l.owner_id != current_user.id: - abort(403, description="Nie jesteś właścicielem tej listy.") - - l = db.session.get(ShoppingList, list_id) - if l is None or l.owner_id != current_user.id: - abort(403) - delete_receipts_for_list(list_id) - Item.query.filter_by(list_id=list_id).delete() - Expense.query.filter_by(list_id=list_id).delete() - db.session.delete(l) - db.session.commit() - flash("Lista została usunięta", "success") - return redirect(url_for("main_page")) - - -@app.route("/toggle_visibility/", methods=["GET", "POST"]) -@login_required -def toggle_visibility(list_id): - l = db.session.get(ShoppingList, list_id) - if l is None: - abort(404) - - if l.owner_id != current_user.id: - if request.is_json or request.method == "POST": - return {"error": "Unauthorized"}, 403 - flash("Nie masz uprawnień do tej listy", "danger") - return redirect(url_for("main_page")) - - l.is_public = not l.is_public - db.session.commit() - - share_url = f"{request.url_root}share/{l.share_token}" - - if request.is_json or request.method == "POST": - return {"is_public": l.is_public, "share_url": share_url} - - if l.is_public: - flash("Lista została udostępniona publicznie", "success") - else: - flash("Lista została ukryta przed gośćmi", "info") - - return redirect(url_for("main_page")) - - -@app.route("/login", methods=["GET", "POST"]) -def login(): - if request.method == "POST": - username_input = request.form["username"].lower() - user = User.query.filter(func.lower(User.username) == username_input).first() - if user and check_password(user.password_hash, request.form["password"]): - session.permanent = True - login_user(user) - session.modified = True - flash("Zalogowano pomyślnie", "success") - return redirect(url_for("main_page")) - flash("Nieprawidłowy login lub hasło", "danger") - return render_template("login.html") - - -@app.route("/logout") -@login_required -def logout(): - logout_user() - flash("Wylogowano pomyślnie", "success") - return redirect(url_for("main_page")) - - -@app.route("/create", methods=["POST"]) -@login_required -def create_list(): - title = request.form.get("title") - is_temporary = request.form.get("temporary") == "1" - token = generate_share_token(8) - - expires_at = ( - datetime.now(timezone.utc) + timedelta(days=7) if is_temporary else None - ) - - new_list = ShoppingList( - title=title, - owner_id=current_user.id, - is_temporary=is_temporary, - share_token=token, - expires_at=expires_at, - ) - db.session.add(new_list) - db.session.commit() - flash("Utworzono nową listę", "success") - return redirect(url_for("view_list", list_id=new_list.id)) - - -@app.route("/list/") -@login_required -def view_list(list_id): - shopping_list = db.session.get(ShoppingList, list_id) - if not shopping_list: - abort(404) - - is_owner = current_user.id == shopping_list.owner_id - if not is_owner: - flash( - "Nie jesteś właścicielem listy, przekierowano do widoku publicznego.", - "warning", - ) - if current_user.is_admin: - flash( - "W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info" - ) - return redirect(url_for("shared_list", token=shopping_list.share_token)) - - shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id) - total_count = len(items) - purchased_count = len([i for i in items if i.purchased]) - percent = (purchased_count / total_count * 100) if total_count > 0 else 0 - - for item in items: - if item.added_by != shopping_list.owner_id: - item.added_by_display = ( - item.added_by_user.username if item.added_by_user else "?" - ) - else: - item.added_by_display = None - - shopping_list.category_badges = [ - {"name": c.name, "color": category_color_for(c)} - for c in shopping_list.categories - ] - - # Wszystkie kategorie (do selecta) - categories = Category.query.order_by(Category.name.asc()).all() - selected_categories_ids = {c.id for c in shopping_list.categories} - - # Najczęściej używane kategorie właściciela (top N) - popular_categories = ( - db.session.query(Category) - .join( - shopping_list_category, - shopping_list_category.c.category_id == Category.id, - ) - .join( - ShoppingList, - ShoppingList.id == shopping_list_category.c.shopping_list_id, - ) - .filter(ShoppingList.owner_id == current_user.id) - .group_by(Category.id) - .order_by(func.count(ShoppingList.id).desc(), func.lower(Category.name).asc()) - .limit(6) - .all() - ) - - # Użytkownicy z uprawnieniami do listy - permitted_users = ( - db.session.query(User) - .join(ListPermission, ListPermission.user_id == User.id) - .filter(ListPermission.list_id == shopping_list.id) - .order_by(User.username.asc()) - .all() - ) - - return render_template( - "list.html", - list=shopping_list, - items=items, - receipts=receipts, - total_count=total_count, - purchased_count=purchased_count, - percent=percent, - expenses=expenses, - total_expense=total_expense, - is_share=False, - is_owner=is_owner, - categories=categories, - selected_categories=selected_categories_ids, - permitted_users=permitted_users, - popular_categories=popular_categories, - ) - - -@app.route("/list//settings", methods=["POST"]) -@login_required -def list_settings(list_id): - # Uprawnienia: właściciel - l = db.session.get(ShoppingList, list_id) - if l is None: - abort(404) - if l.owner_id != current_user.id: - abort(403, description="Brak uprawnień do ustawień tej listy.") - - next_page = request.form.get("next") or url_for("view_list", list_id=list_id) - wants_json = ( - "application/json" in (request.headers.get("Accept") or "") - or request.headers.get("X-Requested-With") == "fetch" - ) - - action = request.form.get("action") - - # 1) Ustawienie kategorii (pojedynczy wybór z list.html -> modal kategorii) - if action == "set_category": - cid = request.form.get("category_id") - if cid in (None, "", "none"): - # usunięcie kategorii lub brak zmiany – w zależności od Twojej logiki - l.categories = [] - db.session.commit() - if wants_json: - return jsonify(ok=True, saved=True) - flash("Zapisano kategorię.", "success") - return redirect(next_page) - - try: - cid = int(cid) - except (TypeError, ValueError): - if wants_json: - return jsonify(ok=False, error="bad_category"), 400 - flash("Błędna kategoria.", "danger") - return redirect(next_page) - - c = db.session.get(Category, cid) - if not c: - if wants_json: - return jsonify(ok=False, error="bad_category"), 400 - flash("Błędna kategoria.", "danger") - return redirect(next_page) - - # Jeśli jeden wybór – zastąp listę kategorii jedną: - l.categories = [c] - db.session.commit() - if wants_json: - return jsonify(ok=True, saved=True) - flash("Zapisano kategorię.", "success") - return redirect(next_page) - - # 2) Nadanie dostępu (akceptuj 'grant_access' i 'grant') - if action in ("grant_access", "grant"): - grant_username = (request.form.get("grant_username") or "").strip().lower() - - if not grant_username: - if wants_json: - return jsonify(ok=False, error="empty_username"), 400 - flash("Podaj nazwę użytkownika.", "danger") - return redirect(next_page) - - # Szukamy użytkownika po username (case-insensitive) - u = User.query.filter(func.lower(User.username) == grant_username).first() - if not u: - if wants_json: - return jsonify(ok=False, error="not_found"), 404 - flash("Użytkownik nie istnieje.", "danger") - return redirect(next_page) - - # Właściciel już ma dostęp - if u.id == l.owner_id: - if wants_json: - return jsonify(ok=False, error="owner"), 409 - flash("Jesteś właścicielem tej listy.", "info") - return redirect(next_page) - - # Czy już ma dostęp? - exists = ( - db.session.query(ListPermission.id) - .filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id) - .first() - ) - if exists: - if wants_json: - return jsonify(ok=False, error="exists"), 409 - flash("Ten użytkownik już ma dostęp.", "info") - return redirect(next_page) - - # Zapis uprawnienia - db.session.add(ListPermission(list_id=l.id, user_id=u.id)) - db.session.commit() - - if wants_json: - # Zwracamy usera, żeby JS mógł dokleić token bez odświeżania - return jsonify(ok=True, user={"id": u.id, "username": u.username}) - flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success") - return redirect(next_page) - - # 3) Odebranie dostępu (po polu revoke_user_id, nie po action) - revoke_uid = request.form.get("revoke_user_id") - if revoke_uid: - try: - uid = int(revoke_uid) - except (TypeError, ValueError): - if wants_json: - return jsonify(ok=False, error="bad_user_id"), 400 - flash("Błędny identyfikator użytkownika.", "danger") - return redirect(next_page) - - # Nie pozwalaj usunąć właściciela - if uid == l.owner_id: - if wants_json: - return jsonify(ok=False, error="cannot_revoke_owner"), 400 - flash("Nie można odebrać dostępu właścicielowi.", "danger") - return redirect(next_page) - - ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete() - db.session.commit() - - if wants_json: - return jsonify(ok=True, removed_user_id=uid) - flash("Odebrano dostęp użytkownikowi.", "success") - return redirect(next_page) - - # 4) Nieznana akcja - if wants_json: - return jsonify(ok=False, error="unknown_action"), 400 - flash("Nieznana akcja.", "danger") - return redirect(next_page) - - -@app.route("/expenses") -@login_required -def expenses(): - start_date_str = request.args.get("start_date") - end_date_str = request.args.get("end_date") - category_id = request.args.get("category_id", type=str) - show_all = request.args.get("show_all", "true").lower() == "true" - - now = datetime.now(timezone.utc) - - visible_clause = visible_lists_clause_for_expenses( - user_id=current_user.id, include_shared=show_all, now_dt=now - ) - - lists_q = ShoppingList.query.filter(*visible_clause) - - if start_date_str and end_date_str: - try: - start = datetime.strptime(start_date_str, "%Y-%m-%d") - end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1) - lists_q = lists_q.filter( - ShoppingList.created_at >= start, - ShoppingList.created_at < end, - ) - except ValueError: - flash("Błędny zakres dat", "danger") - - if category_id: - if category_id == "none": - lists_q = lists_q.filter(~ShoppingList.categories.any()) - else: - try: - cid = int(category_id) - lists_q = lists_q.join( - shopping_list_category, - shopping_list_category.c.shopping_list_id == ShoppingList.id, - ).filter(shopping_list_category.c.category_id == cid) - except (TypeError, ValueError): - pass - - lists_filtered = ( - lists_q.options( - joinedload(ShoppingList.owner), joinedload(ShoppingList.categories) - ) - .order_by(ShoppingList.created_at.desc()) - .all() - ) - list_ids = [l.id for l in lists_filtered] or [-1] - - expenses = ( - Expense.query.options( - joinedload(Expense.shopping_list).joinedload(ShoppingList.owner), - joinedload(Expense.shopping_list).joinedload(ShoppingList.categories), - ) - .filter(Expense.list_id.in_(list_ids)) - .order_by(Expense.added_at.desc()) - .all() - ) - - totals_rows = ( - db.session.query( - ShoppingList.id.label("lid"), - func.coalesce(func.sum(Expense.amount), 0).label("total_expense"), - ) - .select_from(ShoppingList) - .filter(ShoppingList.id.in_(list_ids)) - .outerjoin(Expense, Expense.list_id == ShoppingList.id) - .group_by(ShoppingList.id) - .all() - ) - totals_map = {row.lid: float(row.total_expense or 0) for row in totals_rows} - - categories = ( - Category.query.join( - shopping_list_category, shopping_list_category.c.category_id == Category.id - ) - .join( - ShoppingList, ShoppingList.id == shopping_list_category.c.shopping_list_id - ) - .filter(ShoppingList.id.in_(list_ids)) - .distinct() - .order_by(Category.name.asc()) - .all() - ) - categories.append(SimpleNamespace(id="none", name="Bez kategorii")) - - expense_table = [ - { - "title": (e.shopping_list.title if e.shopping_list else "Nieznana"), - "amount": e.amount, - "added_at": e.added_at, - } - for e in expenses - ] - - lists_data = [ - { - "id": l.id, - "title": l.title, - "created_at": l.created_at, - "total_expense": totals_map.get(l.id, 0.0), - "owner_username": l.owner.username if l.owner else "?", - "categories": [c.id for c in l.categories], - } - for l in lists_filtered - ] - - return render_template( - "expenses.html", - expense_table=expense_table, - lists_data=lists_data, - categories=categories, - selected_category=category_id, - show_all=show_all, - ) - - -@app.route("/expenses_data") -@login_required -def expenses_data(): - range_type = request.args.get("range", "monthly") - start_date = request.args.get("start_date") - end_date = request.args.get("end_date") - show_all = request.args.get("show_all", "true").lower() == "true" - category_id = request.args.get("category_id") - by_category = request.args.get("by_category", "false").lower() == "true" - - if not start_date or not end_date: - sd, ed, bucket = resolve_range(range_type) - if sd and ed: - start_date = sd - end_date = ed - range_type = bucket - - if by_category: - result = get_total_expenses_grouped_by_category( - show_all=show_all, - range_type=range_type, - start_date=start_date, - end_date=end_date, - user_id=current_user.id, - category_id=category_id, - ) - else: - result = get_total_expenses_grouped_by_list_created_at( - user_only=False, - admin=False, - show_all=show_all, - range_type=range_type, - start_date=start_date, - end_date=end_date, - user_id=current_user.id, - category_id=category_id, - ) - - if "error" in result: - return jsonify({"error": result["error"]}), 400 - return jsonify(result) - - -@app.route("/share/") -# @app.route("/guest-list/") -@app.route("/shared/") -def shared_list(token=None, list_id=None): - now = datetime.now(timezone.utc) - - if token: - shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404() - - # jeśli lista wygasła – zablokuj (spójne z resztą aplikacji) - if ( - shopping_list.is_temporary - and shopping_list.expires_at - and shopping_list.expires_at <= now - ): - flash("Link wygasł.", "warning") - return redirect(url_for("main_page")) - - # >>> KLUCZOWE: pozwól wejść nawet, gdy niepubliczna (bez check_list_public) - list_id = shopping_list.id - - # >>> Jeśli zalogowany i nie jest właścicielem — auto-przypisz stałe uprawnienie - if current_user.is_authenticated and current_user.id != shopping_list.owner_id: - # dodaj wpis tylko jeśli go nie ma - exists = ( - db.session.query(ListPermission.id) - .filter( - ListPermission.list_id == shopping_list.id, - ListPermission.user_id == current_user.id, - ) - .first() - ) - if not exists: - db.session.add( - ListPermission(list_id=shopping_list.id, user_id=current_user.id) - ) - db.session.commit() - else: - shopping_list = ShoppingList.query.get_or_404(list_id) - - total_expense = get_total_expense_for_list(list_id) - shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id) - - shopping_list.category_badges = [ - {"name": c.name, "color": category_color_for(c)} - for c in shopping_list.categories - ] - - for item in items: - if item.added_by != shopping_list.owner_id: - item.added_by_display = ( - item.added_by_user.username if item.added_by_user else "?" - ) - else: - item.added_by_display = None - - return render_template( - "list_share.html", - list=shopping_list, - items=items, - receipts=receipts, - expenses=expenses, - total_expense=total_expense, - is_share=True, - ) - - -@app.route("/copy/") -@login_required -def copy_list(list_id): - original = ShoppingList.query.get_or_404(list_id) - token = generate_share_token(8) - new_list = ShoppingList( - title=original.title + " (Kopia)", owner_id=current_user.id, share_token=token - ) - db.session.add(new_list) - db.session.commit() - original_items = Item.query.filter_by(list_id=original.id).all() - for item in original_items: - copy_item = Item(list_id=new_list.id, name=item.name) - db.session.add(copy_item) - db.session.commit() - flash("Skopiowano listę", "success") - return redirect(url_for("view_list", list_id=new_list.id)) - - -@app.route("/suggest_products") -def suggest_products(): - query = request.args.get("q", "") - suggestions = [] - if query: - suggestions = ( - SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f"%{query}%")) - .limit(5) - .all() - ) - return {"suggestions": [s.name for s in suggestions]} - - -@app.route("/all_products") -def all_products(): - sort = request.args.get("sort", "popularity") - limit = request.args.get("limit", type=int) or 100 - offset = request.args.get("offset", type=int) or 0 - - products_from_items = db.session.query( - func.lower(func.trim(Item.name)).label("normalized_name"), - func.min(Item.name).label("display_name"), - func.count(func.distinct(Item.list_id)).label("count"), - ).group_by(func.lower(func.trim(Item.name))) - - products_from_suggested = ( - db.session.query( - func.lower(func.trim(SuggestedProduct.name)).label("normalized_name"), - func.min(SuggestedProduct.name).label("display_name"), - db.literal(1).label("count"), - ) - .filter( - ~func.lower(func.trim(SuggestedProduct.name)).in_( - db.session.query(func.lower(func.trim(Item.name))) - ) - ) - .group_by(func.lower(func.trim(SuggestedProduct.name))) - ) - - union_q = products_from_items.union_all(products_from_suggested).subquery() - - final_q = db.session.query( - union_q.c.normalized_name, - union_q.c.display_name, - func.sum(union_q.c.count).label("count"), - ).group_by(union_q.c.normalized_name, union_q.c.display_name) - - if sort == "alphabetical": - final_q = final_q.order_by(func.lower(union_q.c.display_name).asc()) - else: - final_q = final_q.order_by( - func.sum(union_q.c.count).desc(), func.lower(union_q.c.display_name).asc() - ) - - total_count = ( - db.session.query(func.count()).select_from(final_q.subquery()).scalar() - ) - products = final_q.offset(offset).limit(limit).all() - - out = [{"name": row.display_name, "count": row.count} for row in products] - - return jsonify({"products": out, "total_count": total_count}) - - -@app.route("/upload_receipt/", methods=["POST"]) -@login_required -def upload_receipt(list_id): - l = db.session.get(ShoppingList, list_id) - - file = request.files.get("receipt") - if not file or file.filename == "": - return receipt_error("Nie wybrano pliku") - - if not allowed_file(file.filename): - return receipt_error("Niedozwolony format pliku") - - file_bytes = file.read() - file.seek(0) - file_hash = hashlib.sha256(file_bytes).hexdigest() - - existing = Receipt.query.filter_by(file_hash=file_hash).first() - if existing: - return receipt_error("Taki plik już istnieje") - - now = datetime.now(timezone.utc) - timestamp = now.strftime("%Y%m%d_%H%M") - random_part = secrets.token_hex(3) - webp_filename = f"list_{list_id}_{timestamp}_{random_part}.webp" - file_path = os.path.join(app.config["UPLOAD_FOLDER"], webp_filename) - - try: - if file.filename.lower().endswith(".pdf"): - file.seek(0) - save_pdf_as_webp(file, file_path) - else: - save_resized_image(file, file_path) - except ValueError as e: - return receipt_error(str(e)) - - try: - new_receipt = Receipt( - list_id=list_id, - filename=webp_filename, - filesize=os.path.getsize(file_path), - uploaded_at=now, - file_hash=file_hash, - uploaded_by=current_user.id, - version_token=generate_version_token(), - ) - db.session.add(new_receipt) - db.session.commit() - except Exception as e: - return receipt_error(f"Błąd zapisu do bazy: {str(e)}") - - if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": - url = ( - url_for("uploaded_file", filename=webp_filename) - + f"?v={new_receipt.version_token or '0'}" - ) - socketio.emit("receipt_added", {"url": url}, to=str(list_id)) - return jsonify({"success": True, "url": url}) - - flash("Wgrano paragon", "success") - return redirect(request.referrer or url_for("main_page")) - - -@app.route("/uploads/") -def uploaded_file(filename): - response = send_from_directory(app.config["UPLOAD_FOLDER"], filename) - response.headers["Cache-Control"] = app.config["UPLOADS_CACHE_CONTROL"] - response.headers.pop("Content-Disposition", None) - mime, _ = mimetypes.guess_type(filename) - if mime: - response.headers["Content-Type"] = mime - return response - - -@app.route("/reorder_items", methods=["POST"]) -@login_required -def reorder_items(): - data = request.get_json() - list_id = data.get("list_id") - order = data.get("order") - - for index, item_id in enumerate(order): - item = db.session.get(Item, item_id) - if item and item.list_id == list_id: - item.position = index - db.session.commit() - - socketio.emit( - "items_reordered", {"list_id": list_id, "order": order}, to=str(list_id) - ) - - return jsonify(success=True) - - -@app.route("/rotate_receipt/") -@login_required -def rotate_receipt_user(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - list_obj = ShoppingList.query.get_or_404(receipt.list_id) - - if not (current_user.is_admin or current_user.id == list_obj.owner_id): - flash("Brak uprawnień do tej operacji", "danger") - return redirect(url_for("main_page")) - - try: - rotate_receipt_by_id(receipt_id) - recalculate_filesizes(receipt_id) - flash("Obrócono paragon", "success") - except FileNotFoundError: - flash("Plik nie istnieje", "danger") - except Exception as e: - flash(f"Błąd przy obracaniu: {str(e)}", "danger") - - return redirect(request.referrer or url_for("main_page")) - - -@app.route("/delete_receipt/") -@login_required -def delete_receipt_user(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - list_obj = ShoppingList.query.get_or_404(receipt.list_id) - - if not (current_user.is_admin or current_user.id == list_obj.owner_id): - flash("Brak uprawnień do tej operacji", "danger") - return redirect(url_for("main_page")) - - try: - delete_receipt_by_id(receipt_id) - flash("Paragon usunięty", "success") - except Exception as e: - flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger") - - return redirect(request.referrer or url_for("main_page")) - - -# OCR -@app.route("/lists//analyze", methods=["POST"]) -@login_required -def analyze_receipts_for_list(list_id): - receipt_objs = Receipt.query.filter_by(list_id=list_id).all() - existing_expenses = { - e.receipt_filename - for e in Expense.query.filter_by(list_id=list_id).all() - if e.receipt_filename - } - - results = [] - total = 0.0 - - for receipt in receipt_objs: - filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - if not os.path.exists(filepath): - continue - - try: - raw_image = Image.open(filepath).convert("RGB") - image = preprocess_image_for_tesseract(raw_image) - value, lines = extract_total_tesseract(image) - - except Exception as e: - print(f"OCR error for {receipt.filename}:\n{traceback.format_exc()}") - value = 0.0 - lines = [] - - already_added = receipt.filename in existing_expenses - - results.append( - { - "id": receipt.id, - "filename": receipt.filename, - "amount": round(value, 2), - "debug_text": lines, - "already_added": already_added, - } - ) - - # if not already_added: - total += value - - return jsonify({"results": results, "total": round(total, 2)}) - - -@app.route("/user_crop_receipt", methods=["POST"]) -@login_required -def crop_receipt_user(): - receipt_id = request.form.get("receipt_id") - file = request.files.get("cropped_image") - - receipt = Receipt.query.get_or_404(receipt_id) - list_obj = ShoppingList.query.get_or_404(receipt.list_id) - - if list_obj.owner_id != current_user.id and not current_user.is_admin: - return jsonify(success=False, error="Brak dostępu"), 403 - - result = handle_crop_receipt(receipt_id, file) - return jsonify(result) - - -@app.route("/admin") -@login_required -@admin_required -def admin_panel(): - month_str = request.args.get("m") - if not month_str: - month_str = datetime.now(timezone.utc).strftime("%Y-%m") - show_all = month_str == "all" - - if not show_all: - try: - if month_str: - year, month = map(int, month_str.split("-")) - now = datetime(year, month, 1, tzinfo=timezone.utc) - else: - now = datetime.now(timezone.utc) - month_str = now.strftime("%Y-%m") - except Exception: - now = datetime.now(timezone.utc) - month_str = now.strftime("%Y-%m") - start = now - end = (start + timedelta(days=31)).replace(day=1) - else: - now = datetime.now(timezone.utc) - start = end = None - - user_count = User.query.count() - list_count = ShoppingList.query.count() - item_count = Item.query.count() - - base_query = ShoppingList.query.options( - joinedload(ShoppingList.owner), - joinedload(ShoppingList.items), - joinedload(ShoppingList.receipts), - joinedload(ShoppingList.expenses), - joinedload(ShoppingList.categories), - ) - - if not show_all and start and end: - base_query = base_query.filter( - ShoppingList.created_at >= start, ShoppingList.created_at < end - ) - - all_lists = base_query.all() - all_ids = [l.id for l in all_lists] - - stats_map = {} - latest_expenses_map = {} - - if all_ids: - stats = ( - db.session.query( - Item.list_id, - func.count(Item.id).label("total_count"), - func.sum(case((Item.purchased == True, 1), else_=0)).label( - "purchased_count" - ), - ) - .filter(Item.list_id.in_(all_ids)) - .group_by(Item.list_id) - .all() - ) - stats_map = { - s.list_id: (s.total_count or 0, s.purchased_count or 0) for s in stats - } - - latest_expenses_map = dict( - db.session.query( - Expense.list_id, func.coalesce(func.sum(Expense.amount), 0) - ) - .filter(Expense.list_id.in_(all_ids)) - .group_by(Expense.list_id) - .all() - ) - - enriched_lists = [] - for l in all_lists: - total_count, purchased_count = stats_map.get(l.id, (0, 0)) - percent = (purchased_count / total_count * 100) if total_count > 0 else 0 - comments_count = sum(1 for i in l.items if i.note and i.note.strip() != "") - receipts_count = len(l.receipts) - total_expense = latest_expenses_map.get(l.id, 0) - - if l.is_temporary and l.expires_at: - expires_at = l.expires_at - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=timezone.utc) - is_expired = expires_at < now - else: - is_expired = False - - enriched_lists.append( - { - "list": l, - "total_count": total_count, - "purchased_count": purchased_count, - "percent": round(percent), - "comments_count": comments_count, - "receipts_count": receipts_count, - "total_expense": total_expense, - "expired": is_expired, - "categories": l.categories, - } - ) - - purchased_items_count = Item.query.filter_by(purchased=True).count() - not_purchased_count = Item.query.filter_by(not_purchased=True).count() - items_with_notes = Item.query.filter(Item.note.isnot(None), Item.note != "").count() - - total_expense = db.session.query(func.sum(Expense.amount)).scalar() or 0 - avg_list_expense = round(total_expense / list_count, 2) if list_count else 0 - - if db.engine.name == "sqlite": - timestamp_diff = func.strftime("%s", Item.purchased_at) - func.strftime( - "%s", Item.added_at - ) - elif db.engine.name in ("postgresql", "postgres"): - timestamp_diff = func.extract("epoch", Item.purchased_at) - func.extract( - "epoch", Item.added_at - ) - elif db.engine.name in ("mysql", "mariadb"): - timestamp_diff = func.timestampdiff( - text("SECOND"), Item.added_at, Item.purchased_at - ) - else: - timestamp_diff = None - - time_to_purchase = ( - db.session.query(func.avg(timestamp_diff)) - .filter( - Item.purchased == True, - Item.purchased_at.isnot(None), - Item.added_at.isnot(None), - ) - .scalar() - if timestamp_diff is not None - else None - ) - - avg_hours_to_purchase = round(time_to_purchase / 3600, 2) if time_to_purchase else 0 - - first_list = db.session.query(func.min(ShoppingList.created_at)).scalar() - last_list = db.session.query(func.max(ShoppingList.created_at)).scalar() - now_dt = datetime.now(timezone.utc) - - if first_list and first_list.tzinfo is None: - first_list = first_list.replace(tzinfo=timezone.utc) - - if last_list and last_list.tzinfo is None: - last_list = last_list.replace(tzinfo=timezone.utc) - - if first_list and last_list: - days_span = max((now_dt - first_list).days, 1) - avg_per_day = list_count / days_span - avg_per_week = round(avg_per_day * 7, 2) - avg_per_month = round(avg_per_day * 30.44, 2) - avg_per_year = round(avg_per_day * 365, 2) - else: - avg_per_week = avg_per_month = avg_per_year = 0 - - top_products = ( - db.session.query(Item.name, func.count(Item.id).label("count")) - .filter(Item.purchased.is_(True)) - .group_by(Item.name) - .order_by(func.count(Item.id).desc()) - .limit(7) - .all() - ) - - expense_summary = get_admin_expense_summary() - process = psutil.Process(os.getpid()) - app_mem = process.memory_info().rss // (1024 * 1024) - - db_engine = db.engine - db_info = { - "engine": db_engine.name, - "version": getattr(db_engine.dialect, "server_version_info", None), - "url": str(db_engine.url).split("?")[0], - } - - inspector = inspect(db_engine) - table_count = len(inspector.get_table_names()) - record_total = get_total_records() - uptime_minutes = int( - (datetime.now(timezone.utc) - app_start_time).total_seconds() // 60 - ) - - month_options = get_active_months_query() - - return render_template( - "admin/admin_panel.html", - user_count=user_count, - list_count=list_count, - item_count=item_count, - purchased_items_count=purchased_items_count, - not_purchased_count=not_purchased_count, - items_with_notes=items_with_notes, - avg_hours_to_purchase=avg_hours_to_purchase, - avg_list_expense=avg_list_expense, - avg_per_week=avg_per_week, - avg_per_month=avg_per_month, - avg_per_year=avg_per_year, - enriched_lists=enriched_lists, - top_products=top_products, - expense_summary=expense_summary, - now=now, - python_version=sys.version, - system_info=platform.platform(), - app_memory=f"{app_mem} MB", - db_info=db_info, - table_count=table_count, - record_total=record_total, - uptime_minutes=uptime_minutes, - timedelta=timedelta, - show_all=show_all, - month_str=month_str, - month_options=month_options, - ) - - -@app.route("/admin/add_user", methods=["POST"]) -@login_required -@admin_required -def add_user(): - username = request.form["username"].lower() - password = request.form["password"] - - if not username or not password: - flash("Wypełnij wszystkie pola", "danger") - return redirect(url_for("list_users")) - - if len(password) < 6: - flash("Hasło musi mieć co najmniej 6 znaków", "danger") - return redirect(url_for("list_users")) - - if User.query.filter(func.lower(User.username) == username).first(): - flash("Użytkownik o takiej nazwie już istnieje", "warning") - return redirect(url_for("list_users")) - - hashed_password = hash_password(password) - new_user = User(username=username, password_hash=hashed_password) - db.session.add(new_user) - db.session.commit() - flash("Dodano nowego użytkownika", "success") - return redirect(url_for("list_users")) - - -@app.route("/admin/users") -@login_required -@admin_required -def list_users(): - users = User.query.order_by(User.id.asc()).all() - - user_data = [] - for user in users: - list_count = ShoppingList.query.filter_by(owner_id=user.id).count() - item_count = Item.query.filter_by(added_by=user.id).count() - receipt_count = Receipt.query.filter_by(uploaded_by=user.id).count() - - user_data.append( - { - "user": user, - "list_count": list_count, - "item_count": item_count, - "receipt_count": receipt_count, - } - ) - - total_users = len(users) - - return render_template( - "admin/user_management.html", - user_data=user_data, - total_users=total_users, - ) - - -@app.route("/admin/change_password/", methods=["POST"]) -@login_required -@admin_required -def reset_password(user_id): - user = User.query.get_or_404(user_id) - new_password = request.form["password"] - - if not new_password: - flash("Podaj nowe hasło", "danger") - return redirect(url_for("list_users")) - - user.password_hash = hash_password(new_password) - db.session.commit() - flash(f"Hasło dla użytkownika {user.username} zostało zaktualizowane", "success") - return redirect(url_for("list_users")) - - -@app.route("/admin/delete_user/") -@login_required -@admin_required -def delete_user(user_id): - user = User.query.get_or_404(user_id) - - if user.is_admin: - flash("Nie można usunąć konta administratora.", "warning") - return redirect(url_for("list_users")) - - admin_user = User.query.filter_by(is_admin=True).first() - if not admin_user: - flash("Brak konta administratora do przeniesienia zawartości.", "danger") - return redirect(url_for("list_users")) - - lists_owned = ShoppingList.query.filter_by(owner_id=user.id).count() - - if lists_owned > 0: - ShoppingList.query.filter_by(owner_id=user.id).update( - {"owner_id": admin_user.id} - ) - Receipt.query.filter_by(uploaded_by=user.id).update( - {"uploaded_by": admin_user.id} - ) - Item.query.filter_by(added_by=user.id).update({"added_by": admin_user.id}) - db.session.commit() - flash( - f"Użytkownik '{user.username}' został usunięty, a jego zawartość przeniesiona na administratora.", - "success", - ) - else: - flash( - f"Użytkownik '{user.username}' został usunięty. Nie posiadał żadnych list zakupowych.", - "info", - ) - - db.session.delete(user) - db.session.commit() - - return redirect(url_for("list_users")) - - -@app.route("/admin/receipts", methods=["GET"]) -@app.route("/admin/receipts/", methods=["GET"]) -@login_required -@admin_required -def admin_receipts(list_id=None): - try: - page, per_page = get_page_args(default_per_page=24, max_per_page=200) - - if list_id is not None: - all_receipts = ( - Receipt.query.options(joinedload(Receipt.uploaded_by_user)) - .filter_by(list_id=list_id) - .order_by(Receipt.uploaded_at.desc()) - .all() - ) - receipts_paginated, total_items, total_pages = paginate_items( - all_receipts, page, per_page - ) - orphan_files = [] - id = list_id - else: - all_filenames = { - r.filename for r in Receipt.query.with_entities(Receipt.filename).all() - } - - pagination = ( - Receipt.query.options(joinedload(Receipt.uploaded_by_user)) - .order_by(Receipt.uploaded_at.desc()) - .paginate(page=page, per_page=per_page, error_out=False) - ) - - receipts_paginated = pagination.items - total_pages = pagination.pages - id = "all" - - upload_folder = app.config["UPLOAD_FOLDER"] - files_on_disk = set(os.listdir(upload_folder)) - orphan_files = [ - f - for f in files_on_disk - if f.endswith(".webp") - and f not in all_filenames - and f.startswith("list_") - ] - - except ValueError: - flash("Nieprawidłowe ID listy.", "danger") - return redirect(url_for("admin_panel")) - - total_filesize = db.session.query(func.sum(Receipt.filesize)).scalar() or 0 - page_filesize = sum(r.filesize or 0 for r in receipts_paginated) - - query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) - - return render_template( - "admin/receipts.html", - receipts=receipts_paginated, - orphan_files=orphan_files, - orphan_files_count=len(orphan_files), - page=page, - per_page=per_page, - total_pages=total_pages, - id=id, - query_string=query_string, - total_filesize=total_filesize, - page_filesize=page_filesize, - ) - - -@app.route("/admin/rotate_receipt/") -@login_required -@admin_required -def rotate_receipt(receipt_id): - try: - rotate_receipt_by_id(receipt_id) - recalculate_filesizes(receipt_id) - flash("Obrócono paragon", "success") - except FileNotFoundError: - flash("Plik nie istnieje", "danger") - except Exception as e: - flash(f"Błąd przy obracaniu: {str(e)}", "danger") - - return redirect(request.referrer or url_for("admin_receipts", id="all")) - - -@app.route("/admin/delete_receipt/") -@app.route("/admin/delete_receipt/orphan/") -@login_required -@admin_required -def delete_receipt(receipt_id=None, filename=None): - if filename: # tryb orphan - safe_filename = os.path.basename(filename) - if Receipt.query.filter_by(filename=safe_filename).first(): - flash("Nie można usunąć pliku powiązanego z bazą!", "danger") - else: - file_path = os.path.join(app.config["UPLOAD_FOLDER"], safe_filename) - if os.path.exists(file_path): - try: - os.remove(file_path) - flash(f"Usunięto plik: {safe_filename}", "success") - except Exception as e: - flash(f"Błąd przy usuwaniu pliku: {e}", "danger") - else: - flash("Plik już nie istnieje.", "warning") - return redirect(url_for("admin_receipts", id="all")) - - try: - delete_receipt_by_id(receipt_id) - flash("Paragon usunięty", "success") - except Exception as e: - flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger") - - return redirect(request.referrer or url_for("admin_receipts", id="all")) - - -@app.route("/admin/rename_receipt/") -@login_required -@admin_required -def rename_receipt(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - - if not os.path.exists(old_path): - flash("Plik nie istnieje", "danger") - return redirect(request.referrer) - - new_filename = generate_new_receipt_filename(receipt.list_id) - new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) - - try: - os.rename(old_path, new_path) - receipt.filename = new_filename - db.session.flush() - recalculate_filesizes(receipt.id) - db.session.commit() - flash("Zmieniono nazwę pliku", "success") - except Exception as e: - flash(f"Błąd przy zmianie nazwy: {str(e)}", "danger") - - return redirect(request.referrer or url_for("admin_receipts", id="all")) - - -@app.route("/admin/generate_receipt_hash/") -@login_required -@admin_required -def generate_receipt_hash(receipt_id): - receipt = Receipt.query.get_or_404(receipt_id) - if receipt.file_hash: - flash("Hash już istnieje", "info") - return redirect(request.referrer) - - file_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) - if not os.path.exists(file_path): - flash("Plik nie istnieje", "danger") - return redirect(request.referrer) - - try: - with open(file_path, "rb") as f: - file_hash = hashlib.sha256(f.read()).hexdigest() - receipt.file_hash = file_hash - db.session.commit() - flash("Hash wygenerowany", "success") - except Exception as e: - flash(f"Błąd przy generowaniu hasha: {e}", "danger") - - return redirect(request.referrer) - - -@app.route("/admin/delete_list", methods=["POST"]) -@login_required -@admin_required -def admin_delete_list(): - ids = request.form.getlist("list_ids") - single_id = request.form.get("single_list_id") - if single_id: - ids.append(single_id) - - for list_id in ids: - lst = db.session.get(ShoppingList, int(list_id)) - if lst: - delete_receipts_for_list(lst.id) - Receipt.query.filter_by(list_id=lst.id).delete() - Item.query.filter_by(list_id=lst.id).delete() - Expense.query.filter_by(list_id=lst.id).delete() - db.session.delete(lst) - - db.session.commit() - flash(f"Usunięto {len(ids)} list(e/y)", "success") - return redirect(request.referrer or url_for("admin_panel")) - - -@app.route("/admin/edit_list/", methods=["GET", "POST"]) -@login_required -@admin_required -def edit_list(list_id): - shopping_list = db.session.get( - ShoppingList, - list_id, - options=[ - joinedload(ShoppingList.expenses), - joinedload(ShoppingList.receipts), - joinedload(ShoppingList.owner), - joinedload(ShoppingList.items), - joinedload(ShoppingList.categories), - ], - ) - permitted_users = ( - db.session.query(User) - .join(ListPermission, ListPermission.user_id == User.id) - .filter(ListPermission.list_id == shopping_list.id) - .order_by(User.username.asc()) - .all() - ) - - if shopping_list is None: - abort(404) - - total_expense = get_total_expense_for_list(shopping_list.id) - categories = Category.query.order_by(Category.name.asc()).all() - selected_categories_ids = {c.id for c in shopping_list.categories} - - if request.method == "POST": - action = request.form.get("action") - - if action == "save": - new_title = request.form.get("title", "").strip() - new_amount_str = request.form.get("amount") - is_archived = "archived" in request.form - is_public = "public" in request.form - is_temporary = "temporary" in request.form - new_owner_id = request.form.get("owner_id") - expires_date = request.form.get("expires_date") - expires_time = request.form.get("expires_time") - - if new_title: - shopping_list.title = new_title - - shopping_list.is_archived = is_archived - shopping_list.is_public = is_public - shopping_list.is_temporary = is_temporary - - if expires_date and expires_time: - try: - combined = f"{expires_date} {expires_time}" - dt = datetime.strptime(combined, "%Y-%m-%d %H:%M") - shopping_list.expires_at = dt.replace(tzinfo=timezone.utc) - except ValueError: - flash("Niepoprawna data lub godzina wygasania", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - else: - shopping_list.expires_at = None - - if new_owner_id: - try: - new_owner_id_int = int(new_owner_id) - user_obj = db.session.get(User, new_owner_id_int) - if user_obj: - shopping_list.owner_id = new_owner_id_int - Item.query.filter_by(list_id=list_id).update( - {"added_by": new_owner_id_int} - ) - Receipt.query.filter_by(list_id=list_id).update( - {"uploaded_by": new_owner_id_int} - ) - else: - flash("Wybrany użytkownik nie istnieje", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - except ValueError: - flash("Niepoprawny ID użytkownika", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - - if new_amount_str: - try: - new_amount = float(new_amount_str) - for expense in shopping_list.expenses: - db.session.delete(expense) - db.session.commit() - db.session.add(Expense(list_id=list_id, amount=new_amount)) - except ValueError: - flash("Niepoprawna kwota", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - - created_month = request.form.get("created_month") - if created_month: - try: - year, month = map(int, created_month.split("-")) - shopping_list.created_at = datetime( - year, month, 1, tzinfo=timezone.utc - ) - except ValueError: - flash("Nieprawidłowy format miesiąca", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - - update_list_categories_from_form(shopping_list, request.form) - db.session.commit() - flash("Zapisano zmiany listy", "success") - return redirect(url_for("edit_list", list_id=list_id)) - - elif action == "add_item": - item_name = request.form.get("item_name", "").strip() - quantity_str = request.form.get("quantity", "1") - - if not item_name: - flash("Podaj nazwę produktu", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - - try: - quantity = max(1, int(quantity_str)) - except ValueError: - quantity = 1 - - db.session.add( - Item( - list_id=list_id, - name=item_name, - quantity=quantity, - added_by=current_user.id, - ) - ) - - exists = ( - db.session.query(SuggestedProduct) - .filter(func.lower(SuggestedProduct.name) == item_name.lower()) - .first() - ) - if not exists: - db.session.add(SuggestedProduct(name=item_name)) - - db.session.commit() - flash("Dodano produkt", "success") - return redirect(url_for("edit_list", list_id=list_id)) - - elif action == "delete_item": - item = get_valid_item_or_404(request.form.get("item_id"), list_id) - db.session.delete(item) - db.session.commit() - flash("Usunięto produkt", "success") - return redirect(url_for("edit_list", list_id=list_id)) - - elif action == "toggle_purchased": - item = get_valid_item_or_404(request.form.get("item_id"), list_id) - item.purchased = not item.purchased - db.session.commit() - flash("Zmieniono status oznaczenia produktu", "success") - return redirect(url_for("edit_list", list_id=list_id)) - - elif action == "mark_not_purchased": - item = get_valid_item_or_404(request.form.get("item_id"), list_id) - item.not_purchased = True - item.purchased = False - item.purchased_at = None - db.session.commit() - flash("Oznaczono produkt jako niekupione", "success") - return redirect(url_for("edit_list", list_id=list_id)) - - elif action == "unmark_not_purchased": - item = get_valid_item_or_404(request.form.get("item_id"), list_id) - item.not_purchased = False - item.not_purchased_reason = None - item.purchased = False - item.purchased_at = None - db.session.commit() - flash("Przywrócono produkt do listy", "success") - return redirect(url_for("edit_list", list_id=list_id)) - - elif action == "edit_quantity": - item = get_valid_item_or_404(request.form.get("item_id"), list_id) - try: - new_quantity = int(request.form.get("quantity")) - if new_quantity > 0: - item.quantity = new_quantity - db.session.commit() - flash("Zmieniono ilość produktu", "success") - except ValueError: - flash("Nieprawidłowa ilość", "danger") - return redirect(url_for("edit_list", list_id=list_id)) - - users = User.query.all() - items = shopping_list.items - receipts = shopping_list.receipts - - return render_template( - "admin/edit_list.html", - list=shopping_list, - total_expense=total_expense, - users=users, - items=items, - receipts=receipts, - categories=categories, - selected_categories=selected_categories_ids, - permitted_users=permitted_users, - ) - - -@app.route("/admin/products") -@login_required -@admin_required -def list_products(): - page, per_page = get_page_args() - - all_items = ( - Item.query.options(joinedload(Item.added_by_user)) - .order_by(Item.id.desc()) - .all() - ) - - seen_names = set() - unique_items = [] - for item in all_items: - key = normalize_name(item.name) - if key not in seen_names: - unique_items.append(item) - seen_names.add(key) - - usage_results = ( - db.session.query( - func.lower(Item.name).label("name"), - func.count(func.distinct(Item.list_id)).label("usage_count"), - ) - .group_by(func.lower(Item.name)) - .all() - ) - usage_counts = {row.name: row.usage_count for row in usage_results} - - items, total_items, total_pages = paginate_items(unique_items, page, per_page) - - user_ids = {item.added_by for item in items if item.added_by} - users = User.query.filter(User.id.in_(user_ids)).all() if user_ids else [] - users_dict = {u.id: u.username for u in users} - - suggestions = SuggestedProduct.query.all() - all_suggestions_dict = { - normalize_name(s.name): s for s in suggestions if s.name and s.name.strip() - } - - used_suggestion_names = {normalize_name(i.name) for i in unique_items} - - suggestions_dict = { - name: all_suggestions_dict[name] - for name in used_suggestion_names - if name in all_suggestions_dict - } - - orphan_suggestions = [ - s - for name, s in all_suggestions_dict.items() - if name not in used_suggestion_names - ] - - query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) - synced_names = set(suggestions_dict.keys()) - - return render_template( - "admin/list_products.html", - items=items, - users_dict=users_dict, - suggestions_dict=suggestions_dict, - orphan_suggestions=orphan_suggestions, - page=page, - per_page=per_page, - total_pages=total_pages, - query_string=query_string, - total_items=total_items, - usage_counts=usage_counts, - synced_names=synced_names, - ) - - -@app.route("/admin/sync_suggestion/", methods=["POST"]) -@login_required -def sync_suggestion_ajax(item_id): - if not current_user.is_admin: - return jsonify({"success": False, "message": "Brak uprawnień"}), 403 - - item = Item.query.get_or_404(item_id) - - existing = SuggestedProduct.query.filter( - func.lower(SuggestedProduct.name) == item.name.lower() - ).first() - if not existing: - new_suggestion = SuggestedProduct(name=item.name) - db.session.add(new_suggestion) - db.session.commit() - return jsonify( - { - "success": True, - "message": f"Utworzono sugestię dla produktu: {item.name}", - } - ) - else: - return jsonify( - { - "success": True, - "message": f"Sugestia dla produktu „{item.name}” już istnieje.", - } - ) - - -@app.route("/admin/delete_suggestion/", methods=["POST"]) -@login_required -def delete_suggestion_ajax(suggestion_id): - if not current_user.is_admin: - return jsonify({"success": False, "message": "Brak uprawnień"}), 403 - - suggestion = SuggestedProduct.query.get_or_404(suggestion_id) - db.session.delete(suggestion) - db.session.commit() - - return jsonify({"success": True, "message": "Sugestia została usunięta."}) - - -@app.route("/admin/promote_user/") -@login_required -@admin_required -def promote_user(user_id): - user = User.query.get_or_404(user_id) - user.is_admin = True - db.session.commit() - flash(f"Użytkownik {user.username} został ustawiony jako admin.", "success") - return redirect(url_for("list_users")) - - -@app.route("/admin/demote_user/") -@login_required -@admin_required -def demote_user(user_id): - user = User.query.get_or_404(user_id) - - if user.id == current_user.id: - flash("Nie możesz zdegradować samego siebie!", "danger") - return redirect(url_for("list_users")) - - admin_count = User.query.filter_by(is_admin=True).count() - if admin_count <= 1 and user.is_admin: - flash( - "Nie można zdegradować. Musi pozostać co najmniej jeden administrator.", - "danger", - ) - return redirect(url_for("list_users")) - - user.is_admin = False - db.session.commit() - flash(f"Użytkownik {user.username} został zdegradowany.", "success") - return redirect(url_for("list_users")) - - -@app.route("/admin/crop_receipt", methods=["POST"]) -@login_required -@admin_required -def crop_receipt_admin(): - receipt_id = request.form.get("receipt_id") - file = request.files.get("cropped_image") - result = handle_crop_receipt(receipt_id, file) - return jsonify(result) - - -@app.route("/admin/recalculate_filesizes") -@login_required -@admin_required -def recalculate_filesizes_all(): - updated, unchanged, not_found = recalculate_filesizes() - flash( - f"Zaktualizowano: {updated}, bez zmian: {unchanged}, brak pliku: {not_found}", - "success", - ) - return redirect(url_for("admin_receipts", id="all")) - - -@app.route("/admin/edit_categories", methods=["GET", "POST"]) -@login_required -@admin_required -def admin_edit_categories(): - page, per_page = get_page_args(default_per_page=50, max_per_page=200) - - lists_query = ShoppingList.query.options( - joinedload(ShoppingList.categories), - joinedload(ShoppingList.items), - joinedload(ShoppingList.owner), - ).order_by(ShoppingList.created_at.desc()) - - pagination = lists_query.paginate(page=page, per_page=per_page, error_out=False) - lists = pagination.items - - categories = Category.query.order_by(Category.name.asc()).all() - - for l in lists: - l.total_count = len(l.items) - l.owner_name = l.owner.username if l.owner else "?" - l.category_count = len(l.categories) - - if request.method == "POST": - for l in lists: - selected_ids = request.form.getlist(f"categories_{l.id}") - l.categories.clear() - if selected_ids: - cats = Category.query.filter(Category.id.in_(selected_ids)).all() - l.categories.extend(cats) - db.session.commit() - flash("Zaktualizowano kategorie dla wybranych list", "success") - return redirect(url_for("admin_edit_categories", page=page, per_page=per_page)) - - query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) - - return render_template( - "admin/edit_categories.html", - lists=lists, - categories=categories, - page=page, - per_page=per_page, - total_pages=pagination.pages, - total_items=pagination.total, - query_string=query_string, - ) - - -@app.route("/admin/edit_categories//save", methods=["POST"]) -@login_required -@admin_required -def admin_edit_categories_save(list_id): - l = db.session.get(ShoppingList, list_id) - if not l: - return jsonify(ok=False, error="not_found"), 404 - - data = request.get_json(silent=True) or {} - ids = data.get("category_ids", []) - - try: - ids = [int(x) for x in ids] - except (TypeError, ValueError): - return jsonify(ok=False, error="bad_ids"), 400 - - l.categories.clear() - if ids: - cats = Category.query.filter(Category.id.in_(ids)).all() - l.categories.extend(cats) - - db.session.commit() - return jsonify(ok=True, count=len(l.categories)), 200 - - -@app.route("/admin/list_items/") -@login_required -@admin_required -def admin_list_items_json(list_id): - l = db.session.get(ShoppingList, list_id) - if not l: - return jsonify({"error": "Lista nie istnieje"}), 404 - - items = [ - { - "name": item.name, - "quantity": item.quantity, - "purchased": item.purchased, - "not_purchased": item.not_purchased, - } - for item in l.items - ] - - purchased_count = sum(1 for item in l.items if item.purchased) - total_expense = sum(exp.amount for exp in l.expenses) - - return jsonify( - { - "title": l.title, - "items": items, - "total_count": len(l.items), - "purchased_count": purchased_count, - "total_expense": round(total_expense, 2), - } - ) - - -@app.route("/admin/add_suggestion", methods=["POST"]) -@login_required -@admin_required -def add_suggestion(): - name = request.form.get("suggestion_name", "").strip() - - if not name: - flash("Nazwa nie może być pusta", "warning") - return redirect(url_for("list_products")) - - existing = db.session.query(SuggestedProduct).filter_by(name=name).first() - if existing: - flash("Sugestia już istnieje", "warning") - else: - new_suggestion = SuggestedProduct(name=name) - db.session.add(new_suggestion) - db.session.commit() - flash("Dodano sugestię", "success") - - return redirect(url_for("list_products")) - - -@app.route("/admin/lists-access", methods=["GET", "POST"]) -@app.route("/admin/lists-access/", methods=["GET", "POST"]) -@login_required -@admin_required -def admin_lists_access(list_id=None): - try: - page = int(request.args.get("page", 1)) - except ValueError: - page = 1 - try: - per_page = int(request.args.get("per_page", 25)) - except ValueError: - per_page = 25 - per_page = max(1, min(100, per_page)) - - q = ShoppingList.query.options(db.joinedload(ShoppingList.owner)).order_by( - ShoppingList.created_at.desc() - ) - - if list_id is not None: - target_list = db.session.get(ShoppingList, list_id) - if not target_list: - flash("Lista nie istnieje.", "danger") - return redirect(url_for("admin_lists_access")) - lists = [target_list] - list_ids = [list_id] - pagination = None - else: - pagination = q.paginate(page=page, per_page=per_page, error_out=False) - lists = pagination.items - list_ids = [l.id for l in lists] - - if request.method == "POST": - action = request.form.get("action") - target_list_id = request.form.get("target_list_id", type=int) - - if action == "grant" and target_list_id: - login = (request.form.get("grant_username") or "").strip().lower() - l = db.session.get(ShoppingList, target_list_id) - if not l: - flash("Lista nie istnieje.", "danger") - return redirect(request.url) - u = User.query.filter(func.lower(User.username) == login).first() - if not u: - flash("Użytkownik nie istnieje.", "danger") - return redirect(request.url) - if u.id == l.owner_id: - flash("Nie można nadawać uprawnień właścicielowi listy.", "danger") - return redirect(request.url) - - exists = ( - db.session.query(ListPermission.id) - .filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id) - .first() - ) - if not exists: - db.session.add(ListPermission(list_id=l.id, user_id=u.id)) - db.session.commit() - flash(f"Nadano dostęp „{u.username}” do listy #{l.id}.", "success") - else: - flash("Ten użytkownik już ma dostęp.", "info") - return redirect(request.url) - - if action == "revoke" and target_list_id: - uid = request.form.get("revoke_user_id", type=int) - if uid: - ListPermission.query.filter_by( - list_id=target_list_id, user_id=uid - ).delete() - db.session.commit() - flash("Odebrano dostęp użytkownikowi.", "success") - return redirect(request.url) - - if action == "save_changes": - ids = request.form.getlist("visible_ids", type=int) - if ids: - lists_edit = ShoppingList.query.filter(ShoppingList.id.in_(ids)).all() - posted = request.form - for l in lists_edit: - l.is_public = posted.get(f"is_public_{l.id}") is not None - l.is_temporary = posted.get(f"is_temporary_{l.id}") is not None - l.is_archived = posted.get(f"is_archived_{l.id}") is not None - db.session.commit() - flash("Zapisano zmiany statusów.", "success") - return redirect(request.url) - - perms = ( - db.session.query( - ListPermission.list_id, - User.id.label("uid"), - User.username.label("uname"), - ) - .join(User, User.id == ListPermission.user_id) - .filter(ListPermission.list_id.in_(list_ids)) - .order_by(User.username.asc()) - .all() - ) - - permitted_by_list = {lid: [] for lid in list_ids} - for lid, uid, uname in perms: - permitted_by_list[lid].append({"id": uid, "username": uname}) - - query_string = f"per_page={per_page}" - - return render_template( - "admin/lists_access.html", - lists=lists, - permitted_by_list=permitted_by_list, - page=page, - per_page=per_page, - total_pages=pagination.pages if pagination else 1, - query_string=query_string, - list_id=list_id, - ) - - -@app.route('/healthcheck') -def healthcheck(): - header_token = request.headers.get('X-Internal-Check') - correct_token = app.config.get('HEALTHCHECK_TOKEN') - if header_token != correct_token: - abort(404) - - try: - db.session.execute(text('SELECT 1')) - db.session.commit() - response_data = {"status": "ok"} - except Exception as e: - response_data = { - "status": "waiting", - "message": "waiting for db", - "error": str(e) - } - - return response_data, 200 - - -@app.route("/admin/settings", methods=["GET", "POST"]) -@login_required -@admin_required -def admin_settings(): - categories = Category.query.order_by(Category.name.asc()).all() - - if request.method == "POST": - ocr_raw = (request.form.get("ocr_keywords") or "").strip() - set_setting("ocr_keywords", ocr_raw) - - ocr_sens = (request.form.get("ocr_sensitivity") or "").strip() - set_setting("ocr_sensitivity", ocr_sens) - - max_attempts = (request.form.get("max_login_attempts") or "").strip() - set_setting("max_login_attempts", max_attempts) - - login_window = (request.form.get("login_window_seconds") or "").strip() - if login_window: - set_setting("login_window_seconds", login_window) - - for c in categories: - field = f"color_{c.id}" - vals = request.form.getlist(field) - val = (vals[-1] if vals else "").strip() - - existing = CategoryColorOverride.query.filter_by(category_id=c.id).first() - if val and re.fullmatch(r"^#[0-9A-Fa-f]{6}$", val): - if not existing: - db.session.add(CategoryColorOverride(category_id=c.id, color_hex=val)) - else: - existing.color_hex = val - else: - if existing: - db.session.delete(existing) - - db.session.commit() - flash("Zapisano ustawienia.", "success") - return redirect(url_for("admin_settings")) - - override_rows = CategoryColorOverride.query.filter( - CategoryColorOverride.category_id.in_([c.id for c in categories]) - ).all() - overrides = {row.category_id: row.color_hex for row in override_rows} - auto_colors = {c.id: category_to_color(c.name) for c in categories} - effective_colors = { - c.id: (overrides.get(c.id) or auto_colors[c.id]) for c in categories - } - - current_ocr = get_setting("ocr_keywords", "") - - ocr_sensitivity = get_int_setting("ocr_sensitivity", 5) - max_login_attempts = get_int_setting("max_login_attempts", 10) - login_window_seconds = get_int_setting("login_window_seconds", 3600) - - return render_template( - "admin/settings.html", - categories=categories, - overrides=overrides, - auto_colors=auto_colors, - effective_colors=effective_colors, - current_ocr=current_ocr, - ocr_sensitivity=ocr_sensitivity, - max_login_attempts=max_login_attempts, - login_window_seconds=login_window_seconds, - ) - - -@app.route("/robots.txt") -def robots_txt(): - content = ( - "User-agent: *\nDisallow: /" - if app.config.get("DISABLE_ROBOTS") - else "User-agent: *\nAllow: /" - ) - return content, 200, {"Content-Type": "text/plain"} - - -from flask import render_template_string - -@app.route('/admin/debug-socket') -@login_required -@admin_required -def debug_socket(): - return render_template_string(''' - - - - Socket Debug - - - - -

Socket.IO Debug Tool

- -
Rozlaczony
-
- Transport: - | - Ping: -ms | - SID: - -
- - - - - - -

Logi:

-
- - - - - ''') - - - -# ========================================================================================= -# SOCKET.IO -# ========================================================================================= - - -@socketio.on("delete_item") -def handle_delete_item(data): - # item = Item.query.get(data["item_id"]) - item = db.session.get(Item, data["item_id"]) - - if item: - list_id = item.list_id - db.session.delete(item) - db.session.commit() - emit("item_deleted", {"item_id": item.id}, to=str(item.list_id)) - - purchased_count, total_count, percent = get_progress(list_id) - - emit( - "progress_updated", - { - "purchased_count": purchased_count, - "total_count": total_count, - "percent": percent, - }, - to=str(list_id), - ) - - -@socketio.on("edit_item") -def handle_edit_item(data): - item = db.session.get(Item, data["item_id"]) - - new_name = data["new_name"] - new_quantity = data.get("new_quantity", item.quantity) - - if item and new_name.strip(): - item.name = new_name.strip() - - try: - new_quantity = int(new_quantity) - if new_quantity < 1: - new_quantity = 1 - except: - new_quantity = 1 - - item.quantity = new_quantity - - db.session.commit() - - emit( - "item_edited", - {"item_id": item.id, "new_name": item.name, "new_quantity": item.quantity}, - to=str(item.list_id), - ) - - -@socketio.on("join_list") -def handle_join(data): - global active_users - room = str(data["room"]) - username = data.get("username", "Gość") - join_room(room) - - if room not in active_users: - active_users[room] = set() - active_users[room].add(username) - - shopping_list = db.session.get(ShoppingList, int(data["room"])) - - list_title = shopping_list.title if shopping_list else "Twoja lista" - - emit("user_joined", {"username": username}, to=room) - emit("user_list", {"users": list(active_users[room])}, to=room) - emit("joined_confirmation", {"room": room, "list_title": list_title}) - - -@socketio.on("disconnect") -def handle_disconnect(sid): - global active_users - username = current_user.username if current_user.is_authenticated else "Gość" - for room, users in active_users.items(): - if username in users: - users.remove(username) - emit("user_left", {"username": username}, to=room) - emit("user_list", {"users": list(users)}, to=room) - - -@socketio.on("add_item") -def handle_add_item(data): - list_id = data["list_id"] - name = data["name"].strip() - quantity = data.get("quantity", 1) - - list_obj = db.session.get(ShoppingList, list_id) - if not list_obj: - return - - try: - quantity = int(quantity) - if quantity < 1: - quantity = 1 - except: - quantity = 1 - - existing_item = Item.query.filter( - Item.list_id == list_id, - func.lower(Item.name) == name.lower(), - Item.not_purchased == False, - ).first() - - if existing_item: - existing_item.quantity += quantity - db.session.commit() - - emit( - "item_edited", - { - "item_id": existing_item.id, - "new_name": existing_item.name, - "new_quantity": existing_item.quantity, - }, - to=str(list_id), - ) - else: - max_position = ( - db.session.query(func.max(Item.position)) - .filter_by(list_id=list_id) - .scalar() - ) - if max_position is None: - max_position = 0 - - user_id = current_user.id if current_user.is_authenticated else None - user_name = current_user.username if current_user.is_authenticated else "Gość" - - new_item = Item( - list_id=list_id, - name=name, - quantity=quantity, - position=max_position + 1, - added_by=user_id, - ) - db.session.add(new_item) - - if not SuggestedProduct.query.filter( - func.lower(SuggestedProduct.name) == name.lower() - ).first(): - new_suggestion = SuggestedProduct(name=name) - db.session.add(new_suggestion) - - db.session.commit() - - emit( - "item_added", - { - "id": new_item.id, - "name": new_item.name, - "quantity": new_item.quantity, - "added_by": user_name, - "added_by_id": user_id, - "owner_id": list_obj.owner_id, - }, - to=str(list_id), - include_self=True, - ) - - purchased_count, total_count, percent = get_progress(list_id) - - emit( - "progress_updated", - { - "purchased_count": purchased_count, - "total_count": total_count, - "percent": percent, - }, - to=str(list_id), - ) - - -@socketio.on("check_item") -def handle_check_item(data): - item = db.session.get(Item, data["item_id"]) - - if item: - item.purchased = True - item.purchased_at = datetime.now(UTC) - - db.session.commit() - - purchased_count, total_count, percent = get_progress(item.list_id) - - emit("item_checked", {"item_id": item.id}, to=str(item.list_id)) - emit( - "progress_updated", - { - "purchased_count": purchased_count, - "total_count": total_count, - "percent": percent, - }, - to=str(item.list_id), - ) - - -@socketio.on("uncheck_item") -def handle_uncheck_item(data): - item = db.session.get(Item, data["item_id"]) - - if item: - item.purchased = False - item.purchased_at = None - db.session.commit() - - purchased_count, total_count, percent = get_progress(item.list_id) - - emit("item_unchecked", {"item_id": item.id}, to=str(item.list_id)) - emit( - "progress_updated", - { - "purchased_count": purchased_count, - "total_count": total_count, - "percent": percent, - }, - to=str(item.list_id), - ) - - -@socketio.on("request_full_list") -def handle_request_full_list(data): - list_id = data["list_id"] - - shopping_list = db.session.get(ShoppingList, list_id) - if not shopping_list: - return - - owner_id = shopping_list.owner_id - - items = ( - Item.query.options(joinedload(Item.added_by_user)) - .filter_by(list_id=list_id) - .order_by(Item.position.asc()) - .all() - ) - - items_data = [] - for item in items: - items_data.append( - { - "id": item.id, - "name": item.name, - "quantity": item.quantity, - "purchased": item.purchased if not item.not_purchased else False, - "not_purchased": item.not_purchased, - "not_purchased_reason": item.not_purchased_reason, - "note": item.note or "", - "added_by": item.added_by_user.username if item.added_by_user else None, - "added_by_id": item.added_by_user.id if item.added_by_user else None, - "owner_id": owner_id, - } - ) - - emit("full_list", {"items": items_data}, to=request.sid) - - -@socketio.on("update_note") -def handle_update_note(data): - item_id = data["item_id"] - note = data["note"] - item = Item.query.get(item_id) - if item: - item.note = note - db.session.commit() - emit("note_updated", {"item_id": item_id, "note": note}, to=str(item.list_id)) - - -@socketio.on("add_expense") -def handle_add_expense(data): - list_id = data["list_id"] - amount = data["amount"] - receipt_filename = data.get("receipt_filename") - - if receipt_filename: - existing = Expense.query.filter_by( - list_id=list_id, receipt_filename=receipt_filename - ).first() - if existing: - return - new_expense = Expense( - list_id=list_id, amount=amount, receipt_filename=receipt_filename - ) - - db.session.add(new_expense) - db.session.commit() - - total = ( - db.session.query(func.sum(Expense.amount)).filter_by(list_id=list_id).scalar() - or 0 - ) - - emit("expense_added", {"amount": amount, "total": total}, to=str(list_id)) - - -@socketio.on("mark_not_purchased") -def handle_mark_not_purchased(data): - item = db.session.get(Item, data["item_id"]) - - reason = data.get("reason", "") - if item: - item.not_purchased = True - item.not_purchased_reason = reason - db.session.commit() - emit( - "item_marked_not_purchased", - {"item_id": item.id, "reason": reason}, - to=str(item.list_id), - ) - - -@socketio.on("unmark_not_purchased") -def handle_unmark_not_purchased(data): - item = db.session.get(Item, data["item_id"]) - - if item: - item.not_purchased = False - item.purchased = False - item.purchased_at = None - item.not_purchased_reason = None - db.session.commit() - emit("item_unmarked_not_purchased", {"item_id": item.id}, to=str(item.list_id)) - - -@app.cli.command("db_info") -def create_db(): - with app.app_context(): - inspector = inspect(db.engine) - actual_tables = inspector.get_table_names() - - table_count = len(actual_tables) - record_total = 0 - with db.engine.connect() as conn: - for table in actual_tables: - try: - count = conn.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar() - record_total += count - except Exception: - pass - - print("\nStruktura bazy danych jest poprawna.") - print(f"Silnik: {db.engine.name}") - print(f"Liczba tabel: {table_count}") - print(f"Łączna liczba rekordów: {record_total}") +from shopping_app import app, socketio, APP_PORT, DEBUG_MODE +from shopping_app.app_setup import logging +from shopping_app.startup_info import print_startup_info if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO) + + print_startup_info(app) + socketio.run(app, host="0.0.0.0", port=APP_PORT, debug=False) diff --git a/deploy_docker.sh b/deploy_docker.sh index 2e53b07..fc45d18 100644 --- a/deploy_docker.sh +++ b/deploy_docker.sh @@ -1,7 +1,6 @@ #!/bin/bash set -e -# --- Wczytaj zmienne z .env --- if [[ -f .env ]]; then set -a source .env @@ -9,8 +8,8 @@ if [[ -f .env ]]; then fi APP_PORT="${APP_PORT:-8080}" - PROFILE=$1 +COMPOSE_FILE="docker/compose.yml" if [[ -z "$PROFILE" ]]; then echo "Użycie: $0 {pgsql|mysql|sqlite}" @@ -19,9 +18,9 @@ fi echo "Zatrzymuję kontenery aplikacji i bazy..." if [[ "$PROFILE" == "sqlite" ]]; then - docker compose stop + docker compose -f "$COMPOSE_FILE" stop else - docker compose --profile "$PROFILE" stop + docker compose -f "$COMPOSE_FILE" --profile "$PROFILE" stop fi echo "Pobieram najnowszy kod z repozytorium..." @@ -35,9 +34,9 @@ git rev-parse --short HEAD > version.txt echo "Buduję i uruchamiam kontenery..." if [[ "$PROFILE" == "sqlite" ]]; then - docker compose up -d --build + docker compose -f "$COMPOSE_FILE" up -d --build else - DB_ENGINE="$PROFILE" docker compose --profile "$PROFILE" up -d --build + DB_ENGINE="$PROFILE" docker compose -f "$COMPOSE_FILE" --profile "$PROFILE" up -d --build fi -echo "Gotowe! Wersja aplikacji: $(cat version.txt)" +echo "Gotowe! Wersja aplikacji: $(cat version.txt)" \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..6b1e153 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.14-trixie +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr \ + tesseract-ocr-pol \ + libglib2.0-0 \ + libsm6 \ + libxrender1 \ + libxext6 \ + poppler-utils \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/requirements.txt /app/requirements.txt + +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + + +#EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/Dockerfile.debian-slim b/docker/Dockerfile.debian-slim new file mode 100644 index 0000000..79ab666 --- /dev/null +++ b/docker/Dockerfile.debian-slim @@ -0,0 +1,28 @@ +FROM python:3.14-slim-trixie +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr \ + tesseract-ocr-pol \ + libglib2.0-0 \ + libsm6 \ + libxrender1 \ + libxext6 \ + poppler-utils \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/requirements.txt /app/requirements.txt + +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app + + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +#EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/Dockerfile.debian-stable-slim b/docker/Dockerfile.debian-stable-slim new file mode 100644 index 0000000..742a544 --- /dev/null +++ b/docker/Dockerfile.debian-stable-slim @@ -0,0 +1,27 @@ +FROM python:3.14-slim-trixie +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr \ + tesseract-ocr-pol \ + libglib2.0-0 \ + libsm6 \ + libxrender1 \ + libxext6 \ + poppler-utils \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY docker/requirements-stable.txt /app/requirements.txt + +RUN pip install --upgrade pip +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +#EXPOSE 8000 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker-compose.yml b/docker/compose.yml similarity index 82% rename from docker-compose.yml rename to docker/compose.yml index 12fa9ca..8fb1486 100644 --- a/docker-compose.yml +++ b/docker/compose.yml @@ -1,14 +1,11 @@ services: app: - build: . + build: + context: .. + dockerfile: docker/Dockerfile.debian-stable-slim container_name: lista-zakupow-app expose: - "${APP_PORT:-8000}" - - # temporary - #ports: - # - "9281:${APP_PORT:-8000}" - healthcheck: test: [ @@ -22,11 +19,11 @@ services: retries: 3 start_period: 10s env_file: - - .env + - ../.env volumes: - - .:/app - - ./uploads:/app/uploads - - ./instance:/app/instance + - ../:/app + - ../uploads:/app/uploads + - ../instance:/app/instance networks: - lista-zakupow_network restart: unless-stopped @@ -40,7 +37,7 @@ services: ports: - "${APP_PORT:-8000}:80" volumes: - - ./deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro + - ../deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro environment: - VARNISH_SIZE=256m networks: @@ -56,7 +53,7 @@ services: MYSQL_PASSWORD: ${DB_PASSWORD} MYSQL_ROOT_PASSWORD: 89o38kUX5T4C volumes: - - ./db/mysql:/var/lib/mysql + - ../db/mysql:/var/lib/mysql restart: unless-stopped networks: - lista-zakupow_network @@ -71,7 +68,7 @@ services: POSTGRES_PASSWORD: ${DB_PASSWORD} PGDATA: /var/lib/postgresql volumes: - - ./db/pgsql/:/var/lib/postgresql + - ../db/pgsql:/var/lib/postgresql networks: - lista-zakupow_network restart: unless-stopped @@ -79,4 +76,4 @@ services: networks: lista-zakupow_network: - driver: bridge + driver: bridge \ No newline at end of file diff --git a/docker/requirements-stable.txt b/docker/requirements-stable.txt new file mode 100644 index 0000000..ed22c5c --- /dev/null +++ b/docker/requirements-stable.txt @@ -0,0 +1,21 @@ +bcrypt==5.0.0 +cryptography==46.0.5 +Flask==3.1.3 +Flask-Compress==1.23 +Flask-Login==0.6.3 +Flask-Session==0.8.0 +Flask-SocketIO==5.6.1 +Flask-SQLAlchemy==3.1.1 +flask-talisman==1.1.0 +gevent==25.9.1 +gevent-websocket==0.10.1 +opencv-python-headless>=4.12.0.88 +pdf2image==1.17.0 +pillow==12.1.1 +pillow_heif==1.3.0 +psutil==7.2.2 +psycopg2-binary==2.9.11 +PyMySQL==1.1.2 +pytesseract==0.3.13 +SQLAlchemy==2.0.48 +Werkzeug==3.1.6 diff --git a/requirements.txt b/docker/requirements.txt similarity index 100% rename from requirements.txt rename to docker/requirements.txt diff --git a/shopping_app/__init__.py b/shopping_app/__init__.py new file mode 100644 index 0000000..c34af99 --- /dev/null +++ b/shopping_app/__init__.py @@ -0,0 +1,11 @@ +from .app_setup import app, db, socketio, login_manager, APP_PORT, DEBUG_MODE, static_bp +from . import models # noqa: F401 +from . import helpers # noqa: F401 +app.register_blueprint(static_bp) +from . import web # noqa: F401 +from . import routes_main # noqa: F401 +from . import routes_secondary # noqa: F401 +from . import routes_admin # noqa: F401 +from . import sockets # noqa: F401 + +__all__ = ["app", "db", "socketio", "login_manager", "APP_PORT", "DEBUG_MODE"] diff --git a/shopping_app/app_setup.py b/shopping_app/app_setup.py new file mode 100644 index 0000000..a267e78 --- /dev/null +++ b/shopping_app/app_setup.py @@ -0,0 +1,127 @@ +from .deps import * + +app = Flask(__name__) +app.config.from_object(Config) + +csp_policy = ( + { + "default-src": "'self'", + "script-src": "'self' 'unsafe-inline'", + "style-src": "'self' 'unsafe-inline'", + "img-src": "'self' data:", + "connect-src": "'self'", + } + if app.config.get("ENABLE_CSP", True) + else None +) + +permissions_policy = {"browsing-topics": "()"} if app.config.get("ENABLE_PP") else None + +talisman_kwargs = { + "force_https": False, + "strict_transport_security": app.config.get("ENABLE_HSTS", True), + "frame_options": "DENY" if app.config.get("ENABLE_XFO", True) else None, + "permissions_policy": permissions_policy, + "content_security_policy": csp_policy, + "x_content_type_options": app.config.get("ENABLE_XCTO", True), + "strict_transport_security_include_subdomains": False, +} + +referrer_policy = app.config.get("REFERRER_POLICY") +if referrer_policy: + talisman_kwargs["referrer_policy"] = referrer_policy + +effective_headers = { + k: v + for k, v in talisman_kwargs.items() + if k != "referrer_policy" and v not in (None, False) +} + +if effective_headers: + from flask_talisman import Talisman + + talisman = Talisman( + app, + session_cookie_secure=app.config.get("SESSION_COOKIE_SECURE", True), + **talisman_kwargs, + ) + print("[TALISMAN] Włączony z nagłówkami:", list(effective_headers.keys())) +else: + print("[TALISMAN] Pominięty — wszystkie nagłówki security wyłączone.") + +register_heif_opener() +SQLALCHEMY_ECHO = True +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "heic", "pdf"} +SYSTEM_PASSWORD = app.config.get("SYSTEM_PASSWORD") +DEFAULT_ADMIN_USERNAME = app.config.get("DEFAULT_ADMIN_USERNAME") +DEFAULT_ADMIN_PASSWORD = app.config.get("DEFAULT_ADMIN_PASSWORD") +UPLOAD_FOLDER = app.config.get("UPLOAD_FOLDER") +AUTHORIZED_COOKIE_VALUE = app.config.get("AUTHORIZED_COOKIE_VALUE") +AUTH_COOKIE_MAX_AGE = app.config.get("AUTH_COOKIE_MAX_AGE") +HEALTHCHECK_TOKEN = app.config.get("HEALTHCHECK_TOKEN") +SESSION_TIMEOUT_MINUTES = int(app.config.get("SESSION_TIMEOUT_MINUTES")) +SESSION_COOKIE_SECURE = app.config.get("SESSION_COOKIE_SECURE") +APP_PORT = int(app.config.get("APP_PORT")) +app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"] +app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES) +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) +DEBUG_MODE = app.config.get("DEBUG_MODE", False) +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +db_uri = app.config.get("SQLALCHEMY_DATABASE_URI", "") +if db_uri.startswith("sqlite:///"): + sqlite_path = db_uri.replace("sqlite:///", "", 1) + sqlite_dir = os.path.dirname(sqlite_path) + if sqlite_dir: + os.makedirs(sqlite_dir, exist_ok=True) +failed_login_attempts = defaultdict(deque) +MAX_ATTEMPTS = 10 +TIME_WINDOW = 60 * 60 +WEBP_SAVE_PARAMS = { + "format": "WEBP", + "lossless": False, + "method": 6, + "quality": 95, +} + +def read_commit(filename="version.txt", root_path=None): + base = root_path or os.path.dirname(os.path.abspath(__file__)) + path = os.path.join(base, filename) + if not os.path.exists(path): + return None + try: + commit = open(path, "r", encoding="utf-8").read().strip() + return commit[:12] if commit else None + except Exception: + return None + + +def get_file_md5(path): + try: + digest = hashlib.md5() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + digest.update(chunk) + return digest.hexdigest()[:12] + except Exception: + return "dev" + + +commit = read_commit("version.txt", root_path=os.path.dirname(os.path.dirname(__file__))) or "dev" +APP_VERSION = commit +app.config["APP_VERSION"] = APP_VERSION +db = SQLAlchemy(app) +socketio = SocketIO(app, async_mode="gevent") +login_manager = LoginManager(app) +login_manager.login_view = "login" +app.config["SESSION_TYPE"] = "sqlalchemy" +app.config["SESSION_SQLALCHEMY"] = db +Session(app) +compress = Compress() +compress.init_app(app) +static_bp = Blueprint("static_bp", __name__) +active_users = {} + +def utcnow(): + return datetime.now(timezone.utc) + +app_start_time = utcnow() diff --git a/shopping_app/deps.py b/shopping_app/deps.py new file mode 100644 index 0000000..c080ca3 --- /dev/null +++ b/shopping_app/deps.py @@ -0,0 +1,39 @@ +import os +import secrets +import time +import mimetypes +import sys +import platform +import psutil +import hashlib +import re +import traceback +import bcrypt +import colorsys +from pillow_heif import register_heif_opener +from datetime import datetime, timedelta, UTC, timezone +from urllib.parse import urlparse, urlunparse, urlencode +from flask import ( + Flask, render_template, redirect, url_for, request, flash, Blueprint, + send_from_directory, abort, session, jsonify, g, render_template_string +) +from flask_sqlalchemy import SQLAlchemy +from flask_login import ( + LoginManager, UserMixin, login_user, login_required, logout_user, current_user +) +from flask_compress import Compress +from flask_socketio import SocketIO, emit, join_room +from config import Config +from PIL import Image, ExifTags, ImageFilter, ImageOps +from werkzeug.middleware.proxy_fix import ProxyFix +from sqlalchemy import func, extract, inspect, or_, case, text, and_, literal +from sqlalchemy.orm import joinedload, load_only, aliased +from collections import defaultdict, deque +from functools import wraps +from flask_session import Session +from types import SimpleNamespace +from pdf2image import convert_from_bytes +from typing import Sequence, Any +import pytesseract +from pytesseract import Output +import logging diff --git a/shopping_app/helpers.py b/shopping_app/helpers.py new file mode 100644 index 0000000..642e69b --- /dev/null +++ b/shopping_app/helpers.py @@ -0,0 +1,1525 @@ +from .deps import * +from .app_setup import * +from .models import * + +def get_setting(key: str, default: str | None = None) -> str | None: + s = db.session.get(AppSetting, key) + return s.value if s else default + + +def set_setting(key: str, value: str | None): + s = db.session.get(AppSetting, key) + if (value or "").strip() == "": + if s: + db.session.delete(s) + else: + if not s: + s = AppSetting(key=key, value=value) + db.session.add(s) + else: + s.value = value + + +def get_ocr_keywords() -> list[str]: + raw = get_setting("ocr_keywords", None) + if raw: + try: + vals = ( + json.loads(raw) + if raw.strip().startswith("[") + else [v.strip() for v in raw.split(",")] + ) + return [v for v in vals if v] + except Exception: + pass + # domyślne – obecne w kodzie OCR + return [ + "razem do zapłaty", + "do zapłaty", + "suma", + "kwota", + "wartość", + "płatność", + "total", + "amount", + ] + + +# 1) nowa funkcja: tylko frazy użytkownika (bez domyślnych) +def get_user_ocr_keywords_only() -> list[str]: + raw = get_setting("ocr_keywords", None) + if not raw: + return [] + try: + if raw.strip().startswith("["): + vals = json.loads(raw) + else: + vals = [v.strip() for v in raw.split(",")] + return [v for v in vals if v] + except Exception: + return [] + + +_BASE_KEYWORDS_BLOCK = r""" + (?: + razem\s*do\s*zap[łl][aąo0]ty | + do\s*zap[łl][aąo0]ty | + suma | + kwota | + warto[śćs] | + płatno[śćs] | + total | + amount + ) +""" + + +def priority_keywords_pattern() -> re.Pattern: + user_terms = get_user_ocr_keywords_only() + if user_terms: + + escaped = [re.escape(t) for t in user_terms] + user_block = " | ".join(escaped) + combined = rf""" + \b( + {_BASE_KEYWORDS_BLOCK} + | {user_block} + )\b + """ + else: + combined = rf"""\b({_BASE_KEYWORDS_BLOCK})\b""" + return re.compile(combined, re.IGNORECASE | re.VERBOSE) + + +def category_color_for(c: Category) -> str: + ov = CategoryColorOverride.query.filter_by(category_id=c.id).first() + return ov.color_hex if ov else category_to_color(c.name) + + +def color_for_category_label(label: str) -> str: + cat = Category.query.filter(func.lower(Category.name) == label.lower()).first() + return category_color_for(cat) if cat else category_to_color(label) + + +def hash_password(password): + pepper = app.config["BCRYPT_PEPPER"] + peppered = (password + pepper).encode("utf-8") + salt = bcrypt.gensalt() + hashed = bcrypt.hashpw(peppered, salt) + return hashed.decode("utf-8") + + +def get_int_setting(key: str, default: int) -> int: + try: + v = get_setting(key, None) + return int(v) if v is not None and str(v).strip() != "" else default + except Exception: + return default + + +def check_password(stored_hash, password_input): + pepper = app.config["BCRYPT_PEPPER"] + peppered = (password_input + pepper).encode("utf-8") + if stored_hash.startswith("$2b$") or stored_hash.startswith("$2a$"): + try: + return bcrypt.checkpw(peppered, stored_hash.encode("utf-8")) + except Exception: + return False + return False + + + + +def resolve_user_identifier(identifier): + if identifier is None: + return None + raw = str(identifier).strip() + if not raw: + return None + if raw.isdigit(): + return db.session.get(User, int(raw)) + return User.query.filter(func.lower(User.username) == raw.lower()).first() + + +def create_or_update_admin_user(username: str, password: str | None = None, make_admin: bool = True, update_password: bool = False): + normalized = (username or '').strip().lower() + if not normalized: + raise ValueError('Username nie moze byc pusty.') + + user = User.query.filter(func.lower(User.username) == normalized).first() + created = False + password_changed = False + + if user is None: + if not password: + raise ValueError('Haslo jest wymagane przy tworzeniu nowego uzytkownika.') + user = User( + username=normalized, + password_hash=hash_password(password), + is_admin=bool(make_admin), + ) + db.session.add(user) + created = True + else: + user.username = normalized + if make_admin and not user.is_admin: + user.is_admin = True + elif not make_admin and user.is_admin: + user.is_admin = False + + if password and update_password: + user.password_hash = hash_password(password) + password_changed = True + + db.session.commit() + return user, created, password_changed + + +def parse_cli_datetime(value: str) -> datetime: + raw = (value or '').strip() + if not raw: + raise ValueError('Podaj date i godzine.') + + normalized = raw.replace('T', ' ') + for fmt in ('%Y-%m-%d %H:%M', '%Y-%m-%d %H:%M:%S', '%Y-%m-%d'): + try: + parsed = datetime.strptime(normalized, fmt) + if fmt == '%Y-%m-%d': + parsed = parsed.replace(hour=8, minute=0, second=0) + return parsed.replace(tzinfo=timezone.utc) + except ValueError: + continue + raise ValueError('Niepoprawny format daty. Uzyj YYYY-MM-DD lub YYYY-MM-DD HH:MM.') + + +def duplicate_list_for_schedule(source_list: ShoppingList, scheduled_for: datetime, owner: User | None = None, title: str | None = None): + if source_list is None: + raise ValueError('Lista zrodlowa nie istnieje.') + if scheduled_for.tzinfo is None: + scheduled_for = scheduled_for.replace(tzinfo=timezone.utc) + + owner_id = owner.id if owner else source_list.owner_id + base_title = (title or source_list.title or 'Lista').strip() + new_list = ShoppingList( + title=base_title, + owner_id=owner_id, + is_temporary=bool(source_list.is_temporary), + share_token=generate_share_token(8), + created_at=scheduled_for, + is_archived=bool(source_list.is_archived), + is_public=bool(source_list.is_public), + ) + + if source_list.expires_at: + original_created = source_list.created_at or scheduled_for + if original_created.tzinfo is None: + original_created = original_created.replace(tzinfo=timezone.utc) + expires_at = source_list.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + delta = expires_at - original_created + if delta.total_seconds() > 0: + new_list.expires_at = scheduled_for + delta + + db.session.add(new_list) + db.session.flush() + + for item in source_list.items: + db.session.add( + Item( + list_id=new_list.id, + name=item.name, + quantity=item.quantity or 1, + note=item.note, + position=item.position or 0, + added_at=scheduled_for, + added_by=owner_id, + ) + ) + + for category in source_list.categories: + new_list.categories.append(category) + + db.session.commit() + return new_list + +def hash_api_token(token: str) -> str: + return hashlib.sha256((token or '').encode('utf-8')).hexdigest() + + +def generate_api_token_value() -> str: + return f"sz_{secrets.token_urlsafe(24)}" + + +def mask_token_prefix(token_value: str, visible: int = 12) -> str: + return (token_value or '')[:visible] + + +def create_api_token_record(name: str, created_by: int | None = None, scopes: str = 'expenses:read', allowed_endpoints: str = '/api/expenses/latest,/api/expenses/summary,/api/lists,/api/lists//expenses,/api/templates,/api/ping', max_limit: int = 100): + token_value = generate_api_token_value() + record = ApiToken( + name=name.strip(), + token_hash=hash_api_token(token_value), + token_prefix=mask_token_prefix(token_value), + created_by=created_by, + scopes=scopes or 'expenses:read', + allowed_endpoints=allowed_endpoints or '/api/expenses/latest,/api/expenses/summary,/api/lists,/api/lists//expenses,/api/templates,/api/ping', + max_limit=max(1, min(int(max_limit or 100), 500)), + ) + db.session.add(record) + db.session.commit() + return record, token_value + + +def extract_api_token_from_request() -> str | None: + auth_header = (request.headers.get('Authorization') or '').strip() + if auth_header.lower().startswith('bearer '): + token_value = auth_header[7:].strip() + if token_value: + return token_value + + header_token = (request.headers.get('X-API-Token') or '').strip() + if header_token: + return header_token + + query_token = (request.args.get('api_token') or '').strip() + if query_token: + return query_token + + return None + + +def authenticate_api_token(raw_token: str | None = None, touch: bool = True) -> ApiToken | None: + token_value = (raw_token or extract_api_token_from_request() or '').strip() + if not token_value: + return None + + token_hash = hash_api_token(token_value) + token_record = ApiToken.query.filter_by(token_hash=token_hash, is_active=True).first() + if token_record and touch: + token_record.last_used_at = utcnow() + db.session.commit() + return token_record + + +def api_token_required(view_func): + @wraps(view_func) + def wrapped(*args, **kwargs): + token_record = authenticate_api_token() + if not token_record: + return ( + jsonify( + { + 'ok': False, + 'error': 'unauthorized', + 'message': 'Brak poprawnego tokenu API. Użyj nagłówka Authorization: Bearer albo X-API-Token.', + } + ), + 401, + ) + + g.api_token = token_record + return view_func(*args, **kwargs) + + return wrapped + + + + +def api_token_has_scope(token_record: ApiToken | None, required_scope: str) -> bool: + if not token_record or not required_scope: + return False + scopes = {s.strip() for s in (token_record.scopes or '').split(',') if s.strip()} + return required_scope in scopes or '*' in scopes + + +def api_token_allows_endpoint(token_record: ApiToken | None, endpoint_path: str) -> bool: + if not token_record: + return False + allowed = {s.strip() for s in (token_record.allowed_endpoints or '').split(',') if s.strip()} + if not allowed: + return False + if '*' in allowed or endpoint_path in allowed: + return True + for pattern in allowed: + if '' in pattern: + regex = '^' + re.escape(pattern).replace(re.escape(''), r'\d+') + '$' + if re.match(regex, endpoint_path): + return True + return False + + +def require_api_scope(required_scope: str): + def decorator(view_func): + @wraps(view_func) + def wrapped(*args, **kwargs): + token_record = getattr(g, 'api_token', None) + if not token_record: + return jsonify({'ok': False, 'error': 'unauthorized'}), 401 + if not api_token_has_scope(token_record, required_scope): + return jsonify({'ok': False, 'error': 'forbidden', 'message': 'Token nie ma wymaganego zakresu.'}), 403 + if not api_token_allows_endpoint(token_record, request.path): + return jsonify({'ok': False, 'error': 'forbidden', 'message': 'Token nie ma dostepu do tego endpointu.'}), 403 + return view_func(*args, **kwargs) + return wrapped + return decorator + + +def log_list_activity(list_id: int, action: str, item_name: str | None = None, actor: User | None = None, actor_name: str | None = None, details: str | None = None): + resolved_name = actor_name or (actor.username if actor else None) or 'Gość' + db.session.add(ListActivityLog( + list_id=list_id, + actor_id=actor.id if actor else None, + actor_name=resolved_name, + action=action, + item_name=item_name, + details=details, + )) + + +def action_label(action: str) -> str: + return { + 'item_added': 'dodał produkt', + 'item_deleted': 'usunął produkt', + 'item_checked': 'oznaczył jako kupione', + 'item_unchecked': 'odznaczył produkt', + 'item_marked_not_purchased': 'oznaczył jako niekupione', + 'item_unmarked_not_purchased': 'przywrócił produkt', + 'expense_added': 'dodał wydatek', + 'list_duplicated': 'zduplikował listę', + 'template_created': 'utworzył szablon', + }.get(action, action) + + +def get_expiring_lists_for_user(user_id: int, within_hours: int = 24): + now_dt = datetime.now(timezone.utc) + until_dt = now_dt + timedelta(hours=within_hours) + return ( + ShoppingList.query.filter( + ShoppingList.owner_id == user_id, + ShoppingList.is_temporary == True, + ShoppingList.is_archived == False, + ShoppingList.expires_at.isnot(None), + ShoppingList.expires_at > now_dt, + ShoppingList.expires_at <= until_dt, + ) + .order_by(ShoppingList.expires_at.asc()) + .all() + ) + + +def get_admin_expiring_lists(within_hours: int = 24): + now_dt = datetime.now(timezone.utc) + until_dt = now_dt + timedelta(hours=within_hours) + return ( + ShoppingList.query.options(joinedload(ShoppingList.owner)) + .filter( + ShoppingList.is_temporary == True, + ShoppingList.is_archived == False, + ShoppingList.expires_at.isnot(None), + ShoppingList.expires_at > now_dt, + ShoppingList.expires_at <= until_dt, + ) + .order_by(ShoppingList.expires_at.asc()) + .all() + ) + + +def create_template_from_list(source_list: ShoppingList, created_by: int | None = None, name: str | None = None, description: str | None = None): + template = ListTemplate( + name=(name or source_list.title).strip(), + description=(description or f'Szablon utworzony z listy #{source_list.id}').strip(), + created_by=created_by, + ) + db.session.add(template) + db.session.flush() + for idx, item in enumerate(sorted(source_list.items, key=lambda x: (x.position or 0, x.id))): + db.session.add(ListTemplateItem( + template_id=template.id, + name=item.name, + quantity=item.quantity or 1, + note=item.note, + position=idx + 1, + )) + db.session.commit() + return template + + + + +def template_is_accessible_to_user(template: ListTemplate, user: User | None) -> bool: + if not template or not template.is_active or not user: + return False + if user.is_admin: + return True + return bool(template.created_by == user.id) + +def create_list_from_template(template: ListTemplate, owner: User, title: str | None = None): + token = generate_share_token(8) + new_list = ShoppingList( + title=(title or template.name).strip(), + owner_id=owner.id, + share_token=token, + is_temporary=False, + expires_at=None, + ) + db.session.add(new_list) + db.session.flush() + for idx, item in enumerate(template.items): + db.session.add(Item( + list_id=new_list.id, + name=item.name, + quantity=item.quantity or 1, + note=item.note, + position=idx + 1, + added_by=owner.id, + )) + db.session.commit() + return new_list + +def format_dt_for_api(dt: datetime | None) -> str | None: + if not dt: + return None + if dt.tzinfo is None: + return dt.isoformat() + 'Z' + return dt.astimezone(timezone.utc).isoformat().replace('+00:00', 'Z') + + +def parse_api_date_range(start_date_str: str | None, end_date_str: str | None): + start_date = None + end_date = None + + if start_date_str: + start_date = datetime.strptime(start_date_str, '%Y-%m-%d') + + if end_date_str: + end_date = datetime.strptime(end_date_str, '%Y-%m-%d') + timedelta(days=1) + + if start_date and end_date and start_date >= end_date: + raise ValueError('Data początkowa musi być wcześniejsza niż końcowa.') + + if not start_date and not end_date: + end_date = datetime.utcnow() + timedelta(days=1) + start_date = end_date - timedelta(days=30) + + return start_date, end_date + + +def set_authorized_cookie(response): + secure_flag = app.config["SESSION_COOKIE_SECURE"] + max_age = app.config.get("AUTH_COOKIE_MAX_AGE", 86400) + response.set_cookie( + "authorized", + AUTHORIZED_COOKIE_VALUE, + max_age=max_age, + secure=secure_flag, + httponly=True, + ) + return response + + +if app.config["SQLALCHEMY_DATABASE_URI"].startswith("sqlite:///"): + db_path = app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "", 1) + db_dir = os.path.dirname(db_path) + if db_dir and not os.path.exists(db_dir): + os.makedirs(db_dir, exist_ok=True) + print(f"Utworzono katalog bazy: {db_dir}") + + +with app.app_context(): + db.create_all() + + # --- Tworzenie admina --- + admin_username = DEFAULT_ADMIN_USERNAME + admin_password = DEFAULT_ADMIN_PASSWORD + password_hash = hash_password(admin_password) + + admin = User.query.filter_by(username=admin_username).first() + if admin: + if not admin.is_admin: + admin.is_admin = True + if not check_password(admin.password_hash, admin_password): + admin.password_hash = password_hash + print(f"[INFO] Zmieniono hasło admina '{admin_username}' z konfiguracji.") + db.session.commit() + else: + db.session.add( + User(username=admin_username, password_hash=password_hash, is_admin=True) + ) + db.session.commit() + + default_categories = app.config["DEFAULT_CATEGORIES"] + existing_names = { + c.name for c in Category.query.filter(Category.name.isnot(None)).all() + } + + existing_names_lower = {name.lower() for name in existing_names} + + missing = [ + cat for cat in default_categories if cat.lower() not in existing_names_lower + ] + + if missing: + db.session.add_all(Category(name=cat) for cat in missing) + db.session.commit() + print(f"[INFO] Dodano brakujące kategorie: {', '.join(missing)}") + # else: + # print("[INFO] Wszystkie domyślne kategorie już istnieją") + + +@static_bp.route("/static/js/") +def serve_js(filename): + response = send_from_directory("static/js", filename) + response.headers["Cache-Control"] = app.config["JS_CACHE_CONTROL"] + response.headers.pop("Content-Disposition", None) + return response + + +@static_bp.route("/static/css/") +def serve_css(filename): + response = send_from_directory("static/css", filename) + response.headers["Cache-Control"] = app.config["CSS_CACHE_CONTROL"] + response.headers.pop("Content-Disposition", None) + return response + + +@static_bp.route("/static/lib/js/") +def serve_js_lib(filename): + response = send_from_directory("static/lib/js", filename) + response.headers["Cache-Control"] = app.config["LIB_JS_CACHE_CONTROL"] + response.headers.pop("Content-Disposition", None) + return response + + +@static_bp.route("/static/lib/css/") +def serve_css_lib(filename): + response = send_from_directory("static/lib/css", filename) + response.headers["Cache-Control"] = app.config["LIB_CSS_CACHE_CONTROL"] + response.headers.pop("Content-Disposition", None) + return response + + + + +def allowed_file(filename): + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + + +def generate_version_token(): + return secrets.token_hex(8) + + +def get_list_details(list_id): + shopping_list = ShoppingList.query.options( + joinedload(ShoppingList.items).joinedload(Item.added_by_user), + joinedload(ShoppingList.expenses), + joinedload(ShoppingList.receipts), + ).get_or_404(list_id) + + items = sorted(shopping_list.items, key=lambda i: i.position or 0) + expenses = shopping_list.expenses + total_expense = sum(e.amount for e in expenses) if expenses else 0 + receipts = shopping_list.receipts + + return shopping_list, items, receipts, expenses, total_expense + + +def get_total_expense_for_list(list_id, start_date=None, end_date=None): + query = db.session.query(func.sum(Expense.amount)).filter( + Expense.list_id == list_id + ) + + if start_date and end_date: + query = query.filter( + Expense.added_at >= start_date, Expense.added_at < end_date + ) + + return query.scalar() or 0 + + +def update_list_categories_from_form(shopping_list, form): + raw_vals = form.getlist("categories") + candidate_ids = set() + + for v in raw_vals: + if not v: + continue + v = v.strip() + try: + candidate_ids.add(int(v)) + continue + except ValueError: + pass + + cat = Category.query.filter(func.lower(Category.name) == v.lower()).first() + if cat: + candidate_ids.add(cat.id) + shopping_list.categories.clear() + if candidate_ids: + cats = Category.query.filter(Category.id.in_(candidate_ids)).all() + shopping_list.categories.extend(cats) + + +def generate_share_token(length=8): + return secrets.token_hex(length // 2) + + +def check_list_public(shopping_list): + if not shopping_list.is_public: + flash("Ta lista nie jest publicznie dostępna", "danger") + return False + return True + + +def enrich_list_data(l): + counts = ( + db.session.query( + func.count(Item.id), + func.sum(case((Item.purchased == True, 1), else_=0)), + func.sum(Expense.amount), + ) + .outerjoin(Expense, Expense.list_id == Item.list_id) + .filter(Item.list_id == l.id) + .first() + ) + + l.total_count = counts[0] or 0 + l.purchased_count = counts[1] or 0 + l.total_expense = counts[2] or 0 + + return l + + +def get_total_records(): + total = 0 + inspector = inspect(db.engine) + with db.engine.connect() as conn: + for table_name in inspector.get_table_names(): + count = conn.execute(text(f"SELECT COUNT(*) FROM {table_name}")).scalar() + total += count + return total + + +def save_resized_image(file, path): + try: + image = Image.open(file) + image.verify() + file.seek(0) + image = Image.open(file) + except Exception: + raise ValueError("Nieprawidłowy plik graficzny") + + try: + image = ImageOps.exif_transpose(image) + except Exception: + pass + + try: + image.thumbnail((1500, 1500)) + image = image.convert("RGB") + image.info.clear() + + new_path = path.rsplit(".", 1)[0] + ".webp" + # image.save(new_path, **WEBP_SAVE_PARAMS) + image.save(new_path, format="WEBP", method=6, quality=100) + + except Exception as e: + raise ValueError(f"Błąd podczas przetwarzania obrazu: {e}") + + +def redirect_with_flash( + message: str, category: str = "info", endpoint: str = "main_page" +): + flash(message, category) + return redirect(url_for(endpoint)) + + +def can_view_list(sl: ShoppingList) -> bool: + if current_user.is_authenticated: + if sl.owner_id == current_user.id: + return True + if sl.is_public: + return True + return ( + db.session.query(ListPermission.id) + .filter_by(list_id=sl.id, user_id=current_user.id) + .first() + is not None + ) + return bool(sl.is_public) + + +def db_bucket(col, kind: str = "month"): + name = db.engine.name # 'sqlite', 'mysql', 'mariadb', 'postgresql', ... + kind = (kind or "month").lower() + + if kind == "day": + if name == "sqlite": + return func.strftime("%Y-%m-%d", col) + elif name in ("mysql", "mariadb"): + return func.date_format(col, "%Y-%m-%d") + else: + return func.to_char(col, "YYYY-MM-DD") + + if kind == "week": + if name == "sqlite": + return func.printf( + "%s-W%s", func.strftime("%Y", col), func.strftime("%W", col) + ) + elif name in ("mysql", "mariadb"): + return func.date_format(col, "%x-W%v") + else: + return func.to_char(col, 'IYYY-"W"IW') + + if name == "sqlite": + return func.strftime("%Y-%m", col) + elif name in ("mysql", "mariadb"): + return func.date_format(col, "%Y-%m") + else: + return func.to_char(col, "YYYY-MM") + + +def visible_lists_clause_for_expenses(user_id: int, include_shared: bool, now_dt): + perm_subq = user_permission_subq(user_id) + + base = [ + ShoppingList.is_archived == False, + ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now_dt)), + ] + + if include_shared: + base.append( + or_( + ShoppingList.owner_id == user_id, + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ) + ) + else: + base.append(ShoppingList.owner_id == user_id) + + return base + + +def user_permission_subq(user_id): + return db.session.query(ListPermission.list_id).filter( + ListPermission.user_id == user_id + ) + + +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or not current_user.is_admin: + return redirect_with_flash("Brak uprawnień do tej sekcji.", "danger") + return f(*args, **kwargs) + + return decorated_function + + +def get_progress(list_id: int) -> tuple[int, int, float]: + result = ( + db.session.query( + func.count(Item.id), + func.sum(case(((Item.purchased == True) & (Item.not_purchased == False), 1), else_=0)), + ) + .filter(Item.list_id == list_id) + .first() + ) + + if result is None: + total_count = 0 + purchased_count = 0 + else: + total_count = result[0] or 0 + purchased_count = result[1] or 0 + + percent = (purchased_count / total_count * 100) if total_count > 0 else 0 + return purchased_count, total_count, percent + + +def delete_receipts_for_list(list_id): + receipt_pattern = f"list_{list_id}_" + upload_folder = app.config["UPLOAD_FOLDER"] + for filename in os.listdir(upload_folder): + if filename.startswith(receipt_pattern): + try: + os.remove(os.path.join(upload_folder, filename)) + except Exception as e: + print(f"Nie udało się usunąć pliku {filename}: {e}") + + +def receipt_error(message): + if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify({"success": False, "error": message}), 400 + flash(message, "danger") + return redirect(request.referrer or url_for("main_page")) + + +def rotate_receipt_by_id(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + if not os.path.exists(path): + raise FileNotFoundError("Plik nie istnieje") + + try: + image = Image.open(path) + rotated = image.rotate(-90, expand=True) + + rotated = rotated.convert("RGB") + rotated.info.clear() + + rotated.save(path, format="WEBP", method=6, quality=100) + receipt.version_token = generate_version_token() + recalculate_filesizes(receipt.id) + db.session.commit() + + return receipt + except Exception as e: + app.logger.exception("Błąd podczas rotacji pliku") + raise RuntimeError(f"Błąd podczas rotacji pliku: {e}") + + +def delete_receipt_by_id(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + if os.path.exists(filepath): + os.remove(filepath) + + db.session.delete(receipt) + db.session.commit() + return receipt + + +def generate_new_receipt_filename(list_id): + timestamp = datetime.now().strftime("%Y%m%d_%H%M") + random_part = secrets.token_hex(3) + return f"list_{list_id}_{timestamp}_{random_part}.webp" + + +def handle_crop_receipt(receipt_id, file): + if not receipt_id or not file: + return {"success": False, "error": "Brak danych"} + + try: + receipt = Receipt.query.get_or_404(receipt_id) + path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + save_resized_image(file, path) + receipt.version_token = generate_version_token() + recalculate_filesizes(receipt.id) + db.session.commit() + + return {"success": True} + except Exception as e: + app.logger.exception("Błąd podczas przycinania paragonu") + return {"success": False, "error": str(e)} + + +def recalculate_filesizes(receipt_id: int = None): + updated = 0 + not_found = 0 + unchanged = 0 + + if receipt_id is not None: + receipt = db.session.get(Receipt, receipt_id) + receipts = [receipt] if receipt else [] + else: + receipts = db.session.execute(db.select(Receipt)).scalars().all() + + for r in receipts: + if not r: + continue + filepath = os.path.join(app.config["UPLOAD_FOLDER"], r.filename) + if os.path.exists(filepath): + real_size = os.path.getsize(filepath) + if r.filesize != real_size: + r.filesize = real_size + updated += 1 + else: + unchanged += 1 + else: + not_found += 1 + + db.session.commit() + return updated, unchanged, not_found + + +def get_admin_expense_summary(): + now = datetime.now(timezone.utc) + current_year = now.year + current_month = now.month + + def calc_summary(expense_query, list_query): + total = expense_query.scalar() or 0 + year_total = ( + expense_query.filter( + extract("year", ShoppingList.created_at) == current_year + ).scalar() + or 0 + ) + month_total = ( + expense_query.filter( + extract("year", ShoppingList.created_at) == current_year, + extract("month", ShoppingList.created_at) == current_month, + ).scalar() + or 0 + ) + list_count = list_query.count() + avg = round(total / list_count, 2) if list_count else 0 + return { + "total": total, + "year": year_total, + "month": month_total, + "count": list_count, + "avg": avg, + } + + expense_base = db.session.query(func.sum(Expense.amount)).join( + ShoppingList, ShoppingList.id == Expense.list_id + ) + list_base = ShoppingList.query + + all = calc_summary(expense_base, list_base) + + active_condition = and_( + ShoppingList.is_archived == False, + ~( + (ShoppingList.is_temporary == True) + & (ShoppingList.expires_at != None) + & (ShoppingList.expires_at <= now) + ), + ) + active = calc_summary( + expense_base.filter(active_condition), list_base.filter(active_condition) + ) + + archived_condition = ShoppingList.is_archived == True + archived = calc_summary( + expense_base.filter(archived_condition), list_base.filter(archived_condition) + ) + + expired_condition = and_( + ShoppingList.is_archived == False, + ShoppingList.is_temporary == True, + ShoppingList.expires_at != None, + ShoppingList.expires_at <= now, + ) + expired = calc_summary( + expense_base.filter(expired_condition), list_base.filter(expired_condition) + ) + + return { + "all": all, + "active": active, + "archived": archived, + "expired": expired, + } + + +def category_to_color(name: str, min_hue_gap_deg: int = 18) -> str: + # Stabilny hash -> int + hv = int(hashlib.md5(name.encode("utf-8")).hexdigest(), 16) + + # Proste mieszanie bitów, by uniknąć lokalnych skupień + def rotl(x, r, bits=128): + r %= bits + return ((x << r) | (x >> (bits - r))) & ((1 << bits) - 1) + + mix = hv ^ rotl(hv, 37) ^ rotl(hv, 73) ^ rotl(hv, 91) + + # Pełne pokrycie koła barw 0..360 + hue_deg = mix % 360 + + # Odpychanie lokalne po hue, by podobne nazwy nie lądowały zbyt blisko + gap = (rotl(mix, 17) % (2 * min_hue_gap_deg)) - min_hue_gap_deg # [-gap, +gap] + hue_deg = (hue_deg + gap) % 360 + + # DARK profil: niższa jasność i nieco mniejsza saturacja + s = 0.70 + l = 0.45 + + # Wąska wariacja, żeby uniknąć „neonów” i zachować spójność + s_var = ((rotl(mix, 29) % 5) - 2) / 100.0 # ±0.02 + l_var = ((rotl(mix, 53) % 7) - 3) / 100.0 # ±0.03 + s = min(0.76, max(0.62, s + s_var)) + l = min(0.50, max(0.40, l + l_var)) + + # Konwersja HLS->RGB (colorsys: H,L,S w [0..1]) + h = hue_deg / 360.0 + r, g, b = colorsys.hls_to_rgb(h, l, s) + + return f"#{int(round(r*255)):02x}{int(round(g*255)):02x}{int(round(b*255)):02x}" + + +def get_total_expenses_grouped_by_category( + show_all, range_type, start_date, end_date, user_id, category_id=None +): + now = datetime.now(timezone.utc) + lists_q = ShoppingList.query.filter( + ShoppingList.is_archived == False, + ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), + ) + + if show_all: + perm_subq = user_permission_subq(user_id) + lists_q = lists_q.filter( + or_( + ShoppingList.owner_id == user_id, + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ) + ) + else: + lists_q = lists_q.filter(ShoppingList.owner_id == user_id) + + if category_id: + if str(category_id) == "none": + lists_q = lists_q.filter(~ShoppingList.categories.any()) + else: + try: + cid = int(category_id) + lists_q = lists_q.join( + shopping_list_category, + shopping_list_category.c.shopping_list_id == ShoppingList.id, + ).filter(shopping_list_category.c.category_id == cid) + except (TypeError, ValueError): + pass + + if start_date and end_date: + try: + dt_start = datetime.strptime(start_date, "%Y-%m-%d") + dt_end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) + lists_q = lists_q.filter( + ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end + ) + except Exception: + return {"error": "Błędne daty"} + + lists = lists_q.options(joinedload(ShoppingList.categories)).all() + if not lists: + return {"labels": [], "datasets": []} + + list_ids = [l.id for l in lists] + totals = ( + db.session.query( + Expense.list_id, + func.coalesce(func.sum(Expense.amount), 0).label("total_amount"), + ) + .filter(Expense.list_id.in_(list_ids)) + .group_by(Expense.list_id) + .all() + ) + expense_map = {lid: float(total or 0) for lid, total in totals} + + def bucket_from_dt(ts: datetime) -> str: + if range_type == "daily": + return ts.strftime("%Y-%m-%d") + elif range_type == "weekly": + return f"{ts.isocalendar().year}-W{ts.isocalendar().week:02d}" + elif range_type == "quarterly": + return f"{ts.year}-Q{((ts.month - 1)//3 + 1)}" + elif range_type == "halfyearly": + return f"{ts.year}-H{1 if ts.month <= 6 else 2}" + elif range_type == "yearly": + return str(ts.year) + else: + return ts.strftime("%Y-%m") + + data_map = defaultdict(lambda: defaultdict(float)) + all_labels = set() + + for l in lists: + key = bucket_from_dt(l.created_at) + all_labels.add(key) + total_expense = expense_map.get(l.id, 0.0) + + if str(category_id) == "none": + data_map[key]["Bez kategorii"] += total_expense + continue + + if not l.categories: + data_map[key]["Bez kategorii"] += total_expense + else: + for c in l.categories: + if category_id and str(c.id) != str(category_id): + continue + data_map[key][c.name] += total_expense + + labels = sorted(all_labels) + cats = sorted({cat for b in data_map.values() for cat, v in b.items() if v > 0}) + + datasets = [ + { + "label": cat, + "data": [round(data_map[label].get(cat, 0.0), 2) for label in labels], + "backgroundColor": color_for_category_label(cat), + } + for cat in cats + ] + return {"labels": labels, "datasets": datasets} + + +def get_total_expenses_grouped_by_list_created_at( + user_only=False, + admin=False, + show_all=False, + range_type="monthly", + start_date=None, + end_date=None, + user_id=None, + category_id=None, +): + now = datetime.now(timezone.utc) + lists_q = ShoppingList.query.filter( + ShoppingList.is_archived == False, + ((ShoppingList.expires_at == None) | (ShoppingList.expires_at > now)), + ) + + if admin: + pass + elif user_only: + lists_q = lists_q.filter(ShoppingList.owner_id == user_id) + elif show_all: + perm_subq = user_permission_subq(user_id) + lists_q = lists_q.filter( + or_( + ShoppingList.owner_id == user_id, + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ) + ) + else: + lists_q = lists_q.filter(ShoppingList.owner_id == user_id) + + # kategorie (bez ucinania „none”) + if category_id: + if str(category_id) == "none": + lists_q = lists_q.filter(~ShoppingList.categories.any()) + else: + try: + cid = int(category_id) + lists_q = lists_q.join( + shopping_list_category, + shopping_list_category.c.shopping_list_id == ShoppingList.id, + ).filter(shopping_list_category.c.category_id == cid) + except (TypeError, ValueError): + pass + + if start_date and end_date: + try: + dt_start = datetime.strptime(start_date, "%Y-%m-%d") + dt_end = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) + lists_q = lists_q.filter( + ShoppingList.created_at >= dt_start, ShoppingList.created_at < dt_end + ) + except Exception: + return {"error": "Błędne daty"} + + lists = lists_q.options(joinedload(ShoppingList.categories)).all() + if not lists: + return {"labels": [], "expenses": []} + + list_ids = [l.id for l in lists] + totals = ( + db.session.query( + Expense.list_id, + func.coalesce(func.sum(Expense.amount), 0).label("total_amount"), + ) + .filter(Expense.list_id.in_(list_ids)) + .group_by(Expense.list_id) + .all() + ) + expense_map = {lid: float(total or 0) for lid, total in totals} + + def bucket_from_dt(ts: datetime) -> str: + if range_type == "daily": + return ts.strftime("%Y-%m-%d") + elif range_type == "weekly": + return f"{ts.isocalendar().year}-W{ts.isocalendar().week:02d}" + elif range_type == "quarterly": + return f"{ts.year}-Q{((ts.month - 1)//3 + 1)}" + elif range_type == "halfyearly": + return f"{ts.year}-H{1 if ts.month <= 6 else 2}" + elif range_type == "yearly": + return str(ts.year) + else: + return ts.strftime("%Y-%m") + + grouped = defaultdict(float) + for sl in lists: + grouped[bucket_from_dt(sl.created_at)] += expense_map.get(sl.id, 0.0) + + labels = sorted(grouped.keys()) + expenses = [round(grouped[l], 2) for l in labels] + return {"labels": labels, "expenses": expenses} + + +def resolve_range(range_type: str): + now = datetime.now(timezone.utc) + sd = ed = None + bucket = "monthly" + + rt = (range_type or "").lower() + if rt in ("last7days", "last_7_days"): + sd = (now - timedelta(days=7)).date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "daily" + elif rt in ("last30days", "last_30_days"): + sd = (now - timedelta(days=30)).date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "monthly" + elif rt in ("last90days", "last_90_days"): + sd = (now - timedelta(days=90)).date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "monthly" + elif rt in ("thismonth", "this_month"): + first = datetime(now.year, now.month, 1, tzinfo=timezone.utc) + sd = first.date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "monthly" + elif rt in ( + "currentmonth", + "thismonth", + "this_month", + "monthtodate", + "month_to_date", + "mtd", + ): + first = datetime(now.year, now.month, 1, tzinfo=timezone.utc) + sd = first.date().strftime("%Y-%m-%d") + ed = now.date().strftime("%Y-%m-%d") + bucket = "monthly" + + return sd, ed, bucket + + +def save_pdf_as_webp(file, path): + try: + images = convert_from_bytes(file.read(), dpi=300) + if not images: + raise ValueError("Nie udało się przekonwertować PDF na obraz.") + + total_height = sum(img.height for img in images) + max_width = max(img.width for img in images) + combined = Image.new("RGB", (max_width, total_height), (255, 255, 255)) + + y_offset = 0 + for img in images: + combined.paste(img, (0, y_offset)) + y_offset += img.height + + new_path = path.rsplit(".", 1)[0] + ".webp" + # combined.save(new_path, **WEBP_SAVE_PARAMS) + combined.save(new_path, format="WEBP") + + except Exception as e: + raise ValueError(f"Błąd podczas przetwarzania PDF: {e}") + + +def get_active_months_query(visible_lists_query=None): + if db.engine.name in ("sqlite",): + + def month_expr(col): + return func.strftime("%Y-%m", col) + + elif db.engine.name in ("mysql", "mariadb"): + + def month_expr(col): + return func.date_format(col, "%Y-%m") + + else: # PostgreSQL + + def month_expr(col): + return func.to_char(col, "YYYY-MM") + + if visible_lists_query is not None: + s = visible_lists_query.subquery() + month_sel = month_expr(s.c.created_at).label("month") + inner = ( + db.session.query(month_sel) + .filter(month_sel.isnot(None)) + .distinct() + .subquery() + ) + else: + month_sel = month_expr(ShoppingList.created_at).label("month") + inner = ( + db.session.query(month_sel) + .filter(ShoppingList.created_at.isnot(None)) + .distinct() + .subquery() + ) + + rows = db.session.query(inner.c.month).order_by(inner.c.month).all() + return [r.month for r in rows] + + +def normalize_name(name): + if not name: + return "" + return re.sub(r"\s+", " ", name).strip().lower() + + +def get_valid_item_or_404(item_id: int, list_id: int) -> Item: + item = db.session.get(Item, item_id) + if not item or item.list_id != list_id: + abort(404, description="Nie znaleziono produktu") + return item + + +def paginate_items( + items: Sequence[Any], page: int, per_page: int +) -> tuple[list, int, int]: + total_items = len(items) + total_pages = (total_items + per_page - 1) // per_page + start = (page - 1) * per_page + end = start + per_page + return items[start:end], total_items, total_pages + + +def get_page_args( + default_per_page: int = 100, max_per_page: int = 300 +) -> tuple[int, int]: + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", default_per_page, type=int) + per_page = max(1, min(per_page, max_per_page)) + return page, per_page + + +############# OCR ########################### + + +def preprocess_image_for_tesseract(image): + # czułość 1..10 (domyślnie 5) + sens = get_int_setting("ocr_sensitivity", 5) + # próg progowy – im wyższa czułość, tym niższy próg (więcej czerni) + base_thresh = 150 + delta = int((sens - 5) * 8) # krok 8 na stopień + thresh = max(90, min(210, base_thresh - delta)) + + image = ImageOps.autocontrast(image) + image = image.point(lambda x: 0 if x < thresh else 255) + image = image.resize((image.width * 2, image.height * 2), Image.BICUBIC) + return image + + +def extract_total_tesseract(image): + + text = pytesseract.image_to_string(image, lang="pol", config="--psm 4") + lines = text.splitlines() + candidates = [] + + blacklist_keywords = re.compile(r"\b(ptu|vat|podatek|stawka)\b", re.IGNORECASE) + + priority_keywords = priority_keywords_pattern() + + for line in lines: + if not line.strip(): + continue + + if blacklist_keywords.search(line): + continue + + is_priority = priority_keywords.search(line) + + matches = re.findall(r"\d{1,4}[.,]\d{2}", line) + for match in matches: + try: + val = float(match.replace(",", ".")) + if 0.1 <= val <= 100000: + candidates.append((val, line, is_priority is not None)) + except: + continue + + if is_priority: + spaced = re.findall(r"\d{1,4}\s\d{2}", line) + for match in spaced: + try: + val = float(match.replace(" ", ".")) + if 0.1 <= val <= 100000: + candidates.append((val, line, True)) + except: + continue + + preferred = [(val, line) for val, line, is_pref in candidates if is_pref] + + if preferred: + best_val = max(preferred, key=lambda x: x[0])[0] + if best_val < 99999: + return round(best_val, 2), lines + + if candidates: + best_val = max(candidates, key=lambda x: x[0])[0] + if best_val < 99999: + return round(best_val, 2), lines + + data = pytesseract.image_to_data( + image, lang="pol", config="--psm 4", output_type=Output.DICT + ) + + font_candidates = [] + for i in range(len(data["text"])): + word = data["text"][i].strip() + if not word or not re.match(r"^\d{1,5}[.,\s]\d{2}$", word): + continue + + try: + val = float(word.replace(",", ".").replace(" ", ".")) + height = data["height"][i] + conf = int(data.get("conf", ["0"] * len(data["text"]))[i]) + if 0.1 <= val <= 100000: + font_candidates.append((val, height, conf)) + except: + continue + + if font_candidates: + best = max(font_candidates, key=lambda x: (x[1], x[2])) + return round(best[0], 2), lines + + return 0.0, lines + + +############# END OCR ####################### + + +# zabezpieczenie logowani do systemu - błędne hasła +def is_ip_blocked(ip): + now = time.time() + attempts = failed_login_attempts[ip] + while attempts and now - attempts[0] > TIME_WINDOW: + attempts.popleft() + max_attempts = get_int_setting("max_login_attempts", 10) + return len(attempts) >= max_attempts + + +def attempts_remaining(ip): + attempts = failed_login_attempts[ip] + max_attempts = get_int_setting("max_login_attempts", 10) + return max(0, max_attempts - len(attempts)) + + +def register_failed_attempt(ip): + now = time.time() + attempts = failed_login_attempts[ip] + while attempts and now - attempts[0] > TIME_WINDOW: + attempts.popleft() + attempts.append(now) + + +def reset_failed_attempts(ip): + failed_login_attempts[ip].clear() + + +#################################################### + + +def get_client_ip(): + for header in ["X-Forwarded-For", "X-Real-IP"]: + if header in request.headers: + ip = request.headers[header].split(",")[0].strip() + if ip: + return ip + return request.remote_addr diff --git a/shopping_app/models.py b/shopping_app/models.py new file mode 100644 index 0000000..04ea605 --- /dev/null +++ b/shopping_app/models.py @@ -0,0 +1,216 @@ +from .deps import * +from .app_setup import db, utcnow + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(150), unique=True, nullable=False) + password_hash = db.Column(db.String(512), nullable=False) + is_admin = db.Column(db.Boolean, default=False) + + +# Tabela pośrednia +shopping_list_category = db.Table( + "shopping_list_category", + db.Column( + "shopping_list_id", + db.Integer, + db.ForeignKey("shopping_list.id"), + primary_key=True, + ), + db.Column( + "category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True + ), +) + + +class Category(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), unique=True, nullable=False) + + +class ShoppingList(db.Model): + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(150), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + owner_id = db.Column(db.Integer, db.ForeignKey("user.id")) + owner = db.relationship("User", backref="lists", foreign_keys=[owner_id]) + + is_temporary = db.Column(db.Boolean, default=False) + share_token = db.Column(db.String(64), unique=True, nullable=True) + expires_at = db.Column(db.DateTime(timezone=True), nullable=True) + owner = db.relationship("User", backref="lists", lazy=True) + is_archived = db.Column(db.Boolean, default=False) + is_public = db.Column(db.Boolean, default=False) + + # Relacje + items = db.relationship("Item", back_populates="shopping_list", lazy="select") + receipts = db.relationship( + "Receipt", + back_populates="shopping_list", + cascade="all, delete-orphan", + lazy="select", + ) + expenses = db.relationship("Expense", back_populates="shopping_list", lazy="select") + categories = db.relationship( + "Category", + secondary=shopping_list_category, + backref=db.backref("shopping_lists", lazy="dynamic"), + ) + + +class Item(db.Model): + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id")) + name = db.Column(db.String(150), nullable=False) + # added_at = db.Column(db.DateTime, default=datetime.utcnow) + added_at = db.Column(db.DateTime, default=utcnow) + added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + added_by_user = db.relationship( + "User", backref="added_items", lazy="joined", foreign_keys=[added_by] + ) + + purchased = db.Column(db.Boolean, default=False) + purchased_at = db.Column(db.DateTime, nullable=True) + quantity = db.Column(db.Integer, default=1) + note = db.Column(db.Text, nullable=True) + not_purchased = db.Column(db.Boolean, default=False) + not_purchased_reason = db.Column(db.Text, nullable=True) + position = db.Column(db.Integer, default=0) + + shopping_list = db.relationship("ShoppingList", back_populates="items") + + +class SuggestedProduct(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(150), unique=True, nullable=False) + usage_count = db.Column(db.Integer, default=0) + + +class Expense(db.Model): + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id")) + amount = db.Column(db.Float, nullable=False) + added_at = db.Column(db.DateTime, default=datetime.utcnow) + receipt_filename = db.Column(db.String(255), nullable=True) + + shopping_list = db.relationship("ShoppingList", back_populates="expenses") + + +class Receipt(db.Model): + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column( + db.Integer, + db.ForeignKey("shopping_list.id", ondelete="CASCADE"), + nullable=False, + ) + filename = db.Column(db.String(255), nullable=False) + uploaded_at = db.Column(db.DateTime, default=datetime.utcnow) + filesize = db.Column(db.Integer, nullable=True) + file_hash = db.Column(db.String(64), nullable=True, unique=True) + uploaded_by = db.Column(db.Integer, db.ForeignKey("user.id")) + version_token = db.Column(db.String(32), nullable=True) + + shopping_list = db.relationship("ShoppingList", back_populates="receipts") + uploaded_by_user = db.relationship("User", backref="uploaded_receipts") + + +class ListPermission(db.Model): + __tablename__ = "list_permission" + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column( + db.Integer, + db.ForeignKey("shopping_list.id", ondelete="CASCADE"), + nullable=False, + ) + user_id = db.Column( + db.Integer, + db.ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + ) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + __table_args__ = (db.UniqueConstraint("list_id", "user_id", name="uq_list_user"),) + + +ShoppingList.permitted_users = db.relationship( + "User", + secondary="list_permission", + backref=db.backref("permitted_lists", lazy="dynamic"), + lazy="dynamic", +) + + +class AppSetting(db.Model): + key = db.Column(db.String(64), primary_key=True) + value = db.Column(db.Text, nullable=True) + + +class ApiToken(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(120), nullable=False) + token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True) + token_prefix = db.Column(db.String(18), nullable=False) + created_at = db.Column(db.DateTime, default=utcnow, nullable=False) + last_used_at = db.Column(db.DateTime, nullable=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + scopes = db.Column(db.String(255), nullable=False, default="expenses:read") + allowed_endpoints = db.Column(db.String(255), nullable=False, default="/api/expenses/latest") + max_limit = db.Column(db.Integer, nullable=False, default=100) + + creator = db.relationship( + "User", backref="created_api_tokens", lazy="joined", foreign_keys=[created_by] + ) + + +class ListTemplate(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(150), nullable=False) + description = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=utcnow, nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + + creator = db.relationship("User", backref="list_templates", lazy="joined") + items = db.relationship( + "ListTemplateItem", + back_populates="template", + cascade="all, delete-orphan", + lazy="select", + order_by="ListTemplateItem.position.asc()", + ) + + +class ListTemplateItem(db.Model): + id = db.Column(db.Integer, primary_key=True) + template_id = db.Column(db.Integer, db.ForeignKey("list_template.id", ondelete="CASCADE"), nullable=False) + name = db.Column(db.String(150), nullable=False) + quantity = db.Column(db.Integer, default=1) + note = db.Column(db.Text, nullable=True) + position = db.Column(db.Integer, default=0) + + template = db.relationship("ListTemplate", back_populates="items") + + +class ListActivityLog(db.Model): + id = db.Column(db.Integer, primary_key=True) + list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id", ondelete="CASCADE"), nullable=False, index=True) + actor_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) + actor_name = db.Column(db.String(150), nullable=False, default="System") + action = db.Column(db.String(64), nullable=False) + item_name = db.Column(db.String(150), nullable=True) + details = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=utcnow, nullable=False, index=True) + + shopping_list = db.relationship("ShoppingList", backref=db.backref("activity_logs", lazy="dynamic", cascade="all, delete-orphan")) + actor = db.relationship("User", backref="list_activity_logs", lazy="joined") + + +class CategoryColorOverride(db.Model): + id = db.Column(db.Integer, primary_key=True) + category_id = db.Column( + db.Integer, db.ForeignKey("category.id"), unique=True, nullable=False + ) + color_hex = db.Column(db.String(7), nullable=False) # "#rrggbb" + + diff --git a/shopping_app/routes_admin.py b/shopping_app/routes_admin.py new file mode 100644 index 0000000..8de99ac --- /dev/null +++ b/shopping_app/routes_admin.py @@ -0,0 +1,1443 @@ +from .deps import * +from .app_setup import * +from .models import * +from .helpers import * + +@app.route("/admin") +@login_required +@admin_required +def admin_panel(): + month_str = request.args.get("m") + if not month_str: + month_str = datetime.now(timezone.utc).strftime("%Y-%m") + show_all = month_str == "all" + + if not show_all: + try: + if month_str: + year, month = map(int, month_str.split("-")) + now = datetime(year, month, 1, tzinfo=timezone.utc) + else: + now = datetime.now(timezone.utc) + month_str = now.strftime("%Y-%m") + except Exception: + now = datetime.now(timezone.utc) + month_str = now.strftime("%Y-%m") + start = now + end = (start + timedelta(days=31)).replace(day=1) + else: + now = datetime.now(timezone.utc) + start = end = None + + user_count = User.query.count() + list_count = ShoppingList.query.count() + item_count = Item.query.count() + + base_query = ShoppingList.query.options( + joinedload(ShoppingList.owner), + joinedload(ShoppingList.items), + joinedload(ShoppingList.receipts), + joinedload(ShoppingList.expenses), + joinedload(ShoppingList.categories), + ) + + if not show_all and start and end: + base_query = base_query.filter( + ShoppingList.created_at >= start, ShoppingList.created_at < end + ) + + all_lists = base_query.all() + all_ids = [l.id for l in all_lists] + + stats_map = {} + latest_expenses_map = {} + + if all_ids: + stats = ( + db.session.query( + Item.list_id, + func.count(Item.id).label("total_count"), + func.sum(case((Item.purchased == True, 1), else_=0)).label( + "purchased_count" + ), + ) + .filter(Item.list_id.in_(all_ids)) + .group_by(Item.list_id) + .all() + ) + stats_map = { + s.list_id: (s.total_count or 0, s.purchased_count or 0) for s in stats + } + + latest_expenses_map = dict( + db.session.query( + Expense.list_id, func.coalesce(func.sum(Expense.amount), 0) + ) + .filter(Expense.list_id.in_(all_ids)) + .group_by(Expense.list_id) + .all() + ) + + enriched_lists = [] + for l in all_lists: + total_count, purchased_count = stats_map.get(l.id, (0, 0)) + percent = (purchased_count / total_count * 100) if total_count > 0 else 0 + comments_count = sum(1 for i in l.items if i.note and i.note.strip() != "") + receipts_count = len(l.receipts) + total_expense = latest_expenses_map.get(l.id, 0) + + if l.is_temporary and l.expires_at: + expires_at = l.expires_at + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + is_expired = expires_at < now + else: + is_expired = False + + enriched_lists.append( + { + "list": l, + "total_count": total_count, + "purchased_count": purchased_count, + "percent": round(percent), + "comments_count": comments_count, + "receipts_count": receipts_count, + "total_expense": total_expense, + "expired": is_expired, + "categories": l.categories, + } + ) + + purchased_items_count = Item.query.filter_by(purchased=True).count() + not_purchased_count = Item.query.filter_by(not_purchased=True).count() + items_with_notes = Item.query.filter(Item.note.isnot(None), Item.note != "").count() + + total_expense = db.session.query(func.sum(Expense.amount)).scalar() or 0 + avg_list_expense = round(total_expense / list_count, 2) if list_count else 0 + + if db.engine.name == "sqlite": + timestamp_diff = func.strftime("%s", Item.purchased_at) - func.strftime( + "%s", Item.added_at + ) + elif db.engine.name in ("postgresql", "postgres"): + timestamp_diff = func.extract("epoch", Item.purchased_at) - func.extract( + "epoch", Item.added_at + ) + elif db.engine.name in ("mysql", "mariadb"): + timestamp_diff = func.timestampdiff( + text("SECOND"), Item.added_at, Item.purchased_at + ) + else: + timestamp_diff = None + + time_to_purchase = ( + db.session.query(func.avg(timestamp_diff)) + .filter( + Item.purchased == True, + Item.purchased_at.isnot(None), + Item.added_at.isnot(None), + ) + .scalar() + if timestamp_diff is not None + else None + ) + + avg_hours_to_purchase = round(time_to_purchase / 3600, 2) if time_to_purchase else 0 + + first_list = db.session.query(func.min(ShoppingList.created_at)).scalar() + last_list = db.session.query(func.max(ShoppingList.created_at)).scalar() + now_dt = datetime.now(timezone.utc) + + if first_list and first_list.tzinfo is None: + first_list = first_list.replace(tzinfo=timezone.utc) + + if last_list and last_list.tzinfo is None: + last_list = last_list.replace(tzinfo=timezone.utc) + + if first_list and last_list: + days_span = max((now_dt - first_list).days, 1) + avg_per_day = list_count / days_span + avg_per_week = round(avg_per_day * 7, 2) + avg_per_month = round(avg_per_day * 30.44, 2) + avg_per_year = round(avg_per_day * 365, 2) + else: + avg_per_week = avg_per_month = avg_per_year = 0 + + top_products = ( + db.session.query(Item.name, func.count(Item.id).label("count")) + .filter(Item.purchased.is_(True)) + .group_by(Item.name) + .order_by(func.count(Item.id).desc()) + .limit(7) + .all() + ) + + expense_summary = get_admin_expense_summary() + expiring_lists = get_admin_expiring_lists() + process = psutil.Process(os.getpid()) + app_mem = process.memory_info().rss // (1024 * 1024) + + db_engine = db.engine + db_info = { + "engine": db_engine.name, + "version": getattr(db_engine.dialect, "server_version_info", None), + "url": str(db_engine.url).split("?")[0], + } + + inspector = inspect(db_engine) + table_count = len(inspector.get_table_names()) + record_total = get_total_records() + uptime_minutes = int( + (datetime.now(timezone.utc) - app_start_time).total_seconds() // 60 + ) + + month_options = get_active_months_query() + + return render_template( + "admin/admin_panel.html", + user_count=user_count, + list_count=list_count, + item_count=item_count, + purchased_items_count=purchased_items_count, + not_purchased_count=not_purchased_count, + items_with_notes=items_with_notes, + avg_hours_to_purchase=avg_hours_to_purchase, + avg_list_expense=avg_list_expense, + avg_per_week=avg_per_week, + avg_per_month=avg_per_month, + avg_per_year=avg_per_year, + enriched_lists=enriched_lists, + top_products=top_products, + expense_summary=expense_summary, + now=now, + python_version=sys.version, + system_info=platform.platform(), + app_memory=f"{app_mem} MB", + db_info=db_info, + table_count=table_count, + record_total=record_total, + uptime_minutes=uptime_minutes, + timedelta=timedelta, + show_all=show_all, + month_str=month_str, + month_options=month_options, + ) + + +@app.route("/admin/add_user", methods=["POST"]) +@login_required +@admin_required +def add_user(): + username = request.form["username"].lower() + password = request.form["password"] + + if not username or not password: + flash("Wypełnij wszystkie pola", "danger") + return redirect(url_for("list_users")) + + if len(password) < 6: + flash("Hasło musi mieć co najmniej 6 znaków", "danger") + return redirect(url_for("list_users")) + + if User.query.filter(func.lower(User.username) == username).first(): + flash("Użytkownik o takiej nazwie już istnieje", "warning") + return redirect(url_for("list_users")) + + hashed_password = hash_password(password) + new_user = User(username=username, password_hash=hashed_password) + db.session.add(new_user) + db.session.commit() + flash("Dodano nowego użytkownika", "success") + return redirect(url_for("list_users")) + + +@app.route("/admin/users") +@login_required +@admin_required +def list_users(): + users = User.query.order_by(User.id.asc()).all() + + user_data = [] + for user in users: + list_count = ShoppingList.query.filter_by(owner_id=user.id).count() + item_count = Item.query.filter_by(added_by=user.id).count() + receipt_count = Receipt.query.filter_by(uploaded_by=user.id).count() + + user_data.append( + { + "user": user, + "list_count": list_count, + "item_count": item_count, + "receipt_count": receipt_count, + } + ) + + total_users = len(users) + + return render_template( + "admin/user_management.html", + user_data=user_data, + total_users=total_users, + ) + + +@app.route("/admin/change_password/", methods=["POST"]) +@login_required +@admin_required +def reset_password(user_id): + user = User.query.get_or_404(user_id) + new_password = request.form["password"] + + if not new_password: + flash("Podaj nowe hasło", "danger") + return redirect(url_for("list_users")) + + user.password_hash = hash_password(new_password) + db.session.commit() + flash(f"Hasło dla użytkownika {user.username} zostało zaktualizowane", "success") + return redirect(url_for("list_users")) + + +@app.route("/admin/delete_user/") +@login_required +@admin_required +def delete_user(user_id): + user = User.query.get_or_404(user_id) + + if user.is_admin: + flash("Nie można usunąć konta administratora.", "warning") + return redirect(url_for("list_users")) + + admin_user = User.query.filter_by(is_admin=True).first() + if not admin_user: + flash("Brak konta administratora do przeniesienia zawartości.", "danger") + return redirect(url_for("list_users")) + + lists_owned = ShoppingList.query.filter_by(owner_id=user.id).count() + + if lists_owned > 0: + ShoppingList.query.filter_by(owner_id=user.id).update( + {"owner_id": admin_user.id} + ) + Receipt.query.filter_by(uploaded_by=user.id).update( + {"uploaded_by": admin_user.id} + ) + Item.query.filter_by(added_by=user.id).update({"added_by": admin_user.id}) + db.session.commit() + flash( + f"Użytkownik '{user.username}' został usunięty, a jego zawartość przeniesiona na administratora.", + "success", + ) + else: + flash( + f"Użytkownik '{user.username}' został usunięty. Nie posiadał żadnych list zakupowych.", + "info", + ) + + db.session.delete(user) + db.session.commit() + + return redirect(url_for("list_users")) + + +@app.route("/admin/receipts", methods=["GET"]) +@app.route("/admin/receipts/", methods=["GET"]) +@login_required +@admin_required +def admin_receipts(list_id=None): + try: + page, per_page = get_page_args(default_per_page=24, max_per_page=200) + + if list_id is not None: + all_receipts = ( + Receipt.query.options(joinedload(Receipt.uploaded_by_user)) + .filter_by(list_id=list_id) + .order_by(Receipt.uploaded_at.desc()) + .all() + ) + receipts_paginated, total_items, total_pages = paginate_items( + all_receipts, page, per_page + ) + orphan_files = [] + id = list_id + else: + all_filenames = { + r.filename for r in Receipt.query.with_entities(Receipt.filename).all() + } + + pagination = ( + Receipt.query.options(joinedload(Receipt.uploaded_by_user)) + .order_by(Receipt.uploaded_at.desc()) + .paginate(page=page, per_page=per_page, error_out=False) + ) + + receipts_paginated = pagination.items + total_pages = pagination.pages + id = "all" + + upload_folder = app.config["UPLOAD_FOLDER"] + files_on_disk = set(os.listdir(upload_folder)) + orphan_files = [ + f + for f in files_on_disk + if f.endswith(".webp") + and f not in all_filenames + and f.startswith("list_") + ] + + except ValueError: + flash("Nieprawidłowe ID listy.", "danger") + return redirect(url_for("admin_panel")) + + total_filesize = db.session.query(func.sum(Receipt.filesize)).scalar() or 0 + page_filesize = sum(r.filesize or 0 for r in receipts_paginated) + + query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) + + return render_template( + "admin/receipts.html", + receipts=receipts_paginated, + orphan_files=orphan_files, + orphan_files_count=len(orphan_files), + page=page, + per_page=per_page, + total_pages=total_pages, + id=id, + query_string=query_string, + total_filesize=total_filesize, + page_filesize=page_filesize, + ) + + +@app.route("/admin/rotate_receipt/") +@login_required +@admin_required +def rotate_receipt(receipt_id): + try: + rotate_receipt_by_id(receipt_id) + recalculate_filesizes(receipt_id) + flash("Obrócono paragon", "success") + except FileNotFoundError: + flash("Plik nie istnieje", "danger") + except Exception as e: + flash(f"Błąd przy obracaniu: {str(e)}", "danger") + + return redirect(request.referrer or url_for("admin_receipts", id="all")) + + +@app.route("/admin/delete_receipt/") +@app.route("/admin/delete_receipt/orphan/") +@login_required +@admin_required +def delete_receipt(receipt_id=None, filename=None): + if filename: # tryb orphan + safe_filename = os.path.basename(filename) + if Receipt.query.filter_by(filename=safe_filename).first(): + flash("Nie można usunąć pliku powiązanego z bazą!", "danger") + else: + file_path = os.path.join(app.config["UPLOAD_FOLDER"], safe_filename) + if os.path.exists(file_path): + try: + os.remove(file_path) + flash(f"Usunięto plik: {safe_filename}", "success") + except Exception as e: + flash(f"Błąd przy usuwaniu pliku: {e}", "danger") + else: + flash("Plik już nie istnieje.", "warning") + return redirect(url_for("admin_receipts", id="all")) + + try: + delete_receipt_by_id(receipt_id) + flash("Paragon usunięty", "success") + except Exception as e: + flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger") + + return redirect(request.referrer or url_for("admin_receipts", id="all")) + + +@app.route("/admin/rename_receipt/") +@login_required +@admin_required +def rename_receipt(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + old_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + + if not os.path.exists(old_path): + flash("Plik nie istnieje", "danger") + return redirect(request.referrer) + + new_filename = generate_new_receipt_filename(receipt.list_id) + new_path = os.path.join(app.config["UPLOAD_FOLDER"], new_filename) + + try: + os.rename(old_path, new_path) + receipt.filename = new_filename + db.session.flush() + recalculate_filesizes(receipt.id) + db.session.commit() + flash("Zmieniono nazwę pliku", "success") + except Exception as e: + flash(f"Błąd przy zmianie nazwy: {str(e)}", "danger") + + return redirect(request.referrer or url_for("admin_receipts", id="all")) + + +@app.route("/admin/generate_receipt_hash/") +@login_required +@admin_required +def generate_receipt_hash(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + if receipt.file_hash: + flash("Hash już istnieje", "info") + return redirect(request.referrer) + + file_path = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + if not os.path.exists(file_path): + flash("Plik nie istnieje", "danger") + return redirect(request.referrer) + + try: + with open(file_path, "rb") as f: + file_hash = hashlib.sha256(f.read()).hexdigest() + receipt.file_hash = file_hash + db.session.commit() + flash("Hash wygenerowany", "success") + except Exception as e: + flash(f"Błąd przy generowaniu hasha: {e}", "danger") + + return redirect(request.referrer) + + +@app.route("/admin/delete_list", methods=["POST"]) +@login_required +@admin_required +def admin_delete_list(): + ids = request.form.getlist("list_ids") + single_id = request.form.get("single_list_id") + if single_id: + ids.append(single_id) + + for list_id in ids: + lst = db.session.get(ShoppingList, int(list_id)) + if lst: + delete_receipts_for_list(lst.id) + Receipt.query.filter_by(list_id=lst.id).delete() + Item.query.filter_by(list_id=lst.id).delete() + Expense.query.filter_by(list_id=lst.id).delete() + db.session.delete(lst) + + db.session.commit() + flash(f"Usunięto {len(ids)} list(e/y)", "success") + return redirect(request.referrer or url_for("admin_panel")) + + +@app.route("/admin/edit_list/", methods=["GET", "POST"]) +@login_required +@admin_required +def edit_list(list_id): + shopping_list = db.session.get( + ShoppingList, + list_id, + options=[ + joinedload(ShoppingList.expenses), + joinedload(ShoppingList.receipts), + joinedload(ShoppingList.owner), + joinedload(ShoppingList.items), + joinedload(ShoppingList.categories), + ], + ) + permitted_users = ( + db.session.query(User) + .join(ListPermission, ListPermission.user_id == User.id) + .filter(ListPermission.list_id == shopping_list.id) + .order_by(User.username.asc()) + .all() + ) + + if shopping_list is None: + abort(404) + + total_expense = get_total_expense_for_list(shopping_list.id) + categories = Category.query.order_by(Category.name.asc()).all() + selected_categories_ids = {c.id for c in shopping_list.categories} + + if request.method == "POST": + action = request.form.get("action") + + if action == "save": + new_title = request.form.get("title", "").strip() + new_amount_str = request.form.get("amount") + is_archived = "archived" in request.form + is_public = "public" in request.form + is_temporary = "temporary" in request.form + new_owner_id = request.form.get("owner_id") + expires_date = request.form.get("expires_date") + expires_time = request.form.get("expires_time") + + if new_title: + shopping_list.title = new_title + + shopping_list.is_archived = is_archived + shopping_list.is_public = is_public + shopping_list.is_temporary = is_temporary + + if expires_date and expires_time: + try: + combined = f"{expires_date} {expires_time}" + dt = datetime.strptime(combined, "%Y-%m-%d %H:%M") + shopping_list.expires_at = dt.replace(tzinfo=timezone.utc) + except ValueError: + flash("Niepoprawna data lub godzina wygasania", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + else: + shopping_list.expires_at = None + + if new_owner_id: + try: + new_owner_id_int = int(new_owner_id) + user_obj = db.session.get(User, new_owner_id_int) + if user_obj: + shopping_list.owner_id = new_owner_id_int + Item.query.filter_by(list_id=list_id).update( + {"added_by": new_owner_id_int} + ) + Receipt.query.filter_by(list_id=list_id).update( + {"uploaded_by": new_owner_id_int} + ) + else: + flash("Wybrany użytkownik nie istnieje", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + except ValueError: + flash("Niepoprawny ID użytkownika", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + + if new_amount_str: + try: + new_amount = float(new_amount_str) + for expense in shopping_list.expenses: + db.session.delete(expense) + db.session.commit() + db.session.add(Expense(list_id=list_id, amount=new_amount)) + except ValueError: + flash("Niepoprawna kwota", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + + created_month = request.form.get("created_month") + if created_month: + try: + year, month = map(int, created_month.split("-")) + shopping_list.created_at = datetime( + year, month, 1, tzinfo=timezone.utc + ) + except ValueError: + flash("Nieprawidłowy format miesiąca", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + + update_list_categories_from_form(shopping_list, request.form) + db.session.commit() + flash("Zapisano zmiany listy", "success") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "add_item": + item_name = request.form.get("item_name", "").strip() + quantity_str = request.form.get("quantity", "1") + + if not item_name: + flash("Podaj nazwę produktu", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + + try: + quantity = max(1, int(quantity_str)) + except ValueError: + quantity = 1 + + db.session.add( + Item( + list_id=list_id, + name=item_name, + quantity=quantity, + added_by=current_user.id, + ) + ) + + exists = ( + db.session.query(SuggestedProduct) + .filter(func.lower(SuggestedProduct.name) == item_name.lower()) + .first() + ) + if not exists: + db.session.add(SuggestedProduct(name=item_name)) + + db.session.commit() + flash("Dodano produkt", "success") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "delete_item": + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + db.session.delete(item) + db.session.commit() + flash("Usunięto produkt", "success") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "toggle_purchased": + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + item.purchased = not item.purchased + if item.purchased: + item.not_purchased = False + item.not_purchased_reason = None + item.purchased_at = utcnow() + else: + item.purchased_at = None + db.session.commit() + flash("Zmieniono status oznaczenia produktu", "success") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "mark_not_purchased": + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + item.not_purchased = True + item.purchased = False + item.purchased_at = None + db.session.commit() + flash("Oznaczono produkt jako niekupione", "success") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "unmark_not_purchased": + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + item.not_purchased = False + item.not_purchased_reason = None + item.purchased = False + item.purchased_at = None + db.session.commit() + flash("Przywrócono produkt do listy", "success") + return redirect(url_for("edit_list", list_id=list_id)) + + elif action == "edit_quantity": + item = get_valid_item_or_404(request.form.get("item_id"), list_id) + try: + new_quantity = int(request.form.get("quantity")) + if new_quantity > 0: + item.quantity = new_quantity + db.session.commit() + flash("Zmieniono ilość produktu", "success") + except ValueError: + flash("Nieprawidłowa ilość", "danger") + return redirect(url_for("edit_list", list_id=list_id)) + + users = User.query.all() + items = shopping_list.items + receipts = shopping_list.receipts + + return render_template( + "admin/edit_list.html", + list=shopping_list, + total_expense=total_expense, + users=users, + items=items, + receipts=receipts, + categories=categories, + selected_categories=selected_categories_ids, + permitted_users=permitted_users, + ) + + +@app.route("/admin/products") +@login_required +@admin_required +def list_products(): + page, per_page = get_page_args() + + all_items = ( + Item.query.options(joinedload(Item.added_by_user)) + .order_by(Item.id.desc()) + .all() + ) + + seen_names = set() + unique_items = [] + for item in all_items: + key = normalize_name(item.name) + if key not in seen_names: + unique_items.append(item) + seen_names.add(key) + + usage_results = ( + db.session.query( + func.lower(Item.name).label("name"), + func.count(func.distinct(Item.list_id)).label("usage_count"), + ) + .group_by(func.lower(Item.name)) + .all() + ) + usage_counts = {row.name: row.usage_count for row in usage_results} + + items, total_items, total_pages = paginate_items(unique_items, page, per_page) + + user_ids = {item.added_by for item in items if item.added_by} + users = User.query.filter(User.id.in_(user_ids)).all() if user_ids else [] + users_dict = {u.id: u.username for u in users} + + suggestions = SuggestedProduct.query.all() + all_suggestions_dict = { + normalize_name(s.name): s for s in suggestions if s.name and s.name.strip() + } + + used_suggestion_names = {normalize_name(i.name) for i in unique_items} + + suggestions_dict = { + name: all_suggestions_dict[name] + for name in used_suggestion_names + if name in all_suggestions_dict + } + + orphan_suggestions = [ + s + for name, s in all_suggestions_dict.items() + if name not in used_suggestion_names + ] + + query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) + synced_names = set(suggestions_dict.keys()) + + return render_template( + "admin/list_products.html", + items=items, + users_dict=users_dict, + suggestions_dict=suggestions_dict, + orphan_suggestions=orphan_suggestions, + page=page, + per_page=per_page, + total_pages=total_pages, + query_string=query_string, + total_items=total_items, + usage_counts=usage_counts, + synced_names=synced_names, + ) + + +@app.route("/admin/sync_suggestion/", methods=["POST"]) +@login_required +def sync_suggestion_ajax(item_id): + if not current_user.is_admin: + return jsonify({"success": False, "message": "Brak uprawnień"}), 403 + + item = Item.query.get_or_404(item_id) + + existing = SuggestedProduct.query.filter( + func.lower(SuggestedProduct.name) == item.name.lower() + ).first() + if not existing: + new_suggestion = SuggestedProduct(name=item.name) + db.session.add(new_suggestion) + db.session.commit() + return jsonify( + { + "success": True, + "message": f"Utworzono sugestię dla produktu: {item.name}", + } + ) + else: + return jsonify( + { + "success": True, + "message": f"Sugestia dla produktu „{item.name}” już istnieje.", + } + ) + + +@app.route("/admin/delete_suggestion/", methods=["POST"]) +@login_required +def delete_suggestion_ajax(suggestion_id): + if not current_user.is_admin: + return jsonify({"success": False, "message": "Brak uprawnień"}), 403 + + suggestion = SuggestedProduct.query.get_or_404(suggestion_id) + db.session.delete(suggestion) + db.session.commit() + + return jsonify({"success": True, "message": "Sugestia została usunięta."}) + + +@app.route("/admin/promote_user/") +@login_required +@admin_required +def promote_user(user_id): + user = User.query.get_or_404(user_id) + user.is_admin = True + db.session.commit() + flash(f"Użytkownik {user.username} został ustawiony jako admin.", "success") + return redirect(url_for("list_users")) + + +@app.route("/admin/demote_user/") +@login_required +@admin_required +def demote_user(user_id): + user = User.query.get_or_404(user_id) + + if user.id == current_user.id: + flash("Nie możesz zdegradować samego siebie!", "danger") + return redirect(url_for("list_users")) + + admin_count = User.query.filter_by(is_admin=True).count() + if admin_count <= 1 and user.is_admin: + flash( + "Nie można zdegradować. Musi pozostać co najmniej jeden administrator.", + "danger", + ) + return redirect(url_for("list_users")) + + user.is_admin = False + db.session.commit() + flash(f"Użytkownik {user.username} został zdegradowany.", "success") + return redirect(url_for("list_users")) + + +@app.route("/admin/crop_receipt", methods=["POST"]) +@login_required +@admin_required +def crop_receipt_admin(): + receipt_id = request.form.get("receipt_id") + file = request.files.get("cropped_image") + result = handle_crop_receipt(receipt_id, file) + return jsonify(result) + + +@app.route("/admin/recalculate_filesizes") +@login_required +@admin_required +def recalculate_filesizes_all(): + updated, unchanged, not_found = recalculate_filesizes() + flash( + f"Zaktualizowano: {updated}, bez zmian: {unchanged}, brak pliku: {not_found}", + "success", + ) + return redirect(url_for("admin_receipts", id="all")) + + +@app.route("/admin/edit_categories", methods=["GET", "POST"]) +@login_required +@admin_required +def admin_edit_categories(): + page, per_page = get_page_args(default_per_page=50, max_per_page=200) + + lists_query = ShoppingList.query.options( + joinedload(ShoppingList.categories), + joinedload(ShoppingList.items), + joinedload(ShoppingList.owner), + ).order_by(ShoppingList.created_at.desc()) + + pagination = lists_query.paginate(page=page, per_page=per_page, error_out=False) + lists = pagination.items + + categories = Category.query.order_by(Category.name.asc()).all() + + for l in lists: + l.total_count = len(l.items) + l.owner_name = l.owner.username if l.owner else "?" + l.category_count = len(l.categories) + + if request.method == "POST": + for l in lists: + selected_ids = request.form.getlist(f"categories_{l.id}") + l.categories.clear() + if selected_ids: + cats = Category.query.filter(Category.id.in_(selected_ids)).all() + l.categories.extend(cats) + db.session.commit() + flash("Zaktualizowano kategorie dla wybranych list", "success") + return redirect(url_for("admin_edit_categories", page=page, per_page=per_page)) + + query_string = urlencode({k: v for k, v in request.args.items() if k != "page"}) + + return render_template( + "admin/edit_categories.html", + lists=lists, + categories=categories, + page=page, + per_page=per_page, + total_pages=pagination.pages, + total_items=pagination.total, + query_string=query_string, + ) + + +@app.route("/admin/edit_categories//save", methods=["POST"]) +@login_required +@admin_required +def admin_edit_categories_save(list_id): + l = db.session.get(ShoppingList, list_id) + if not l: + return jsonify(ok=False, error="not_found"), 404 + + data = request.get_json(silent=True) or {} + ids = data.get("category_ids", []) + + try: + ids = [int(x) for x in ids] + except (TypeError, ValueError): + return jsonify(ok=False, error="bad_ids"), 400 + + l.categories.clear() + if ids: + cats = Category.query.filter(Category.id.in_(ids)).all() + l.categories.extend(cats) + + db.session.commit() + return jsonify(ok=True, count=len(l.categories)), 200 + + +@app.route("/admin/list_items/") +@login_required +@admin_required +def admin_list_items_json(list_id): + l = db.session.get(ShoppingList, list_id) + if not l: + return jsonify({"error": "Lista nie istnieje"}), 404 + + items = [ + { + "name": item.name, + "quantity": item.quantity, + "purchased": item.purchased, + "not_purchased": item.not_purchased, + } + for item in l.items + ] + + purchased_count = sum(1 for item in l.items if item.purchased) + total_expense = sum(exp.amount for exp in l.expenses) + + return jsonify( + { + "title": l.title, + "items": items, + "total_count": len(l.items), + "purchased_count": purchased_count, + "total_expense": round(total_expense, 2), + } + ) + + +@app.route("/admin/add_suggestion", methods=["POST"]) +@login_required +@admin_required +def add_suggestion(): + name = request.form.get("suggestion_name", "").strip() + + if not name: + flash("Nazwa nie może być pusta", "warning") + return redirect(url_for("list_products")) + + existing = db.session.query(SuggestedProduct).filter_by(name=name).first() + if existing: + flash("Sugestia już istnieje", "warning") + else: + new_suggestion = SuggestedProduct(name=name) + db.session.add(new_suggestion) + db.session.commit() + flash("Dodano sugestię", "success") + + return redirect(url_for("list_products")) + + +@app.route("/admin/user-suggestions", methods=["GET"]) +@login_required +@admin_required +def admin_user_suggestions(): + q = (request.args.get("q") or "").strip().lower().lstrip('@') + query = User.query.order_by(func.lower(User.username).asc()) + if q: + query = query.filter(func.lower(User.username).like(f"{q}%")) + rows = query.limit(20).all() + return jsonify({"users": [u.username for u in rows]}) + + +@app.route("/admin/lists-access", methods=["GET", "POST"]) +@app.route("/admin/lists-access/", methods=["GET", "POST"]) +@login_required +@admin_required +def admin_lists_access(list_id=None): + try: + page = int(request.args.get("page", 1)) + except ValueError: + page = 1 + try: + per_page = int(request.args.get("per_page", 25)) + except ValueError: + per_page = 25 + per_page = max(1, min(100, per_page)) + + q = ShoppingList.query.options(db.joinedload(ShoppingList.owner)).order_by( + ShoppingList.created_at.desc() + ) + + if list_id is not None: + target_list = db.session.get(ShoppingList, list_id) + if not target_list: + flash("Lista nie istnieje.", "danger") + return redirect(url_for("admin_lists_access")) + lists = [target_list] + list_ids = [list_id] + pagination = None + else: + pagination = q.paginate(page=page, per_page=per_page, error_out=False) + lists = pagination.items + list_ids = [l.id for l in lists] + + wants_json = ( + "application/json" in (request.headers.get("Accept") or "") + or request.headers.get("X-Requested-With") == "fetch" + ) + + if request.method == "POST": + action = request.form.get("action") + target_list_id = request.form.get("target_list_id", type=int) or list_id + + if action == "grant" and target_list_id: + login = (request.form.get("grant_username") or "").strip().lower().lstrip('@') + l = db.session.get(ShoppingList, target_list_id) + if not l: + if wants_json: + return jsonify(ok=False, error="list_not_found"), 404 + flash("Lista nie istnieje.", "danger") + return redirect(request.url) + u = User.query.filter(func.lower(User.username) == login).first() + if not u: + if wants_json: + return jsonify(ok=False, error="user_not_found"), 404 + flash("Użytkownik nie istnieje.", "danger") + return redirect(request.url) + if u.id == l.owner_id: + if wants_json: + return jsonify(ok=False, error="owner"), 409 + flash("Nie można nadawać uprawnień właścicielowi listy.", "danger") + return redirect(request.url) + + exists = ( + db.session.query(ListPermission.id) + .filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id) + .first() + ) + if exists: + if wants_json: + return jsonify(ok=False, error="exists"), 409 + flash("Ten użytkownik już ma dostęp.", "info") + return redirect(request.url) + + db.session.add(ListPermission(list_id=l.id, user_id=u.id)) + db.session.commit() + if wants_json: + return jsonify(ok=True, user={"id": u.id, "username": u.username}) + flash(f"Nadano dostęp „{u.username}” do listy #{l.id}.", "success") + return redirect(request.url) + + if action == "revoke" and target_list_id: + uid = request.form.get("revoke_user_id", type=int) + if uid: + ListPermission.query.filter_by(list_id=target_list_id, user_id=uid).delete() + db.session.commit() + if wants_json: + return jsonify(ok=True, removed_user_id=uid) + flash("Odebrano dostęp użytkownikowi.", "success") + return redirect(request.url) + + + perms = ( + db.session.query( + ListPermission.list_id, + User.id.label("uid"), + User.username.label("uname"), + ) + .join(User, User.id == ListPermission.user_id) + .filter(ListPermission.list_id.in_(list_ids)) + .order_by(User.username.asc()) + .all() + ) + + permitted_by_list = {lid: [] for lid in list_ids} + for lid, uid, uname in perms: + permitted_by_list[lid].append({"id": uid, "username": uname}) + + all_usernames = [u.username for u in User.query.order_by(func.lower(User.username).asc()).limit(300).all()] + query_string = f"per_page={per_page}" + + return render_template( + "admin/lists_access.html", + lists=lists, + permitted_by_list=permitted_by_list, + page=page, + per_page=per_page, + total_pages=pagination.pages if pagination else 1, + query_string=query_string, + list_id=list_id, + all_usernames=all_usernames, + ) + + +@app.route('/healthcheck') +def healthcheck(): + header_token = request.headers.get('X-Internal-Check') + correct_token = app.config.get('HEALTHCHECK_TOKEN') + if header_token != correct_token: + abort(404) + + try: + db.session.execute(text('SELECT 1')) + db.session.commit() + response_data = {"status": "ok"} + except Exception as e: + response_data = { + "status": "waiting", + "message": "waiting for db", + "error": str(e) + } + + return response_data, 200 + + +@app.route("/admin/api-tokens", methods=["GET", "POST"]) +@login_required +@admin_required +def admin_api_tokens(): + if request.method == "POST": + action = (request.form.get("action") or "create").strip() + + if action == "create": + name = (request.form.get("name") or "").strip() + if not name: + flash("Podaj nazwę tokenu API.", "danger") + return redirect(url_for("admin_api_tokens")) + + scopes = [] + if request.form.get('scope_expenses_read'): + scopes.append('expenses:read') + if request.form.get('scope_lists_read'): + scopes.append('lists:read') + if request.form.get('scope_templates_read'): + scopes.append('templates:read') + scopes = ','.join(scopes) + + allowed = [] + if request.form.get('allow_ping'): + allowed.append('/api/ping') + if request.form.get('allow_latest_expenses'): + allowed.append('/api/expenses/latest') + if request.form.get('allow_expenses_summary'): + allowed.append('/api/expenses/summary') + if request.form.get('allow_lists'): + allowed.extend(['/api/lists', '/api/lists//expenses']) + if request.form.get('allow_templates'): + allowed.append('/api/templates') + allowed_endpoints = ','.join(dict.fromkeys(allowed)) + max_limit = request.form.get('max_limit', type=int) or 100 + _, plain_token = create_api_token_record(name=name, created_by=current_user.id, scopes=scopes, allowed_endpoints=allowed_endpoints, max_limit=max_limit) + session["latest_api_token_plain"] = plain_token + session["latest_api_token_name"] = name + flash("Wygenerowano nowy token API. Skopiuj go teraz — później nie będzie widoczny w całości.", "success") + return redirect(url_for("admin_api_tokens")) + + token_id = request.form.get("token_id", type=int) + token_row = ApiToken.query.get_or_404(token_id) + + if action == "deactivate": + token_row.is_active = False + db.session.commit() + flash(f"Token „{token_row.name}” został wyłączony.", "warning") + elif action == "activate": + token_row.is_active = True + db.session.commit() + flash(f"Token „{token_row.name}” został ponownie aktywowany.", "success") + elif action == "delete": + db.session.delete(token_row) + db.session.commit() + flash(f"Token „{token_row.name}” został usunięty.", "info") + else: + flash("Nieznana akcja dla tokenu API.", "danger") + + return redirect(url_for("admin_api_tokens")) + + latest_plain_token = session.pop("latest_api_token_plain", None) + latest_api_token_name = session.pop("latest_api_token_name", None) + api_tokens = ApiToken.query.options(joinedload(ApiToken.creator)).order_by(ApiToken.created_at.desc(), ApiToken.id.desc()).all() + api_examples = [ + {'method': 'GET', 'path': '/api/ping', 'scope': 'dowolny aktywny token', 'description': 'szybki test poprawności tokenu'}, + {'method': 'GET', 'path': '/api/expenses/latest', 'scope': 'expenses:read', 'description': 'ostatnie wydatki z filtrem po datach, liście i właścicielu'}, + {'method': 'GET', 'path': '/api/expenses/summary', 'scope': 'expenses:read', 'description': 'sumy wydatków i liczba rekordów dla zakresu'}, + {'method': 'GET', 'path': '/api/lists', 'scope': 'lists:read', 'description': 'lista list z podstawowymi metadanymi'}, + {'method': 'GET', 'path': '/api/lists//expenses', 'scope': 'lists:read', 'description': 'wydatki dla konkretnej listy'}, + {'method': 'GET', 'path': '/api/templates', 'scope': 'templates:read', 'description': 'szablony przypisane do użytkownika tokenu lub wszystkie dla admina'}, + ] + + return render_template( + "admin/api_tokens.html", + api_tokens=api_tokens, + latest_plain_token=latest_plain_token, + latest_api_token_name=latest_api_token_name, + api_examples=api_examples, + ) + + +@app.route("/admin/api-docs.txt") +@login_required +@admin_required +def admin_api_docs(): + return send_from_directory( + os.path.dirname(app.root_path), + "API_OPIS.txt", + mimetype="text/plain; charset=utf-8", + as_attachment=False, + ) + + +@app.route("/admin/settings", methods=["GET", "POST"]) +@login_required +@admin_required +def admin_settings(): + categories = Category.query.order_by(Category.name.asc()).all() + + if request.method == "POST": + ocr_raw = (request.form.get("ocr_keywords") or "").strip() + set_setting("ocr_keywords", ocr_raw) + + ocr_sens = (request.form.get("ocr_sensitivity") or "").strip() + set_setting("ocr_sensitivity", ocr_sens) + + max_attempts = (request.form.get("max_login_attempts") or "").strip() + set_setting("max_login_attempts", max_attempts) + + login_window = (request.form.get("login_window_seconds") or "").strip() + if login_window: + set_setting("login_window_seconds", login_window) + + for c in categories: + field = f"color_{c.id}" + enabled_field = f"override_enabled_{c.id}" + val = (request.form.get(field) or "").strip() + override_enabled = (request.form.get(enabled_field) or "0").strip() == "1" + + existing = CategoryColorOverride.query.filter_by(category_id=c.id).first() + if override_enabled and val and re.fullmatch(r"^#[0-9A-Fa-f]{6}$", val): + if not existing: + db.session.add(CategoryColorOverride(category_id=c.id, color_hex=val)) + else: + existing.color_hex = val + else: + if existing: + db.session.delete(existing) + + db.session.commit() + flash("Zapisano ustawienia.", "success") + return redirect(url_for("admin_settings")) + + override_rows = CategoryColorOverride.query.filter( + CategoryColorOverride.category_id.in_([c.id for c in categories]) + ).all() + overrides = {row.category_id: row.color_hex for row in override_rows} + auto_colors = {c.id: category_to_color(c.name) for c in categories} + effective_colors = { + c.id: (overrides.get(c.id) or auto_colors[c.id]) for c in categories + } + + current_ocr = get_setting("ocr_keywords", "") + + ocr_sensitivity = get_int_setting("ocr_sensitivity", 5) + max_login_attempts = get_int_setting("max_login_attempts", 10) + login_window_seconds = get_int_setting("login_window_seconds", 3600) + + return render_template( + "admin/settings.html", + categories=categories, + overrides=overrides, + auto_colors=auto_colors, + effective_colors=effective_colors, + current_ocr=current_ocr, + ocr_sensitivity=ocr_sensitivity, + max_login_attempts=max_login_attempts, + login_window_seconds=login_window_seconds, + ) + + +@app.route("/robots.txt") +def robots_txt(): + content = ( + "User-agent: *\nDisallow: /" + if app.config.get("DISABLE_ROBOTS") + else "User-agent: *\nAllow: /" + ) + return content, 200, {"Content-Type": "text/plain"} + + +@app.route('/admin/list//duplicate', methods=['POST']) +@login_required +@admin_required +def admin_duplicate_list(list_id): + source_list = ShoppingList.query.options(joinedload(ShoppingList.items), joinedload(ShoppingList.categories)).get_or_404(list_id) + owner = source_list.owner or current_user + new_list = duplicate_list_for_schedule(source_list, scheduled_for=datetime.now(timezone.utc), owner=owner, title=f'{source_list.title} (Kopia)') + log_list_activity(new_list.id, 'list_duplicated', actor=current_user, details=f'Źródło #{source_list.id}') + db.session.commit() + flash(f'Zduplikowano listę #{source_list.id} do nowej listy #{new_list.id}.', 'success') + return redirect(url_for('admin_panel')) + + +@app.route('/admin/templates', methods=['GET', 'POST']) +@login_required +@admin_required +def admin_templates(): + if request.method == 'POST': + action = (request.form.get('action') or 'create_manual').strip() + if action == 'create_manual': + name = (request.form.get('name') or '').strip() + description = (request.form.get('description') or '').strip() + raw_items = (request.form.get('items_text') or '').splitlines() + if not name: + flash('Podaj nazwę szablonu.', 'danger') + return redirect(url_for('admin_templates')) + template = ListTemplate(name=name, description=description, created_by=current_user.id, is_active=True) + db.session.add(template) + db.session.flush() + pos = 1 + for line in raw_items: + line = line.strip() + if not line: + continue + qty = 1 + item_name = line + match = re.match(r'^(.*?)(?:\s+[xX](\d+))?$', line) + if match: + item_name = (match.group(1) or '').strip() or line + if match.group(2): + qty = max(1, int(match.group(2))) + db.session.add(ListTemplateItem(template_id=template.id, name=item_name, quantity=qty, position=pos)) + pos += 1 + db.session.commit() + flash(f'Utworzono szablon „{template.name}”.', 'success') + return redirect(url_for('admin_templates')) + if action == 'create_from_list': + list_id = request.form.get('source_list_id', type=int) + source_list = ShoppingList.query.options(joinedload(ShoppingList.items)).get_or_404(list_id) + template = create_template_from_list(source_list, created_by=current_user.id, name=(request.form.get('template_name') or '').strip() or None, description=(request.form.get('description') or '').strip() or None) + flash(f'Utworzono szablon z listy „{source_list.title}”.', 'success') + return redirect(url_for('admin_templates')) + if action in {'toggle', 'delete', 'instantiate'}: + template = ListTemplate.query.options(joinedload(ListTemplate.items)).get_or_404(request.form.get('template_id', type=int)) + if action == 'toggle': + template.is_active = not template.is_active + db.session.commit() + flash(f'Zmieniono status szablonu „{template.name}”.', 'info') + elif action == 'delete': + db.session.delete(template) + db.session.commit() + flash(f'Usunięto szablon „{template.name}”.', 'warning') + elif action == 'instantiate': + owner = User.query.get(request.form.get('owner_id', type=int) or current_user.id) or current_user + new_list = create_list_from_template(template, owner=owner, title=(request.form.get('title') or '').strip() or None) + log_list_activity(new_list.id, 'template_created', actor=current_user, details=f'Admin utworzył z szablonu: {template.name}') + db.session.commit() + flash(f'Utworzono listę #{new_list.id} z szablonu.', 'success') + return redirect(url_for('admin_templates')) + + templates = ListTemplate.query.options(joinedload(ListTemplate.creator), joinedload(ListTemplate.items)).order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).all() + source_lists = ShoppingList.query.order_by(ShoppingList.created_at.desc()).limit(100).all() + users = User.query.order_by(User.username.asc()).all() + return render_template('admin/templates.html', templates=templates, source_lists=source_lists, users=users) diff --git a/shopping_app/routes_main.py b/shopping_app/routes_main.py new file mode 100644 index 0000000..d86dab1 --- /dev/null +++ b/shopping_app/routes_main.py @@ -0,0 +1,880 @@ +from .deps import * +from .app_setup import * +from .models import * +from .helpers import * + +@app.route("/") +def main_page(): + perm_subq = ( + user_permission_subq(current_user.id) if current_user.is_authenticated else None + ) + + now = datetime.now(timezone.utc) + + month_param = request.args.get("m", None) + start = end = None + + if month_param in (None, ""): + # domyślnie: bieżący miesiąc + month_str = now.strftime("%Y-%m") + start = datetime(now.year, now.month, 1, tzinfo=timezone.utc) + end = (start + timedelta(days=31)).replace(day=1) + elif month_param == "all": + month_str = "all" + start = end = None + else: + month_str = month_param + try: + year, month = map(int, month_str.split("-")) + start = datetime(year, month, 1, tzinfo=timezone.utc) + end = (start + timedelta(days=31)).replace(day=1) + except ValueError: + # jeśli m ma zły format – pokaż wszystko + month_str = "all" + start = end = None + + def date_filter(query): + if start and end: + query = query.filter( + ShoppingList.created_at >= start, ShoppingList.created_at < end + ) + return query + + if current_user.is_authenticated: + user_lists = ( + date_filter( + ShoppingList.query.filter( + ShoppingList.owner_id == current_user.id, + ShoppingList.is_archived == False, + (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), + ) + ) + .order_by(ShoppingList.created_at.desc()) + .all() + ) + + archived_lists = ( + ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=True) + .order_by(ShoppingList.created_at.desc()) + .all() + ) + + # publiczne cudze + udzielone mi (po list_permission) + public_lists = ( + date_filter( + ShoppingList.query.filter( + ShoppingList.owner_id != current_user.id, + ShoppingList.is_archived == False, + (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), + or_( + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ), + ) + ) + .order_by(ShoppingList.created_at.desc()) + .all() + ) + accessible_lists = public_lists # alias do szablonu: publiczne + udostępnione + else: + user_lists = [] + archived_lists = [] + public_lists = ( + date_filter( + ShoppingList.query.filter( + ShoppingList.is_public == True, + (ShoppingList.expires_at == None) | (ShoppingList.expires_at > now), + ShoppingList.is_archived == False, + ) + ) + .order_by(ShoppingList.created_at.desc()) + .all() + ) + accessible_lists = public_lists # dla gościa = tylko publiczne + + # Zakres miesięcy do selektora + if current_user.is_authenticated: + visible_lists_query = ShoppingList.query.filter( + or_( + ShoppingList.owner_id == current_user.id, + ShoppingList.is_public == True, + ShoppingList.id.in_(perm_subq), + ) + ) + else: + visible_lists_query = ShoppingList.query.filter(ShoppingList.is_public == True) + + month_options = get_active_months_query(visible_lists_query) + + # Statystyki dla wszystkich widocznych sekcji + all_lists = user_lists + accessible_lists + archived_lists + all_ids = [l.id for l in all_lists] + + if all_ids: + stats = ( + db.session.query( + Item.list_id, + func.count(Item.id).label("total_count"), + func.sum(case((((Item.purchased == True) & (Item.not_purchased == False)), 1), else_=0)).label( + "purchased_count" + ), + func.sum(case((Item.not_purchased == True, 1), else_=0)).label( + "not_purchased_count" + ), + ) + .filter(Item.list_id.in_(all_ids)) + .group_by(Item.list_id) + .all() + ) + stats_map = { + s.list_id: ( + s.total_count or 0, + s.purchased_count or 0, + s.not_purchased_count or 0, + ) + for s in stats + } + + latest_expenses_map = dict( + db.session.query( + Expense.list_id, func.coalesce(func.sum(Expense.amount), 0) + ) + .filter(Expense.list_id.in_(all_ids)) + .group_by(Expense.list_id) + .all() + ) + + for l in all_lists: + total_count, purchased_count, not_purchased_count = stats_map.get( + l.id, (0, 0, 0) + ) + l.total_count = total_count + l.purchased_count = purchased_count + l.not_purchased_count = not_purchased_count + l.total_expense = latest_expenses_map.get(l.id, 0) + l.category_badges = [ + {"name": c.name, "color": category_color_for(c)} for c in l.categories + ] + else: + for l in all_lists: + l.total_count = 0 + l.purchased_count = 0 + l.not_purchased_count = 0 + l.total_expense = 0 + l.category_badges = [] + + def build_progress_summary(lists_): + total_lists = len(lists_) + total_products = sum(getattr(l, "total_count", 0) or 0 for l in lists_) + purchased_products = sum(getattr(l, "purchased_count", 0) or 0 for l in lists_) + not_purchased_products = sum(getattr(l, "not_purchased_count", 0) or 0 for l in lists_) + total_expense = float(sum((getattr(l, "total_expense", 0) or 0) for l in lists_)) + completion_percent = ( + (purchased_products / total_products) * 100 if total_products > 0 else 0 + ) + return { + "list_count": total_lists, + "total_products": total_products, + "purchased_products": purchased_products, + "not_purchased_products": not_purchased_products, + "remaining_products": max(total_products - purchased_products - not_purchased_products, 0), + "total_expense": round(total_expense, 2), + "completion_percent": completion_percent, + } + + user_lists_summary = build_progress_summary(user_lists) + accessible_lists_summary = build_progress_summary(accessible_lists) + + expiring_lists = get_expiring_lists_for_user(current_user.id) if current_user.is_authenticated else [] + templates = (ListTemplate.query.filter_by(is_active=True, created_by=current_user.id).order_by(ListTemplate.name.asc()).all() if current_user.is_authenticated else []) + + return render_template( + "main.html", + user_lists=user_lists, + public_lists=public_lists, + accessible_lists=accessible_lists, + archived_lists=archived_lists, + now=now, + timedelta=timedelta, + month_options=month_options, + selected_month=month_str, + expiring_lists=expiring_lists, + templates=templates, + user_lists_summary=user_lists_summary, + accessible_lists_summary=accessible_lists_summary, + ) + + +@app.route("/system-auth", methods=["GET", "POST"]) +def system_auth(): + if ( + current_user.is_authenticated + or request.cookies.get("authorized") == AUTHORIZED_COOKIE_VALUE + ): + flash("Jesteś już zalogowany lub autoryzowany.", "info") + return redirect(url_for("main_page")) + + ip = request.access_route[0] + next_page = request.args.get("next") or url_for("main_page") + + if is_ip_blocked(ip): + flash( + "Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", + "danger", + ) + return render_template("system_auth.html"), 403 + + if request.method == "POST": + if request.form["password"] == SYSTEM_PASSWORD: + reset_failed_attempts(ip) + resp = redirect(next_page) + return set_authorized_cookie(resp) + else: + register_failed_attempt(ip) + if is_ip_blocked(ip): + flash( + "Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.", + "danger", + ) + return render_template("system_auth.html"), 403 + remaining = attempts_remaining(ip) + flash(f"Nieprawidłowe hasło. Pozostało {remaining} prób.", "warning") + + return render_template("system_auth.html") + + +@app.route("/edit_my_list/", methods=["GET", "POST"]) +@login_required +def edit_my_list(list_id): + # --- Pobranie listy i weryfikacja właściciela --- + l = db.session.get(ShoppingList, list_id) + if l is None: + abort(404) + if l.owner_id != current_user.id: + abort(403, description="Nie jesteś właścicielem tej listy.") + + # Dane do widoku + receipts = ( + Receipt.query.filter_by(list_id=list_id) + .order_by(Receipt.uploaded_at.desc()) + .all() + ) + categories = Category.query.order_by(Category.name.asc()).all() + selected_categories_ids = {c.id for c in l.categories} + + next_page = request.args.get("next") or request.referrer + wants_json = ( + "application/json" in (request.headers.get("Accept") or "") + or request.headers.get("X-Requested-With") == "fetch" + ) + + if request.method == "POST": + action = request.form.get("action") + + # --- Nadanie dostępu (grant) --- + if action == "grant": + grant_username = (request.form.get("grant_username") or "").strip().lower() + if not grant_username: + if wants_json: + return jsonify(ok=False, error="empty"), 400 + flash("Podaj nazwę użytkownika do nadania dostępu.", "danger") + return redirect(next_page or request.url) + + u = User.query.filter(func.lower(User.username) == grant_username).first() + if not u: + if wants_json: + return jsonify(ok=False, error="not_found"), 404 + flash("Użytkownik nie istnieje.", "danger") + return redirect(next_page or request.url) + if u.id == current_user.id: + if wants_json: + return jsonify(ok=False, error="owner"), 409 + flash("Jesteś właścicielem tej listy.", "info") + return redirect(next_page or request.url) + + exists = ( + db.session.query(ListPermission.id) + .filter( + ListPermission.list_id == l.id, + ListPermission.user_id == u.id, + ) + .first() + ) + if not exists: + db.session.add(ListPermission(list_id=l.id, user_id=u.id)) + db.session.commit() + if wants_json: + return jsonify(ok=True, user={"id": u.id, "username": u.username}) + flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success") + else: + if wants_json: + return jsonify(ok=False, error="exists"), 409 + flash("Ten użytkownik już ma dostęp.", "info") + return redirect(next_page or request.url) + + # --- Odebranie dostępu (revoke) --- + revoke_user_id = request.form.get("revoke_user_id") + if revoke_user_id: + try: + uid = int(revoke_user_id) + except ValueError: + if wants_json: + return jsonify(ok=False, error="bad_id"), 400 + flash("Błędny identyfikator użytkownika.", "danger") + return redirect(next_page or request.url) + + ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete() + db.session.commit() + if wants_json: + return jsonify(ok=True, removed_user_id=uid) + flash("Odebrano dostęp użytkownikowi.", "success") + return redirect(next_page or request.url) + + # --- Przywracanie z archiwum --- + if "unarchive" in request.form: + l.is_archived = False + db.session.commit() + if wants_json: + return jsonify(ok=True, unarchived=True) + flash(f"Lista „{l.title}” została przywrócona.", "success") + return redirect(next_page or request.url) + + # --- Główny zapis pól formularza --- + move_to_month = request.form.get("move_to_month") + if move_to_month: + try: + year, month = map(int, move_to_month.split("-")) + l.created_at = datetime(year, month, 1, tzinfo=timezone.utc) + if not wants_json: + flash( + f"Zmieniono datę utworzenia listy na {l.created_at.strftime('%Y-%m-%d')}", + "success", + ) + except ValueError: + if not wants_json: + flash( + "Nieprawidłowy format miesiąca — zignorowano zmianę miesiąca.", + "danger", + ) + + new_title = (request.form.get("title") or "").strip() + is_public = "is_public" in request.form + is_temporary = "is_temporary" in request.form + is_archived = "is_archived" in request.form + expires_date = request.form.get("expires_date") + expires_time = request.form.get("expires_time") + + if not new_title: + if wants_json: + return jsonify(ok=False, error="title_empty"), 400 + flash("Podaj poprawny tytuł", "danger") + return redirect(next_page or request.url) + + l.title = new_title + l.is_public = is_public + l.is_temporary = is_temporary + l.is_archived = is_archived + + if expires_date and expires_time: + try: + combined = f"{expires_date} {expires_time}" + expires_dt = datetime.strptime(combined, "%Y-%m-%d %H:%M") + l.expires_at = expires_dt.replace(tzinfo=timezone.utc) + except ValueError: + if wants_json: + return jsonify(ok=False, error="bad_expiry"), 400 + flash("Błędna data lub godzina wygasania", "danger") + return redirect(next_page or request.url) + else: + l.expires_at = None + + # Kategorie (używa Twojej pomocniczej funkcji) + update_list_categories_from_form(l, request.form) + + db.session.commit() + if wants_json: + return jsonify(ok=True, saved=True) + flash("Zaktualizowano dane listy", "success") + return redirect(next_page or request.url) + + # GET: użytkownicy z dostępem + permitted_users = ( + db.session.query(User) + .join(ListPermission, ListPermission.user_id == User.id) + .where(ListPermission.list_id == l.id) + .order_by(User.username.asc()) + .all() + ) + + all_usernames = [ + u.username + for u in User.query.filter(User.id != current_user.id) + .order_by(func.lower(User.username).asc()) + .limit(300) + .all() + ] + + return render_template( + "edit_my_list.html", + list=l, + receipts=receipts, + categories=categories, + selected_categories=selected_categories_ids, + permitted_users=permitted_users, + all_usernames=all_usernames, + ) + + +@app.route("/edit_my_list//suggestions", methods=["GET"]) +@login_required +def edit_my_list_suggestions(list_id: int): + # Weryfikacja listy i właściciela (prywatność) + l = db.session.get(ShoppingList, list_id) + if l is None: + abort(404) + if l.owner_id != current_user.id: + abort(403, description="Nie jesteś właścicielem tej listy.") + + q = (request.args.get("q") or "").strip().lower() + + # Historia nadawań uprawnień przez tego właściciela (po wszystkich jego listach) + subq = ( + db.session.query( + ListPermission.user_id.label("uid"), + func.count(ListPermission.id).label("grant_count"), + func.max(ListPermission.id).label("last_grant_id"), + ) + .join(ShoppingList, ShoppingList.id == ListPermission.list_id) + .filter(ShoppingList.owner_id == current_user.id) + .group_by(ListPermission.user_id) + .subquery() + ) + + query = ( + db.session.query(User.username, subq.c.grant_count, subq.c.last_grant_id) + .outerjoin(subq, subq.c.uid == User.id) + .filter(User.id != current_user.id) + ) + if q: + query = query.filter(func.lower(User.username).like(f"{q}%")) + + rows = ( + query.order_by( + func.coalesce(subq.c.grant_count, 0).desc(), + func.coalesce(subq.c.last_grant_id, 0).desc(), + func.lower(User.username).asc(), + ) + .limit(20) + .all() + ) + + return jsonify({"users": [r.username for r in rows]}) + + +@app.route("/delete_user_list/", methods=["POST"]) +@login_required +def delete_user_list(list_id): + + l = db.session.get(ShoppingList, list_id) + if l is None or l.owner_id != current_user.id: + abort(403, description="Nie jesteś właścicielem tej listy.") + + l = db.session.get(ShoppingList, list_id) + if l is None or l.owner_id != current_user.id: + abort(403) + delete_receipts_for_list(list_id) + Item.query.filter_by(list_id=list_id).delete() + Expense.query.filter_by(list_id=list_id).delete() + db.session.delete(l) + db.session.commit() + flash("Lista została usunięta", "success") + return redirect(url_for("main_page")) + + +@app.route("/toggle_visibility/", methods=["GET", "POST"]) +@login_required +def toggle_visibility(list_id): + l = db.session.get(ShoppingList, list_id) + if l is None: + abort(404) + + if l.owner_id != current_user.id: + if request.is_json or request.method == "POST": + return {"error": "Unauthorized"}, 403 + flash("Nie masz uprawnień do tej listy", "danger") + return redirect(url_for("main_page")) + + l.is_public = not l.is_public + db.session.commit() + + share_url = f"{request.url_root}share/{l.share_token}" + + if request.is_json or request.method == "POST": + return {"is_public": l.is_public, "share_url": share_url} + + if l.is_public: + flash("Lista została udostępniona publicznie", "success") + else: + flash("Lista została ukryta przed gośćmi", "info") + + return redirect(url_for("main_page")) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username_input = request.form["username"].lower() + user = User.query.filter(func.lower(User.username) == username_input).first() + if user and check_password(user.password_hash, request.form["password"]): + session.permanent = True + login_user(user) + session.modified = True + flash("Zalogowano pomyślnie", "success") + return redirect(url_for("main_page")) + flash("Nieprawidłowy login lub hasło", "danger") + return render_template("login.html") + + +@app.route("/logout") +@login_required +def logout(): + logout_user() + flash("Wylogowano pomyślnie", "success") + return redirect(url_for("main_page")) + + +@app.route("/create", methods=["POST"]) +@login_required +def create_list(): + title = request.form.get("title") + is_temporary = request.form.get("temporary") == "1" + token = generate_share_token(8) + + expires_at = ( + datetime.now(timezone.utc) + timedelta(days=7) if is_temporary else None + ) + + new_list = ShoppingList( + title=title, + owner_id=current_user.id, + is_temporary=is_temporary, + share_token=token, + expires_at=expires_at, + ) + db.session.add(new_list) + db.session.commit() + log_list_activity(new_list.id, 'list_created', actor=current_user, actor_name=current_user.username, details='Utworzono listę ręcznie') + db.session.commit() + flash("Utworzono nową listę", "success") + return redirect(url_for("view_list", list_id=new_list.id)) + + +@app.route("/list/") +@login_required +def view_list(list_id): + shopping_list = db.session.get(ShoppingList, list_id) + if not shopping_list: + abort(404) + + is_owner = current_user.id == shopping_list.owner_id + if not is_owner: + flash( + "Nie jesteś właścicielem listy, przekierowano do widoku publicznego.", + "warning", + ) + if current_user.is_admin: + flash( + "W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info" + ) + return redirect(url_for("shared_list", token=shopping_list.share_token)) + + shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id) + total_count = len(items) + purchased_count = len([i for i in items if i.purchased]) + percent = (purchased_count / total_count * 100) if total_count > 0 else 0 + + for item in items: + if item.added_by != shopping_list.owner_id: + item.added_by_display = ( + item.added_by_user.username if item.added_by_user else "?" + ) + else: + item.added_by_display = None + + shopping_list.category_badges = [ + {"name": c.name, "color": category_color_for(c)} + for c in shopping_list.categories + ] + + # Wszystkie kategorie (do selecta) + categories = Category.query.order_by(Category.name.asc()).all() + selected_categories_ids = {c.id for c in shopping_list.categories} + + # Najczęściej używane kategorie właściciela (top N) + popular_categories = ( + db.session.query(Category) + .join( + shopping_list_category, + shopping_list_category.c.category_id == Category.id, + ) + .join( + ShoppingList, + ShoppingList.id == shopping_list_category.c.shopping_list_id, + ) + .filter(ShoppingList.owner_id == current_user.id) + .group_by(Category.id) + .order_by(func.count(ShoppingList.id).desc(), func.lower(Category.name).asc()) + .limit(6) + .all() + ) + + # Użytkownicy z uprawnieniami do listy + permitted_users = ( + db.session.query(User) + .join(ListPermission, ListPermission.user_id == User.id) + .filter(ListPermission.list_id == shopping_list.id) + .order_by(User.username.asc()) + .all() + ) + + activity_logs = ( + ListActivityLog.query.filter_by(list_id=list_id) + .order_by(ListActivityLog.created_at.desc(), ListActivityLog.id.desc()) + .limit(20) + .all() + ) + + all_usernames = [ + u.username + for u in User.query.filter(User.id != current_user.id) + .order_by(func.lower(User.username).asc()) + .limit(300) + .all() + ] + + return render_template( + "list.html", + list=shopping_list, + items=items, + receipts=receipts, + total_count=total_count, + purchased_count=purchased_count, + percent=percent, + expenses=expenses, + total_expense=total_expense, + is_share=False, + is_owner=is_owner, + categories=categories, + selected_categories=selected_categories_ids, + permitted_users=permitted_users, + popular_categories=popular_categories, + activity_logs=activity_logs, + action_label=action_label, + all_usernames=all_usernames, + ) + + +@app.route("/list//settings", methods=["POST"]) +@login_required +def list_settings(list_id): + # Uprawnienia: właściciel + l = db.session.get(ShoppingList, list_id) + if l is None: + abort(404) + if l.owner_id != current_user.id: + abort(403, description="Brak uprawnień do ustawień tej listy.") + + next_page = request.form.get("next") or url_for("view_list", list_id=list_id) + wants_json = ( + "application/json" in (request.headers.get("Accept") or "") + or request.headers.get("X-Requested-With") == "fetch" + ) + + action = request.form.get("action") + + # 1) Ustawienie kategorii (pojedynczy wybór z list.html -> modal kategorii) + if action == "set_category": + cid = request.form.get("category_id") + if cid in (None, "", "none"): + # usunięcie kategorii lub brak zmiany – w zależności od Twojej logiki + l.categories = [] + db.session.commit() + if wants_json: + return jsonify(ok=True, saved=True) + flash("Zapisano kategorię.", "success") + return redirect(next_page) + + try: + cid = int(cid) + except (TypeError, ValueError): + if wants_json: + return jsonify(ok=False, error="bad_category"), 400 + flash("Błędna kategoria.", "danger") + return redirect(next_page) + + c = db.session.get(Category, cid) + if not c: + if wants_json: + return jsonify(ok=False, error="bad_category"), 400 + flash("Błędna kategoria.", "danger") + return redirect(next_page) + + # Jeśli jeden wybór – zastąp listę kategorii jedną: + l.categories = [c] + db.session.commit() + if wants_json: + return jsonify(ok=True, saved=True) + flash("Zapisano kategorię.", "success") + return redirect(next_page) + + # 2) Nadanie dostępu (akceptuj 'grant_access' i 'grant') + if action in ("grant_access", "grant"): + grant_username = (request.form.get("grant_username") or "").strip().lower() + + if not grant_username: + if wants_json: + return jsonify(ok=False, error="empty_username"), 400 + flash("Podaj nazwę użytkownika.", "danger") + return redirect(next_page) + + # Szukamy użytkownika po username (case-insensitive) + u = User.query.filter(func.lower(User.username) == grant_username).first() + if not u: + if wants_json: + return jsonify(ok=False, error="not_found"), 404 + flash("Użytkownik nie istnieje.", "danger") + return redirect(next_page) + + # Właściciel już ma dostęp + if u.id == l.owner_id: + if wants_json: + return jsonify(ok=False, error="owner"), 409 + flash("Jesteś właścicielem tej listy.", "info") + return redirect(next_page) + + # Czy już ma dostęp? + exists = ( + db.session.query(ListPermission.id) + .filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id) + .first() + ) + if exists: + if wants_json: + return jsonify(ok=False, error="exists"), 409 + flash("Ten użytkownik już ma dostęp.", "info") + return redirect(next_page) + + # Zapis uprawnienia + db.session.add(ListPermission(list_id=l.id, user_id=u.id)) + db.session.commit() + + if wants_json: + # Zwracamy usera, żeby JS mógł dokleić token bez odświeżania + return jsonify(ok=True, user={"id": u.id, "username": u.username}) + flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success") + return redirect(next_page) + + # 3) Odebranie dostępu (po polu revoke_user_id, nie po action) + revoke_uid = request.form.get("revoke_user_id") + if revoke_uid: + try: + uid = int(revoke_uid) + except (TypeError, ValueError): + if wants_json: + return jsonify(ok=False, error="bad_user_id"), 400 + flash("Błędny identyfikator użytkownika.", "danger") + return redirect(next_page) + + # Nie pozwalaj usunąć właściciela + if uid == l.owner_id: + if wants_json: + return jsonify(ok=False, error="cannot_revoke_owner"), 400 + flash("Nie można odebrać dostępu właścicielowi.", "danger") + return redirect(next_page) + + ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete() + db.session.commit() + + if wants_json: + return jsonify(ok=True, removed_user_id=uid) + flash("Odebrano dostęp użytkownikowi.", "success") + return redirect(next_page) + + # 4) Nieznana akcja + if wants_json: + return jsonify(ok=False, error="unknown_action"), 400 + flash("Nieznana akcja.", "danger") + return redirect(next_page) + + +@app.route('/my-templates', methods=['GET', 'POST']) +@login_required +def my_templates(): + if request.method == 'POST': + action = (request.form.get('action') or 'create_manual').strip() + if action == 'create_manual': + name = (request.form.get('name') or '').strip() + description = (request.form.get('description') or '').strip() + raw_items = (request.form.get('items_text') or '').splitlines() + if not name: + flash('Podaj nazwę szablonu.', 'danger') + return redirect(url_for('my_templates')) + template = ListTemplate(name=name, description=description, created_by=current_user.id, is_active=True) + db.session.add(template) + db.session.flush() + pos = 1 + for line in raw_items: + line = line.strip() + if not line: + continue + qty = 1 + item_name = line + match = re.match(r'^(.*?)(?:\s+[xX](\d+))?$', line) + if match: + item_name = (match.group(1) or '').strip() or line + if match.group(2): + qty = max(1, int(match.group(2))) + db.session.add(ListTemplateItem(template_id=template.id, name=item_name, quantity=qty, position=pos)) + pos += 1 + db.session.commit() + flash(f'Utworzono szablon „{template.name}”.', 'success') + return redirect(url_for('my_templates')) + elif action == 'delete': + template = ListTemplate.query.options(joinedload(ListTemplate.items)).get_or_404(request.form.get('template_id', type=int)) + if template.created_by != current_user.id and not current_user.is_admin: + abort(403) + db.session.delete(template) + db.session.commit() + flash(f'Usunięto szablon „{template.name}”.', 'warning') + return redirect(url_for('my_templates')) + + templates = ListTemplate.query.options(joinedload(ListTemplate.items)).filter_by(created_by=current_user.id, is_active=True).order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).all() + source_lists = ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False).order_by(ShoppingList.created_at.desc()).limit(100).all() + return render_template('my_templates.html', templates=templates, source_lists=source_lists) + + +@app.route('/templates//instantiate', methods=['POST']) +@login_required +def instantiate_template(template_id): + template = ListTemplate.query.get_or_404(template_id) + if not template_is_accessible_to_user(template, current_user): + abort(403) + title = (request.form.get('title') or '').strip() or None + new_list = create_list_from_template(template, owner=current_user, title=title) + log_list_activity(new_list.id, 'template_created', actor=current_user, details=f'Utworzono z szablonu: {template.name}') + db.session.commit() + flash(f'Utworzono listę z szablonu „{template.name}”.', 'success') + return redirect(url_for('view_list', list_id=new_list.id)) + + +@app.route('/templates/create-from-list/', methods=['POST']) +@login_required +def create_template_from_user_list(list_id): + source_list = ShoppingList.query.options(joinedload(ShoppingList.items)).get_or_404(list_id) + if source_list.owner_id != current_user.id and not current_user.is_admin: + abort(403) + name = (request.form.get('template_name') or '').strip() or f'{source_list.title} - szablon' + description = (request.form.get('description') or '').strip() or f'Szablon utworzony z listy {source_list.title}' + template = create_template_from_list(source_list, created_by=current_user.id, name=name, description=description) + flash(f'Utworzono szablon „{template.name}”.', 'success') + return redirect(url_for('my_templates')) diff --git a/shopping_app/routes_secondary.py b/shopping_app/routes_secondary.py new file mode 100644 index 0000000..5149f2d --- /dev/null +++ b/shopping_app/routes_secondary.py @@ -0,0 +1,740 @@ +from .deps import * +from .app_setup import * +from .models import * +from .helpers import * + +@app.route("/expenses") +@login_required +def expenses(): + start_date_str = request.args.get("start_date") + end_date_str = request.args.get("end_date") + category_id = request.args.get("category_id", type=str) + show_all = request.args.get("show_all", "true").lower() == "true" + + now = datetime.now(timezone.utc) + + visible_clause = visible_lists_clause_for_expenses( + user_id=current_user.id, include_shared=show_all, now_dt=now + ) + + lists_q = ShoppingList.query.filter(*visible_clause) + + if start_date_str and end_date_str: + try: + start = datetime.strptime(start_date_str, "%Y-%m-%d") + end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1) + lists_q = lists_q.filter( + ShoppingList.created_at >= start, + ShoppingList.created_at < end, + ) + except ValueError: + flash("Błędny zakres dat", "danger") + + if category_id: + if category_id == "none": + lists_q = lists_q.filter(~ShoppingList.categories.any()) + else: + try: + cid = int(category_id) + lists_q = lists_q.join( + shopping_list_category, + shopping_list_category.c.shopping_list_id == ShoppingList.id, + ).filter(shopping_list_category.c.category_id == cid) + except (TypeError, ValueError): + pass + + lists_filtered = ( + lists_q.options( + joinedload(ShoppingList.owner), joinedload(ShoppingList.categories) + ) + .order_by(ShoppingList.created_at.desc()) + .all() + ) + list_ids = [l.id for l in lists_filtered] or [-1] + + expenses = ( + Expense.query.options( + joinedload(Expense.shopping_list).joinedload(ShoppingList.owner), + joinedload(Expense.shopping_list).joinedload(ShoppingList.categories), + ) + .filter(Expense.list_id.in_(list_ids)) + .order_by(Expense.added_at.desc()) + .all() + ) + + totals_rows = ( + db.session.query( + ShoppingList.id.label("lid"), + func.coalesce(func.sum(Expense.amount), 0).label("total_expense"), + ) + .select_from(ShoppingList) + .filter(ShoppingList.id.in_(list_ids)) + .outerjoin(Expense, Expense.list_id == ShoppingList.id) + .group_by(ShoppingList.id) + .all() + ) + totals_map = {row.lid: float(row.total_expense or 0) for row in totals_rows} + + categories = ( + Category.query.join( + shopping_list_category, shopping_list_category.c.category_id == Category.id + ) + .join( + ShoppingList, ShoppingList.id == shopping_list_category.c.shopping_list_id + ) + .filter(ShoppingList.id.in_(list_ids)) + .distinct() + .order_by(Category.name.asc()) + .all() + ) + categories.append(SimpleNamespace(id="none", name="Bez kategorii")) + + expense_table = [ + { + "title": (e.shopping_list.title if e.shopping_list else "Nieznana"), + "amount": e.amount, + "added_at": e.added_at, + } + for e in expenses + ] + + lists_data = [ + { + "id": l.id, + "title": l.title, + "created_at": l.created_at, + "total_expense": totals_map.get(l.id, 0.0), + "owner_username": l.owner.username if l.owner else "?", + "categories": [c.id for c in l.categories], + } + for l in lists_filtered + ] + + return render_template( + "expenses.html", + expense_table=expense_table, + lists_data=lists_data, + categories=categories, + selected_category=category_id, + show_all=show_all, + ) + + +@app.route("/expenses_data") +@login_required +def expenses_data(): + range_type = request.args.get("range", "monthly") + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + show_all = request.args.get("show_all", "true").lower() == "true" + category_id = request.args.get("category_id") + by_category = request.args.get("by_category", "false").lower() == "true" + + if not start_date or not end_date: + sd, ed, bucket = resolve_range(range_type) + if sd and ed: + start_date = sd + end_date = ed + range_type = bucket + + if by_category: + result = get_total_expenses_grouped_by_category( + show_all=show_all, + range_type=range_type, + start_date=start_date, + end_date=end_date, + user_id=current_user.id, + category_id=category_id, + ) + else: + result = get_total_expenses_grouped_by_list_created_at( + user_only=False, + admin=False, + show_all=show_all, + range_type=range_type, + start_date=start_date, + end_date=end_date, + user_id=current_user.id, + category_id=category_id, + ) + + if "error" in result: + return jsonify({"error": result["error"]}), 400 + return jsonify(result) + + +@app.route("/api/expenses/latest") +@api_token_required +@require_api_scope('expenses:read') +def api_latest_expenses(): + start_date_str = (request.args.get("start_date") or "").strip() or None + end_date_str = (request.args.get("end_date") or "").strip() or None + list_id = request.args.get("list_id", type=int) + owner_id = request.args.get("owner_id", type=int) + limit = request.args.get("limit", default=50, type=int) or 50 + token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500 + limit = max(1, min(limit, int(token_limit or 500), 500)) + + try: + start_date, end_date = parse_api_date_range(start_date_str, end_date_str) + except ValueError as exc: + return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400 + + filter_query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id) + + if start_date: + filter_query = filter_query.filter(Expense.added_at >= start_date) + if end_date: + filter_query = filter_query.filter(Expense.added_at < end_date) + if list_id: + filter_query = filter_query.filter(Expense.list_id == list_id) + if owner_id: + filter_query = filter_query.filter(ShoppingList.owner_id == owner_id) + + total_count = filter_query.with_entities(func.count(Expense.id)).scalar() or 0 + total_amount = float(filter_query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0) + + expenses = ( + filter_query.options( + joinedload(Expense.shopping_list).joinedload(ShoppingList.owner), + joinedload(Expense.shopping_list).joinedload(ShoppingList.categories), + ) + .order_by(Expense.added_at.desc(), Expense.id.desc()) + .limit(limit) + .all() + ) + + items = [] + for expense in expenses: + shopping_list = expense.shopping_list + owner = shopping_list.owner if shopping_list else None + items.append( + { + "expense_id": expense.id, + "amount": round(float(expense.amount or 0), 2), + "added_at": format_dt_for_api(expense.added_at), + "receipt_filename": expense.receipt_filename, + "list": { + "id": shopping_list.id if shopping_list else None, + "title": shopping_list.title if shopping_list else None, + "created_at": format_dt_for_api(shopping_list.created_at if shopping_list else None), + "is_archived": bool(shopping_list.is_archived) if shopping_list else None, + "is_public": bool(shopping_list.is_public) if shopping_list else None, + "categories": [c.name for c in shopping_list.categories] if shopping_list else [], + }, + "owner": { + "id": owner.id if owner else None, + "username": owner.username if owner else None, + }, + } + ) + + return jsonify( + { + "ok": True, + "filters": { + "start_date": start_date_str, + "end_date": end_date_str, + "list_id": list_id, + "owner_id": owner_id, + "limit": limit, + }, + "meta": { + "returned_count": len(items), + "total_count": int(total_count), + "total_amount": round(total_amount, 2), + "token_name": g.api_token.name, + "token_prefix": g.api_token.token_prefix, + }, + "items": items, + } + ) + + +@app.route("/api/ping") +@api_token_required +def api_ping(): + return jsonify({"ok": True, "message": "token accepted", "token_name": g.api_token.name, "token_prefix": g.api_token.token_prefix}) + + +@app.route("/api/expenses/summary") +@api_token_required +@require_api_scope('expenses:read') +def api_expenses_summary(): + start_date_str = (request.args.get("start_date") or "").strip() or None + end_date_str = (request.args.get("end_date") or "").strip() or None + list_id = request.args.get("list_id", type=int) + owner_id = request.args.get("owner_id", type=int) + + try: + start_date, end_date = parse_api_date_range(start_date_str, end_date_str) + except ValueError as exc: + return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400 + + query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id) + if start_date: + query = query.filter(Expense.added_at >= start_date) + if end_date: + query = query.filter(Expense.added_at < end_date) + if list_id: + query = query.filter(Expense.list_id == list_id) + if owner_id: + query = query.filter(ShoppingList.owner_id == owner_id) + + total_count = int(query.with_entities(func.count(Expense.id)).scalar() or 0) + total_amount = float(query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0) + by_list = ( + query.with_entities(ShoppingList.id, ShoppingList.title, func.count(Expense.id), func.coalesce(func.sum(Expense.amount), 0)) + .group_by(ShoppingList.id, ShoppingList.title) + .order_by(func.coalesce(func.sum(Expense.amount), 0).desc(), ShoppingList.id.desc()) + .limit(100) + .all() + ) + return jsonify({ + "ok": True, + "filters": {"start_date": start_date_str, "end_date": end_date_str, "list_id": list_id, "owner_id": owner_id}, + "meta": {"total_count": total_count, "total_amount": round(total_amount, 2)}, + "lists": [{"id": row[0], "title": row[1], "expense_count": int(row[2] or 0), "total_amount": round(float(row[3] or 0), 2)} for row in by_list], + }) + + +@app.route("/api/lists") +@api_token_required +@require_api_scope('lists:read') +def api_lists(): + owner_id = request.args.get("owner_id", type=int) + limit = request.args.get("limit", default=50, type=int) or 50 + token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500 + limit = max(1, min(limit, int(token_limit or 500), 500)) + + query = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).order_by(ShoppingList.created_at.desc(), ShoppingList.id.desc()) + if owner_id: + query = query.filter(ShoppingList.owner_id == owner_id) + rows = query.limit(limit).all() + return jsonify({ + "ok": True, + "items": [{ + "id": row.id, + "title": row.title, + "created_at": format_dt_for_api(row.created_at), + "owner": {"id": row.owner.id if row.owner else None, "username": row.owner.username if row.owner else None}, + "is_temporary": bool(row.is_temporary), + "expires_at": format_dt_for_api(row.expires_at), + "is_archived": bool(row.is_archived), + "is_public": bool(row.is_public), + "categories": [c.name for c in row.categories], + } for row in rows], + }) + + +@app.route("/api/lists//expenses") +@api_token_required +@require_api_scope('lists:read') +def api_list_expenses(list_id): + limit = request.args.get("limit", default=50, type=int) or 50 + token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500 + limit = max(1, min(limit, int(token_limit or 500), 500)) + shopping_list = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).get_or_404(list_id) + rows = Expense.query.filter_by(list_id=list_id).order_by(Expense.added_at.desc(), Expense.id.desc()).limit(limit).all() + return jsonify({ + "ok": True, + "list": { + "id": shopping_list.id, + "title": shopping_list.title, + "owner": {"id": shopping_list.owner.id if shopping_list.owner else None, "username": shopping_list.owner.username if shopping_list.owner else None}, + "categories": [c.name for c in shopping_list.categories], + }, + "items": [{"expense_id": row.id, "amount": round(float(row.amount or 0), 2), "added_at": format_dt_for_api(row.added_at), "receipt_filename": row.receipt_filename} for row in rows], + }) + + +@app.route("/api/templates") +@api_token_required +@require_api_scope('templates:read') +def api_templates(): + query = ListTemplate.query.options(joinedload(ListTemplate.creator), joinedload(ListTemplate.items)).filter_by(is_active=True) + owner_id = request.args.get("owner_id", type=int) + if owner_id: + query = query.filter(ListTemplate.created_by == owner_id) + rows = query.order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).limit(100).all() + return jsonify({ + "ok": True, + "items": [{ + "id": row.id, + "name": row.name, + "description": row.description, + "created_at": format_dt_for_api(row.created_at), + "owner": {"id": row.creator.id if row.creator else None, "username": row.creator.username if row.creator else None}, + "items_count": len(row.items), + "items": [{"name": item.name, "quantity": item.quantity, "note": item.note} for item in row.items], + } for row in rows], + }) + + +@app.route("/share/") +# @app.route("/guest-list/") +@app.route("/shared/") +def shared_list(token=None, list_id=None): + now = datetime.now(timezone.utc) + + if token: + shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404() + + expires_at = shopping_list.expires_at + if expires_at and expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + + # jeśli lista wygasła – zablokuj (spójne z resztą aplikacji) + if shopping_list.is_temporary and expires_at and expires_at <= now: + flash("Link wygasł.", "warning") + return redirect(url_for("main_page")) + + list_id = shopping_list.id + + # jeśli zalogowany i nie jest właścicielem — auto-przypisz stałe uprawnienie + if current_user.is_authenticated and current_user.id != shopping_list.owner_id: + exists = ( + db.session.query(ListPermission.id) + .filter( + ListPermission.list_id == shopping_list.id, + ListPermission.user_id == current_user.id, + ) + .first() + ) + if not exists: + db.session.add( + ListPermission(list_id=shopping_list.id, user_id=current_user.id) + ) + db.session.commit() + else: + shopping_list = ShoppingList.query.get_or_404(list_id) + expires_at = shopping_list.expires_at + if expires_at and expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) + + if shopping_list.is_temporary and expires_at and expires_at <= now: + flash("Ta lista wygasła.", "warning") + return redirect(url_for("main_page")) + + is_allowed = shopping_list.is_public + if current_user.is_authenticated: + is_allowed = is_allowed or shopping_list.owner_id == current_user.id or ( + db.session.query(ListPermission.id) + .filter( + ListPermission.list_id == shopping_list.id, + ListPermission.user_id == current_user.id, + ) + .first() + is not None + ) + + if not is_allowed: + flash("Ta lista nie jest publicznie dostępna.", "warning") + return redirect(url_for("main_page")) + + total_expense = get_total_expense_for_list(list_id) + shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id) + + shopping_list.category_badges = [ + {"name": c.name, "color": category_color_for(c)} + for c in shopping_list.categories + ] + + for item in items: + if item.added_by and item.added_by != shopping_list.owner_id: + item.added_by_display = ( + item.added_by_user.username if item.added_by_user else None + ) + else: + item.added_by_display = None + + return render_template( + "list_share.html", + list=shopping_list, + items=items, + receipts=receipts, + expenses=expenses, + total_expense=total_expense, + is_share=True, + ) + + +@app.route("/copy/") +@login_required +def copy_list(list_id): + original = ShoppingList.query.get_or_404(list_id) + token = generate_share_token(8) + new_list = ShoppingList( + title=original.title + " (Kopia)", owner_id=current_user.id, share_token=token + ) + db.session.add(new_list) + db.session.commit() + original_items = Item.query.filter_by(list_id=original.id).all() + for item in original_items: + copy_item = Item(list_id=new_list.id, name=item.name) + db.session.add(copy_item) + db.session.commit() + flash("Skopiowano listę", "success") + return redirect(url_for("view_list", list_id=new_list.id)) + + +@app.route("/suggest_products") +def suggest_products(): + query = request.args.get("q", "") + suggestions = [] + if query: + suggestions = ( + SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f"%{query}%")) + .limit(5) + .all() + ) + return {"suggestions": [s.name for s in suggestions]} + + +@app.route("/all_products") +def all_products(): + sort = request.args.get("sort", "popularity") + limit = request.args.get("limit", type=int) or 100 + offset = request.args.get("offset", type=int) or 0 + + products_from_items = db.session.query( + func.lower(func.trim(Item.name)).label("normalized_name"), + func.min(Item.name).label("display_name"), + func.count(func.distinct(Item.list_id)).label("count"), + ).group_by(func.lower(func.trim(Item.name))) + + products_from_suggested = ( + db.session.query( + func.lower(func.trim(SuggestedProduct.name)).label("normalized_name"), + func.min(SuggestedProduct.name).label("display_name"), + db.literal(1).label("count"), + ) + .filter( + ~func.lower(func.trim(SuggestedProduct.name)).in_( + db.session.query(func.lower(func.trim(Item.name))) + ) + ) + .group_by(func.lower(func.trim(SuggestedProduct.name))) + ) + + union_q = products_from_items.union_all(products_from_suggested).subquery() + + final_q = db.session.query( + union_q.c.normalized_name, + union_q.c.display_name, + func.sum(union_q.c.count).label("count"), + ).group_by(union_q.c.normalized_name, union_q.c.display_name) + + if sort == "alphabetical": + final_q = final_q.order_by(func.lower(union_q.c.display_name).asc()) + else: + final_q = final_q.order_by( + func.sum(union_q.c.count).desc(), func.lower(union_q.c.display_name).asc() + ) + + total_count = ( + db.session.query(func.count()).select_from(final_q.subquery()).scalar() + ) + products = final_q.offset(offset).limit(limit).all() + + out = [{"name": row.display_name, "count": row.count} for row in products] + + return jsonify({"products": out, "total_count": total_count}) + + +@app.route("/upload_receipt/", methods=["POST"]) +@login_required +def upload_receipt(list_id): + l = db.session.get(ShoppingList, list_id) + + file = request.files.get("receipt") + if not file or file.filename == "": + return receipt_error("Nie wybrano pliku") + + if not allowed_file(file.filename): + return receipt_error("Niedozwolony format pliku") + + file_bytes = file.read() + file.seek(0) + file_hash = hashlib.sha256(file_bytes).hexdigest() + + existing = Receipt.query.filter_by(file_hash=file_hash).first() + if existing: + return receipt_error("Taki plik już istnieje") + + now = datetime.now(timezone.utc) + timestamp = now.strftime("%Y%m%d_%H%M") + random_part = secrets.token_hex(3) + webp_filename = f"list_{list_id}_{timestamp}_{random_part}.webp" + file_path = os.path.join(app.config["UPLOAD_FOLDER"], webp_filename) + + try: + if file.filename.lower().endswith(".pdf"): + file.seek(0) + save_pdf_as_webp(file, file_path) + else: + save_resized_image(file, file_path) + except ValueError as e: + return receipt_error(str(e)) + + try: + new_receipt = Receipt( + list_id=list_id, + filename=webp_filename, + filesize=os.path.getsize(file_path), + uploaded_at=now, + file_hash=file_hash, + uploaded_by=current_user.id, + version_token=generate_version_token(), + ) + db.session.add(new_receipt) + db.session.commit() + except Exception as e: + return receipt_error(f"Błąd zapisu do bazy: {str(e)}") + + if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest": + url = ( + url_for("uploaded_file", filename=webp_filename) + + f"?v={new_receipt.version_token or '0'}" + ) + socketio.emit("receipt_added", {"url": url}, to=str(list_id)) + return jsonify({"success": True, "url": url}) + + flash("Wgrano paragon", "success") + return redirect(request.referrer or url_for("main_page")) + + +@app.route("/uploads/") +def uploaded_file(filename): + response = send_from_directory(app.config["UPLOAD_FOLDER"], filename) + response.headers["Cache-Control"] = app.config["UPLOADS_CACHE_CONTROL"] + response.headers.pop("Content-Disposition", None) + mime, _ = mimetypes.guess_type(filename) + if mime: + response.headers["Content-Type"] = mime + return response + + +@app.route("/reorder_items", methods=["POST"]) +@login_required +def reorder_items(): + data = request.get_json() + list_id = data.get("list_id") + order = data.get("order") + + for index, item_id in enumerate(order): + item = db.session.get(Item, item_id) + if item and item.list_id == list_id: + item.position = index + db.session.commit() + + socketio.emit( + "items_reordered", {"list_id": list_id, "order": order}, to=str(list_id) + ) + + return jsonify(success=True) + + +@app.route("/rotate_receipt/") +@login_required +def rotate_receipt_user(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + list_obj = ShoppingList.query.get_or_404(receipt.list_id) + + if not (current_user.is_admin or current_user.id == list_obj.owner_id): + flash("Brak uprawnień do tej operacji", "danger") + return redirect(url_for("main_page")) + + try: + rotate_receipt_by_id(receipt_id) + recalculate_filesizes(receipt_id) + flash("Obrócono paragon", "success") + except FileNotFoundError: + flash("Plik nie istnieje", "danger") + except Exception as e: + flash(f"Błąd przy obracaniu: {str(e)}", "danger") + + return redirect(request.referrer or url_for("main_page")) + + +@app.route("/delete_receipt/") +@login_required +def delete_receipt_user(receipt_id): + receipt = Receipt.query.get_or_404(receipt_id) + list_obj = ShoppingList.query.get_or_404(receipt.list_id) + + if not (current_user.is_admin or current_user.id == list_obj.owner_id): + flash("Brak uprawnień do tej operacji", "danger") + return redirect(url_for("main_page")) + + try: + delete_receipt_by_id(receipt_id) + flash("Paragon usunięty", "success") + except Exception as e: + flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger") + + return redirect(request.referrer or url_for("main_page")) + + +# OCR +@app.route("/lists//analyze", methods=["POST"]) +@login_required +def analyze_receipts_for_list(list_id): + receipt_objs = Receipt.query.filter_by(list_id=list_id).all() + existing_expenses = { + e.receipt_filename + for e in Expense.query.filter_by(list_id=list_id).all() + if e.receipt_filename + } + + results = [] + total = 0.0 + + for receipt in receipt_objs: + filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename) + if not os.path.exists(filepath): + continue + + try: + raw_image = Image.open(filepath).convert("RGB") + image = preprocess_image_for_tesseract(raw_image) + value, lines = extract_total_tesseract(image) + + except Exception as e: + print(f"OCR error for {receipt.filename}:\n{traceback.format_exc()}") + value = 0.0 + lines = [] + + already_added = receipt.filename in existing_expenses + + results.append( + { + "id": receipt.id, + "filename": receipt.filename, + "amount": round(value, 2), + "debug_text": lines, + "already_added": already_added, + } + ) + + # if not already_added: + total += value + + return jsonify({"results": results, "total": round(total, 2)}) + + +@app.route("/user_crop_receipt", methods=["POST"]) +@login_required +def crop_receipt_user(): + receipt_id = request.form.get("receipt_id") + file = request.files.get("cropped_image") + + receipt = Receipt.query.get_or_404(receipt_id) + list_obj = ShoppingList.query.get_or_404(receipt.list_id) + + if list_obj.owner_id != current_user.id and not current_user.is_admin: + return jsonify(success=False, error="Brak dostępu"), 403 + + result = handle_crop_receipt(receipt_id, file) + return jsonify(result) + diff --git a/shopping_app/sockets.py b/shopping_app/sockets.py new file mode 100644 index 0000000..1b96f89 --- /dev/null +++ b/shopping_app/sockets.py @@ -0,0 +1,622 @@ +import click +from .deps import * +from .app_setup import * +from .models import * +from .helpers import * + +from flask import render_template_string + +@app.route('/admin/debug-socket') +@login_required +@admin_required +def debug_socket(): + return render_template_string(''' + + + + Socket Debug + + + + +

Socket.IO Debug Tool

+ +
Rozlaczony
+
+ Transport: - | + Ping: -ms | + SID: - +
+ + + + + + +

Logi:

+
+ + + + + ''') + + + +# ========================================================================================= +# SOCKET.IO +# ========================================================================================= + + +@socketio.on("delete_item") +def handle_delete_item(data): + # item = Item.query.get(data["item_id"]) + item = db.session.get(Item, data["item_id"]) + + if item: + list_id = item.list_id + log_list_activity(list_id, 'item_deleted', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość') + db.session.delete(item) + db.session.commit() + emit("item_deleted", {"item_id": item.id}, to=str(item.list_id)) + + purchased_count, total_count, percent = get_progress(list_id) + + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(list_id), + ) + + +@socketio.on("edit_item") +def handle_edit_item(data): + item = db.session.get(Item, data["item_id"]) + + new_name = data["new_name"] + new_quantity = data.get("new_quantity", item.quantity) + + if item and new_name.strip(): + item.name = new_name.strip() + + try: + new_quantity = int(new_quantity) + if new_quantity < 1: + new_quantity = 1 + except: + new_quantity = 1 + + item.quantity = new_quantity + + db.session.commit() + + emit( + "item_edited", + {"item_id": item.id, "new_name": item.name, "new_quantity": item.quantity}, + to=str(item.list_id), + ) + + +@socketio.on("join_list") +def handle_join(data): + global active_users + room = str(data["room"]) + username = data.get("username", "Gość") + join_room(room) + + if room not in active_users: + active_users[room] = set() + active_users[room].add(username) + + shopping_list = db.session.get(ShoppingList, int(data["room"])) + + list_title = shopping_list.title if shopping_list else "Twoja lista" + + emit("user_joined", {"username": username}, to=room) + emit("user_list", {"users": list(active_users[room])}, to=room) + emit("joined_confirmation", {"room": room, "list_title": list_title}) + + +@socketio.on("disconnect") +def handle_disconnect(sid): + global active_users + username = current_user.username if current_user.is_authenticated else "Gość" + for room, users in active_users.items(): + if username in users: + users.remove(username) + emit("user_left", {"username": username}, to=room) + emit("user_list", {"users": list(users)}, to=room) + + +@socketio.on("add_item") +def handle_add_item(data): + list_id = data["list_id"] + name = data["name"].strip() + quantity = data.get("quantity", 1) + + list_obj = db.session.get(ShoppingList, list_id) + if not list_obj: + return + + try: + quantity = int(quantity) + if quantity < 1: + quantity = 1 + except: + quantity = 1 + + existing_item = Item.query.filter( + Item.list_id == list_id, + func.lower(Item.name) == name.lower(), + Item.not_purchased == False, + ).first() + + if existing_item: + existing_item.quantity += quantity + db.session.commit() + + emit( + "item_edited", + { + "item_id": existing_item.id, + "new_name": existing_item.name, + "new_quantity": existing_item.quantity, + }, + to=str(list_id), + ) + else: + max_position = ( + db.session.query(func.max(Item.position)) + .filter_by(list_id=list_id) + .scalar() + ) + if max_position is None: + max_position = 0 + + user_id = current_user.id if current_user.is_authenticated else None + user_name = current_user.username if current_user.is_authenticated else "Gość" + + new_item = Item( + list_id=list_id, + name=name, + quantity=quantity, + position=max_position + 1, + added_by=user_id, + ) + db.session.add(new_item) + + if not SuggestedProduct.query.filter( + func.lower(SuggestedProduct.name) == name.lower() + ).first(): + new_suggestion = SuggestedProduct(name=name) + db.session.add(new_suggestion) + + log_list_activity(list_id, 'item_added', item_name=new_item.name, actor=current_user if current_user.is_authenticated else None, actor_name=user_name, details=f'ilość: {new_item.quantity}') + db.session.commit() + + emit( + "item_added", + { + "id": new_item.id, + "name": new_item.name, + "quantity": new_item.quantity, + "added_by": user_name, + "added_by_id": user_id, + "owner_id": list_obj.owner_id, + }, + to=str(list_id), + include_self=True, + ) + + purchased_count, total_count, percent = get_progress(list_id) + + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(list_id), + ) + + +@socketio.on("check_item") +def handle_check_item(data): + item = db.session.get(Item, data["item_id"]) + + if item: + item.purchased = True + item.purchased_at = datetime.now(UTC) + item.not_purchased = False + item.not_purchased_reason = None + log_list_activity(item.list_id, 'item_checked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość') + db.session.commit() + + purchased_count, total_count, percent = get_progress(item.list_id) + + emit("item_checked", {"item_id": item.id}, to=str(item.list_id)) + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(item.list_id), + ) + + +@socketio.on("uncheck_item") +def handle_uncheck_item(data): + item = db.session.get(Item, data["item_id"]) + + if item: + item.purchased = False + item.purchased_at = None + log_list_activity(item.list_id, 'item_unchecked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość') + db.session.commit() + + purchased_count, total_count, percent = get_progress(item.list_id) + + emit("item_unchecked", {"item_id": item.id}, to=str(item.list_id)) + emit( + "progress_updated", + { + "purchased_count": purchased_count, + "total_count": total_count, + "percent": percent, + }, + to=str(item.list_id), + ) + + +@socketio.on("request_full_list") +def handle_request_full_list(data): + list_id = data["list_id"] + + shopping_list = db.session.get(ShoppingList, list_id) + if not shopping_list: + return + + owner_id = shopping_list.owner_id + + items = ( + Item.query.options(joinedload(Item.added_by_user)) + .filter_by(list_id=list_id) + .order_by(Item.position.asc()) + .all() + ) + + items_data = [] + for item in items: + items_data.append( + { + "id": item.id, + "name": item.name, + "quantity": item.quantity, + "purchased": item.purchased if not item.not_purchased else False, + "not_purchased": item.not_purchased, + "not_purchased_reason": item.not_purchased_reason, + "note": item.note or "", + "added_by": item.added_by_user.username if item.added_by_user else None, + "added_by_id": item.added_by_user.id if item.added_by_user else None, + "owner_id": owner_id, + } + ) + + emit("full_list", {"items": items_data}, to=request.sid) + + +@socketio.on("update_note") +def handle_update_note(data): + item_id = data["item_id"] + note = data["note"] + item = Item.query.get(item_id) + if item: + item.note = note + db.session.commit() + emit("note_updated", {"item_id": item_id, "note": note}, to=str(item.list_id)) + + +@socketio.on("add_expense") +def handle_add_expense(data): + list_id = data["list_id"] + amount = data["amount"] + receipt_filename = data.get("receipt_filename") + + if receipt_filename: + existing = Expense.query.filter_by( + list_id=list_id, receipt_filename=receipt_filename + ).first() + if existing: + return + new_expense = Expense( + list_id=list_id, amount=amount, receipt_filename=receipt_filename + ) + + db.session.add(new_expense) + log_list_activity(list_id, 'expense_added', item_name=None, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=f'kwota: {float(amount):.2f} PLN') + db.session.commit() + + total = ( + db.session.query(func.sum(Expense.amount)).filter_by(list_id=list_id).scalar() + or 0 + ) + + emit("expense_added", {"amount": amount, "total": total}, to=str(list_id)) + + +@socketio.on("mark_not_purchased") +def handle_mark_not_purchased(data): + item = db.session.get(Item, data["item_id"]) + + reason = data.get("reason", "") + if item: + item.not_purchased = True + item.not_purchased_reason = reason + item.purchased = False + item.purchased_at = None + log_list_activity(item.list_id, 'item_marked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=reason or None) + db.session.commit() + emit( + "item_marked_not_purchased", + {"item_id": item.id, "reason": reason}, + to=str(item.list_id), + ) + + +@socketio.on("unmark_not_purchased") +def handle_unmark_not_purchased(data): + item = db.session.get(Item, data["item_id"]) + + if item: + item.not_purchased = False + item.purchased = False + item.purchased_at = None + item.not_purchased_reason = None + log_list_activity(item.list_id, 'item_unmarked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość') + db.session.commit() + emit("item_unmarked_not_purchased", {"item_id": item.id}, to=str(item.list_id)) + + +@app.cli.command("db_info") +def create_db(): + with app.app_context(): + inspector = inspect(db.engine) + actual_tables = inspector.get_table_names() + + table_count = len(actual_tables) + record_total = 0 + with db.engine.connect() as conn: + for table in actual_tables: + try: + count = conn.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar() + record_total += count + except Exception: + pass + + print("\nStruktura bazy danych jest poprawna.") + print(f"Silnik: {db.engine.name}") + print(f"Liczba tabel: {table_count}") + print(f"Łączna liczba rekordów: {record_total}") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO) + socketio.run(app, host="0.0.0.0", port=APP_PORT, debug=False) + + +@app.cli.group("admins") +def admins_cli(): + """Zarzadzanie kontami administratorow z CLI.""" + + +@admins_cli.command("list") +def admins_list_command(): + with app.app_context(): + users = User.query.order_by(User.username.asc()).all() + if not users: + click.echo('Brak uzytkownikow.') + return + for user in users: + role = 'admin' if user.is_admin else 'user' + click.echo(f"{user.id} {user.username} {role}") + + +@admins_cli.command("create") +@click.argument("username") +@click.argument("password") +@click.option("--admin/--user", "make_admin", default=True, show_default=True, help="Utworz konto admina albo zwyklego uzytkownika.") +def admins_create_command(username, password, make_admin): + with app.app_context(): + user, created, _ = create_or_update_admin_user(username, password=password, make_admin=make_admin, update_password=False) + status = 'Utworzono' if created else 'Istnieje juz' + click.echo(f"{status} konto: id={user.id}, username={user.username}, admin={user.is_admin}") + + +@admins_cli.command("promote") +@click.argument("username") +def admins_promote_command(username): + with app.app_context(): + user = resolve_user_identifier(username) + if not user: + raise click.ClickException('Nie znaleziono uzytkownika.') + user.is_admin = True + db.session.commit() + click.echo(f"Uzytkownik {user.username} ma teraz uprawnienia admina.") + + +@admins_cli.command("demote") +@click.argument("username") +def admins_demote_command(username): + with app.app_context(): + user = resolve_user_identifier(username) + if not user: + raise click.ClickException('Nie znaleziono uzytkownika.') + user.is_admin = False + db.session.commit() + click.echo(f"Uzytkownik {user.username} nie jest juz adminem.") + + +@admins_cli.command("set-password") +@click.argument("username") +@click.argument("password") +def admins_set_password_command(username, password): + with app.app_context(): + user = resolve_user_identifier(username) + if not user: + raise click.ClickException('Nie znaleziono uzytkownika.') + user.password_hash = hash_password(password) + db.session.commit() + click.echo(f"Zmieniono haslo dla {user.username}.") + + +@app.cli.group("lists") +def lists_cli(): + """Operacje CLI na listach zakupowych.""" + + +@lists_cli.command("copy-schedule") +@click.option("--source-list-id", required=True, type=int, help="ID listy zrodlowej.") +@click.option("--when", "when_value", required=True, help="Nowa data utworzenia listy: YYYY-MM-DD lub YYYY-MM-DD HH:MM") +@click.option("--owner", "owner_value", default=None, help="Nowy wlasciciel: username albo ID. Domyslnie wlasciciel oryginalu.") +@click.option("--title", default=None, help="Nowy tytul listy. Domyslnie taki sam jak w oryginale.") +def lists_copy_schedule_command(source_list_id, when_value, owner_value, title): + with app.app_context(): + source_list = ShoppingList.query.options(joinedload(ShoppingList.items), joinedload(ShoppingList.categories)).get(source_list_id) + if not source_list: + raise click.ClickException('Nie znaleziono listy zrodlowej.') + + try: + scheduled_for = parse_cli_datetime(when_value) + except ValueError as exc: + raise click.ClickException(str(exc)) + + owner = None + if owner_value: + owner = resolve_user_identifier(owner_value) + if not owner: + raise click.ClickException('Nie znaleziono docelowego wlasciciela.') + + new_list = duplicate_list_for_schedule(source_list, scheduled_for=scheduled_for, owner=owner, title=title) + click.echo( + f"Utworzono kopie listy: nowa_id={new_list.id}, tytul={new_list.title}, created_at={new_list.created_at.isoformat()}" + ) diff --git a/shopping_app/startup_info.py b/shopping_app/startup_info.py new file mode 100644 index 0000000..44e02a4 --- /dev/null +++ b/shopping_app/startup_info.py @@ -0,0 +1,95 @@ +import os +import sys +import platform +import socket +from datetime import datetime + +import psutil + +try: + from sqlalchemy import text +except Exception: + text = None + +def mb(x): + return int(x / 1024 / 1024) + + +def get_db_type(app): + uri = app.config.get("SQLALCHEMY_DATABASE_URI") or app.config.get("DATABASE_URL", "") + + if not uri: + return "NONE" + + if uri.startswith("sqlite"): + return "SQLite" + if uri.startswith("mysql"): + return "MySQL" + if uri.startswith("postgresql"): + return "PostgreSQL" + + return "OTHER" + +def print_startup_info(app): + host = os.getenv("HOST", "127.0.0.1") + port = int(os.getenv("PORT", "8000")) + + rules = list(app.url_map.iter_rules()) + + cpu = psutil.cpu_percent(interval=0.2) + ram = psutil.virtual_memory() + proc = psutil.Process(os.getpid()) + + db_type = get_db_type(app) + + print("\n" + "="*52) + print(" APP START") + print("="*52) + + # SYSTEM + print("\n[ SYSTEM ]") + print(f"Time : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"OS : {platform.system()} {platform.release()} ({platform.machine()})") + print(f"Python : {sys.version.split()[0]}") + print(f"Host : {socket.gethostname()}") + + # SERVER + print("\n[ SERVER ]") + print(f"Bind : {host}:{port}") + print(f"URL : http://127.0.0.1:{port}") + + # APP + print("\n[ APP ]") + print(f"Name : {app.name}") + print(f"Mode : {'DEV' if app.debug else 'PROD'}") + print(f"Debug : {app.debug}") + + # RESOURCES + print("\n[ RESOURCES ]") + print(f"CPU : {cpu:>5.1f}%") + print(f"RAM : {ram.percent:>5.1f}% ({mb(ram.used)} / {mb(ram.total)} MB)") + print(f"PROC : {mb(proc.memory_info().rss)} MB") + + # DATABASE + print("\n[ DATABASE ]") + print(f"Type : {db_type}") + + # SECURITY + print("\n[ SECURITY ]") + print(f"Secret : {'OK' if app.config.get('SECRET_KEY') else 'MISSING'}") + print(f"Talis : {'OFF' if app.config.get('TALISMAN_DISABLED') else 'ON'}") + + # HEALTH + print("\n[ HEALTH ]") + print(f"Uploads: {'OK' if os.path.exists('uploads') else 'MISS'}") + print(f"Static : {'OK' if os.path.exists(app.static_folder) else 'MISS'}") + + # ROUTES + print("\n[ ROUTES ]") + print(f"Total : {len(rules)}") + + # STATUS + print("\n[ STATUS ]") + print("READY") + + print("="*52 + "\n") \ No newline at end of file diff --git a/shopping_app/static/css/style.css b/shopping_app/static/css/style.css new file mode 100644 index 0000000..d55365e --- /dev/null +++ b/shopping_app/static/css/style.css @@ -0,0 +1,5877 @@ +/* ========================================================= + Variables (single source of truth) +========================================================= */ +:root { + /* brand / info */ + --primary: #184076; + --primary-border: #153866; + --primary-text: #e6f0ff; + + --info: var(--primary); + --info-border: var(--primary-border); + --info-text: var(--primary-text); + + /* success */ + --success: #1c6930; + --success-border: #165024; + --success-text: #eaffea; + + /* warning */ + --warning: #665c1e; + --warning-border: #4d4415; + --warning-text: #fffbe5; + + /* danger */ + --danger: #6e1a1e; + --danger-border: #531417; + --danger-text: #ffeaea; + + /* neutrals / dark */ + --dark-900: #181a1b; + --dark-800: #1c1f22; + --dark-750: #1f2225; + --dark-700: #212529; + --dark-650: #23272a; + --dark-600: #2a2d31; + --dark-550: #2b2f33; + --dark-500: #2c2f33; + --dark-480: #2c3034; + --dark-470: #2a2d31; + --dark-450: #3a3f44; + --dark-400: #343a40; + --dark-350: #3d4248; + --dark-300: #495057; + + --text-strong: #f8f9fa; + --text: #e2e3e5; + --text-dim: #e1e1e1; + --muted: #6c757d; + + /* defaults */ + --progress-default: #3d7bd6; +} + +/* ========================================================= + Utilities & Sizes +========================================================= */ +.large-checkbox { + width: 1.5em; + height: 1.5em; +} + +.clickable-item { + cursor: pointer; +} + +.progress-thin { + height: 12px; +} + +.item-not-checked { + background-color: var(--dark-500) !important; + color: #fff !important; +} + +#empty-placeholder { + font-style: italic; + pointer-events: none; +} + +.fade-out { + opacity: 0; + transition: opacity 0.5s ease; +} + +@media (pointer: fine) { + .only-mobile { + display: none !important; + } +} + +/* Bootstrap bg overrides via variables */ +.bg-success { + background-color: var(--success) !important; +} + +.bg-warning { + background-color: var(--warning) !important; +} + +/* ========================================================= + Buttons +========================================================= */ +/* Primary */ +.btn-primary { + background-color: var(--primary) !important; + border-color: var(--primary-border) !important; +} + +.btn-primary:hover, +.btn-primary:focus, +.btn-primary:active { + background-color: #13315f !important; + border-color: #10284f !important; +} + +/* Success */ +.btn-success { + background-color: var(--success) !important; + border-color: var(--success-border) !important; + color: #fff !important; +} + +.btn-success:hover, +.btn-success:focus, +.btn-success:active { + background-color: #155627 !important; + border-color: #124521 !important; + color: #fff !important; +} + +/* Warning */ +.btn-warning { + background-color: var(--warning) !important; + border-color: var(--warning-border) !important; + color: var(--warning-text) !important; +} + +.btn-warning:hover, +.btn-warning:focus, +.btn-warning:active { + background-color: #5c4c17 !important; + border-color: #3e3610 !important; + color: var(--warning-text) !important; +} + +/* Outline */ +.btn-outline-success { + color: var(--success) !important; + border-color: var(--success) !important; +} + +.btn-outline-success:hover, +.btn-outline-success:focus, +.btn-outline-success:active { + background-color: var(--success) !important; + border-color: var(--success-border) !important; + color: #fff !important; +} + +.btn-outline-warning { + color: #d9c97a !important; + border-color: var(--warning) !important; +} + +.btn-outline-warning:hover, +.btn-outline-warning:focus, +.btn-outline-warning:active { + background-color: var(--warning) !important; + border-color: var(--warning-border) !important; + color: var(--warning-text) !important; +} + +/* File input button */ +input[type="file"]::file-selector-button { + background-color: #1b4a29; + color: #f0f0f0; + border: none; + padding: .5em 1em; + border-radius: 4px; + font-weight: bold; + cursor: pointer; + transition: background .2s; +} + +/* ========================================================= + Forms (inputs, selects, switches, placeholders) +========================================================= */ +.form-select, +.form-control, +textarea.form-control { + background-color: var(--dark-700) !important; + color: var(--text-strong) !important; + border: 1px solid var(--dark-300) !important; +} + +.form-select:focus, +.form-control:focus, +textarea.form-control:focus { + background-color: var(--dark-800) !important; + border-color: var(--primary) !important; + color: #fff !important; + box-shadow: 0 0 0 .25rem rgba(24, 64, 118, .35) !important; +} + +.form-control:disabled, +textarea.form-control:disabled { + background-color: var(--dark-550) !important; + color: var(--muted) !important; + cursor: not-allowed; +} + +/* Switch */ +.form-switch .form-check-input { + background-color: var(--dark-400) !important; + border-color: var(--dark-300) !important; +} + +.form-switch .form-check-input:checked { + background-color: var(--primary) !important; + border-color: var(--primary-border) !important; +} + +/* Placeholders */ +.form-control::placeholder, +.bg-dark .form-control::placeholder { + color: #aaa !important; + opacity: 1 !important; +} + +/* Paired corners (utility) */ +#tempToggle { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.create-list-input-group > input.form-control { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +/* XXL custom checkbox */ +input[type="checkbox"].large-checkbox { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + width: 1.5em; + height: 1.5em; + margin: 0; + padding: 0; + outline: none; + background: none; + cursor: pointer; + position: relative; + vertical-align: middle; +} + +input[type="checkbox"].large-checkbox::before { + content: '✗'; + color: #dc3545; + font-size: 1.6em; + font-weight: 700; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + transition: color .2s; +} + +input[type="checkbox"].large-checkbox:checked::before { + content: '✓'; + color: #fff; +} + +input[type="checkbox"].large-checkbox:disabled::before { + opacity: .5; + cursor: not-allowed; +} + +input[type="checkbox"].large-checkbox:disabled { + cursor: not-allowed; +} + +/* Tom-Select / TS */ +.tom-dark .ts-control { + background-color: var(--dark-700) !important; + color: #fff !important; + border: 1px solid var(--dark-300) !important; + border-radius: .375rem; + min-height: 38px; + padding: .25rem .5rem; + box-sizing: border-box; +} + +.tom-dark .ts-control .item { + background-color: var(--dark-400) !important; + color: #fff !important; + border-radius: .25rem; + padding: 2px 8px; + margin-right: 4px; +} + +.ts-dropdown { + background-color: var(--dark-700) !important; + color: #fff !important; + border: 1px solid var(--dark-300); + border-radius: .375rem; + z-index: 9999 !important; + max-height: 300px; + overflow-y: auto; +} + +.ts-dropdown .active { + background-color: var(--dark-300) !important; + color: #fff !important; +} + +td select.tom-dark { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +/* ========================================================= + Alerts, Badges, Background helpers +========================================================= */ +/* Alerts */ +.alert-success { + background-color: #225d36 !important; + color: var(--success-text) !important; + border-color: #174428 !important; +} + +.alert-danger { + background-color: #7a1f23 !important; + color: var(--danger-text) !important; + border-color: #531417 !important; +} + +.alert-info { + background-color: var(--primary) !important; + color: var(--primary-text) !important; + border-color: var(--primary-border) !important; +} + +.alert-warning { + background-color: var(--warning) !important; + color: var(--warning-text) !important; + border-color: var(--warning-border) !important; +} + +.alert-light { + background-color: #3a3f44 !important; + color: var(--text-strong) !important; + border-color: var(--dark-480) !important; +} + +/* Badges */ +.badge.bg-success, +.badge.text-bg-success { + background-color: #225d36 !important; + color: var(--success-text) !important; +} + +.badge.bg-danger, +.badge.text-bg-danger { + background-color: #7a1f23 !important; + color: var(--danger-text) !important; +} + +.badge.bg-info, +.badge.text-bg-info { + background-color: #1d3a4d !important; + color: #eaf6ff !important; +} + +.badge.bg-warning, +.badge.text-bg-warning { + background-color: var(--warning) !important; + color: var(--warning-text) !important; +} + +.badge.bg-secondary, +.badge.text-bg-secondary { + background-color: var(--dark-400) !important; + color: #e2e3e5 !important; +} + +.badge.bg-primary, +.badge.text-bg-primary { + background-color: var(--primary) !important; + color: var(--primary-text) !important; +} + +.badge.bg-light, +.badge.text-bg-light { + background-color: var(--dark-350) !important; + color: #f1f3f5 !important; +} + +.badge.bg-dark, +.badge.text-bg-dark { + background-color: var(--dark-900) !important; + color: var(--text-strong) !important; +} + +/* ========================================================= + Progress +========================================================= */ +.progress-dark { + background-color: var(--dark-700) !important; + border-radius: 20px !important; + overflow: hidden; +} + +.progress { + background-color: #2a2d31 !important; + border-radius: 20px !important; +} + +.progress-bar { + border-radius: 0 !important; + transition: width .4s ease, background-color .4s ease; + background-color: var(--progress-default) !important; +} + +.progress-bar:first-child { + border-top-left-radius: 20px !important; + border-bottom-left-radius: 20px !important; +} + +.progress-bar:last-child { + border-top-right-radius: 20px !important; + border-bottom-right-radius: 20px !important; +} + +.progress-bar.bg-success { + background-color: var(--success) !important; +} + +.progress-bar.bg-danger { + background-color: var(--danger) !important; +} + +.progress-bar.bg-warning { + background-color: var(--warning) !important; + color: #fff !important; +} + +.progress-bar.bg-info { + background-color: #16425a !important; +} + +/* Label (parent must be position-relative) */ +.progress-label { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + white-space: nowrap; +} + +/* ========================================================= + Cards & Tables +========================================================= */ +.card { + background-color: var(--dark-500) !important; + border: 1px solid var(--dark-450) !important; + color: var(--text) !important; +} + +.card-header, +.card-footer { + background-color: var(--dark-650) !important; + border-color: var(--dark-450) !important; + color: #f1f3f5 !important; +} + +.card .table { + border-radius: 0 !important; + overflow: hidden; + margin-bottom: 0; +} + +.table-responsive { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.table-responsive table { + min-width: 1000px; +} + +.table-dark.table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(255, 255, 255, .025); +} + +.table-dark tbody tr:hover { + background-color: rgba(255, 255, 255, .04); +} + +.table-dark thead th { + background-color: var(--dark-800); + color: var(--text-dim); + font-weight: 500; + border-bottom: 1px solid var(--dark-450); +} + +.table-dark td, +.table-dark th { + padding: .6rem .75rem; + vertical-align: middle; + border-top: 1px solid var(--dark-450); +} + +/* ========================================================= + Navs & Pagination +========================================================= */ +.nav-tabs .nav-link.active, +.nav-tabs .nav-item.show .nav-link { + background-color: var(--dark-500) !important; + color: var(--text-strong) !important; + border-color: var(--dark-450) var(--dark-450) var(--dark-500) !important; +} + +.page-link { + color: #e0e0e0 !important; + background-color: var(--dark-750) !important; + border: 1px solid var(--dark-450) !important; +} + +.page-link:hover, +.page-link:focus { + color: #fff !important; + background-color: var(--dark-400) !important; + border-color: var(--dark-300) !important; +} + +.page-item.active .page-link { + color: #fff !important; + background-color: var(--primary) !important; + border-color: var(--primary-border) !important; +} + +.page-item.disabled .page-link { + color: var(--muted) !important; + background-color: var(--dark-550) !important; + border-color: var(--dark-450) !important; +} + +/* ========================================================= + Lists & Misc UI +========================================================= */ +.list-group-item { + display: flex; + align-items: center; + justify-content: space-between; +} + +.list-group-item:first-child, +.list-group-item:last-child { + border-radius: 0 !important; +} + +#items li.hide-purchased { + display: none !important; +} + +#mass-add-list li { + transition: background .2s; +} + +#mass-add-list li.active { + background: #198754 !important; + color: #fff !important; + border: 1px solid #000 !important; +} + +.quantity-input { + width: 60px; + background: var(--dark-400); + color: #fff; + border: 1px solid var(--dark-300); + border-radius: 4px; + text-align: center; +} + +.add-btn { + margin-left: 10px; +} + +.quantity-controls { + min-width: 120px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +/* ========================================================= + Toasts & Info Bar +========================================================= */ +.toast { + animation: fadeInUp .5s ease; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Base toast when not using text-bg-* */ +.toast:not([class*="text-bg-"]) { + background-color: var(--dark-500) !important; + color: #f1f1f1 !important; + border: 1px solid var(--dark-450) !important; + animation: fadeInUp .5s ease; +} + +.toast .toast-body { + color: inherit !important; +} + +.toast .btn-close { + filter: invert(1) grayscale(100%) brightness(200%); +} + +/* Typed toasts (override Bootstrap text-bg-*) */ +.toast.text-bg-primary { + background-color: var(--info) !important; + color: var(--info-text) !important; + border-color: var(--info-border) !important; +} + +.toast.text-bg-info { + background-color: var(--info) !important; + color: var(--info-text) !important; + border-color: var(--info-border) !important; +} + +.toast.text-bg-success { + background-color: var(--success) !important; + color: var(--success-text) !important; + border-color: var(--success-border) !important; +} + +.toast.text-bg-warning { + background-color: var(--warning) !important; + color: var(--warning-text) !important; + border-color: var(--warning-border) !important; +} + +.toast.text-bg-danger { + background-color: var(--danger) !important; + color: var(--danger-text) !important; + border-color: var(--danger-border) !important; +} + +.toast-body { + color: #fff !important; + font-weight: 500 !important; +} + +.info-bar-fixed { + width: 100%; + color: var(--text-strong); + background-color: var(--dark-700); + border-radius: 12px 12px 0 0; + text-align: center; + padding: 10px 10px; + font-size: .95rem; + box-sizing: border-box; + margin-top: 2rem; + box-shadow: 0 -1px 4px rgba(0, 0, 0, .25); +} + +@media (max-width: 768px) { + .info-bar-fixed { + position: static; + font-size: .85rem; + padding: 8px 4px; + border-radius: 0; + } +} + +/* ========================================================= + Modals (incl. fullscreen chart modal) +========================================================= */ +.modal-content { + background-color: var(--dark-470) !important; + color: #f1f1f1 !important; + border: 1px solid var(--dark-450) !important; +} + +.modal-header, +.modal-footer { + background-color: var(--dark-650) !important; + border-color: var(--dark-450) !important; +} + +/* Fullscreen chart modal */ +#chartFullscreenModal .modal-dialog { + max-width: 100vw; + width: 100vw; + margin: 0; +} + +#chartFullscreenModal .modal-content { + height: 100vh; + border-radius: 0; +} + +#chartFullscreenModal .modal-body { + display: flex; + flex: 1 1 auto; + padding: 0; + overflow: hidden; +} + +#chartFullscreenCanvas { + display: block; + width: 100%; + height: 100%; +} + +/* ========================================================= + Dropdown (TS already above) — active +========================================================= */ +.ts-dropdown .active { + background-color: var(--dark-300) !important; +} + +.list-group-item.bg-success { + background-color: var(--success) !important; + border-color: var(--success-border) !important; + color: var(--success-text) !important; + --bs-bg-opacity: 1 !important; +} + +.list-group-item.bg-warning { + background-color: var(--warning) !important; + border-color: var(--warning-border) !important; + color: var(--warning-text) !important; + --bs-bg-opacity: 1 !important; +} + +.btn-outline-light { + color: #f8f9fa !important; + border-color: #f8f9fa !important; + background-color: transparent !important; + /* brak białego tła domyślnie */ +} + +.btn-outline-light:hover, +.btn-outline-light:focus { + background-color: #6c757d !important; + /* szare, jak wcześniej */ + color: #fff !important; + border-color: #6c757d !important; +} + +.btn-outline-light:active, +.btn-outline-light.active, +.show>.btn-outline-light.dropdown-toggle { + background-color: #5a6268 !important; + /* ciemniejsze szare na active */ + color: #fff !important; + border-color: #545b62 !important; +} + +.btn-outline-info { + color: var(--info) !important; + border-color: var(--info) !important; + background-color: transparent !important; +} + +.btn-outline-info:hover, +.btn-outline-info:focus { + background-color: #1d4d8c !important; + border-color: #1d4d8c !important; + color: var(--info-text) !important; +} + +.btn-outline-info:active, +.btn-outline-info.active, +.show>.btn-outline-info.dropdown-toggle { + background-color: var(--info) !important; + border-color: var(--info-border) !important; + color: var(--info-text) !important; +} + +/* Tekstowe kolory */ +.text-success { + color: var(--success) !important; +} + +.text-warning { + color: var(--warning) !important; +} + +.text-info { + color: var(--info) !important; +} + +.text-danger { + color: var(--danger) !important; +} + + +.settings-category-card { + background: rgba(255,255,255,.03); + border: 1px solid rgba(255,255,255,.09); + border-radius: 16px; + padding: 1rem; + height: 100%; +} + +.settings-category-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; +} + +.settings-category-name { + font-size: 1rem; + font-weight: 700; + line-height: 1.2; +} + +.settings-override-badge { + white-space: nowrap; +} + +.settings-color-controls { + display: flex; + align-items: stretch; + gap: .75rem; +} + +.settings-color-controls .category-color { + width: 72px; + min-width: 72px; + height: auto; + padding: .35rem; + border-radius: 14px !important; + border: 1px solid rgba(255,255,255,.14); + background: rgba(255,255,255,.04); +} + +.settings-color-actions { + display: flex; + flex: 1 1 auto; +} + +.settings-color-actions .btn { + flex: 1 1 0; + min-height: 44px; + border-radius: 14px !important; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; +} + +.settings-color-actions .btn + .btn { + margin-left: .5rem; +} + +@media (min-width: 992px) { + .settings-category-name { + font-size: 1.08rem; + } +} + +@media (max-width: 767.98px) { + .settings-category-card { + padding: .9rem; + } + + .settings-color-controls { + flex-direction: column; + } + + .settings-color-controls .category-color { + width: 100%; + min-width: 0; + height: 48px; + } + + .settings-color-actions { + flex-direction: column; + } + + .settings-color-actions .btn + .btn { + margin-left: 0; + margin-top: .5rem; + } +} + +/* ========== Kolorowe wskaźniki pod pickerem ========== */ +.color-indicators .indicator { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: .5rem; +} + +.color-indicators .bar { + height: 10px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,.25); + box-shadow: inset 0 0 0 1px rgba(0,0,0,.25); +} + +/* ========== Swatch + zapisy heksowe ========== */ +.swatch { + width: 16px; + height: 16px; + border-radius: 50%; + display: inline-block; + border: 1px solid rgba(0,0,0,.15); +} + +.hex, +.hex-label { + font-variant-numeric: lining-nums; + letter-spacing: .2px; +} + +/* ========== OCR textarea ========== */ +.settings-ocr-textarea { + font: inherit; + line-height: 1.45; +} + +/* ========== Odznaka poziomu czułości ========== */ +.sens-badge { font-weight: 600; } +.sens-low { background: rgba(108,117,125,.25); color: #ced4da; } /* szary */ +.sens-mid { background: rgba(13,110,253,.25); color: #9ec5fe; } /* niebieski */ +.sens-high { background: rgba(220,53,69,.25); color: #f1aeb5; } /* czerwony */ + +/* ========================================================= + COMPACT: przyciski akcji na listach + - Desktop: standard Bootstrap + - <=576px: kompakt +========================================================= */ + +/* <=420px: tylko emoji */ +@media (max-width: 420px) { + .btn-group-compact .btn-text { + display: none !important; + } + + .btn-group-compact .btn { + padding: 0.22rem 0.45rem; + min-width: auto; + font-size: 0.9rem; + line-height: 1.1; + } +} + +/* 421–576px: lekko ciaśniej, ale tekst zostaje */ +@media (min-width: 421px) and (max-width: 576px) { + .btn-group-compact .btn { + padding: 0.25rem 0.5rem; + font-size: 0.82rem; + line-height: 1.1; + } + + .btn-group-compact .btn-text { + font-size: 0.75rem; + } +} + +/* Medium-narrow screens */ +@media (min-width: 421px) and (max-width: 576px) { + .btn-group-compact .btn { + padding: 0.24rem 0.45rem; /* ciaśniej */ + font-size: 0.82rem; + line-height: 1.1; + } + + .btn-group-compact .btn-text { + font-size: 0.75rem; + } +} + + +/* ================================================ + RESPONSIVE NAVBAR + ================================================ */ + +/* Wąskie ekrany (np. iPhone 11) */ +@media (max-width: 420px) { + + .navbar .container-fluid { + gap: 4px; + } + + .navbar-brand-compact { + font-size: 0.9rem !important; + margin-right: 0.25rem; + white-space: nowrap; + } + .navbar-brand-compact .navbar-brand-text { + font-size: 0.95em; + } + + .user-info-compact { + font-size: 0.72rem !important; + line-height: 0.9; + white-space: nowrap; + } + .user-info-compact .badge { + font-size: 0.68rem; + padding: 0.2rem 0.45rem; + } + + .nav-buttons-compact .nav-btn-text { + display: none !important; + } + .nav-buttons-compact { + gap: 0.35rem !important; + flex-wrap: nowrap; + } + .nav-buttons-compact .btn { + padding: 0.22rem 0.45rem; + min-width: auto; + line-height: 1.1; + } +} + + +/* Małe ekrany (np. 421-576px) */ +@media (min-width: 421px) and (max-width: 576px) { + .navbar .container-fluid { + gap: 8px; + } + + .navbar-brand-compact { + font-size: 1.25rem !important; + white-space: nowrap; + } + + .user-info-compact { + font-size: 0.8rem !important; + white-space: nowrap; + } + .user-info-compact .badge { + font-size: 0.75rem; + } + + .nav-buttons-compact { + flex-wrap: nowrap; + } + .nav-buttons-compact .btn { + font-size: 0.8rem; + padding: 0.25rem 0.45rem; + } + .nav-buttons-compact .nav-btn-text { + font-size: 0.75rem; + } +} + +@media (max-width: 420px) { + .user-label-desktop { display: none !important; } + .user-label-mobile { display: inline !important; } +} + +@media (min-width: 421px) { + .user-label-desktop { display: inline !important; } + .user-label-mobile { display: none !important; } +} + +.category-dot-pure { + width: 10px; + height: 10px; + display: inline-block; + flex: 0 0 auto; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.8); + background-clip: padding-box; + vertical-align: middle; + margin-left: 0.35rem; + opacity: 1; + padding: 0; + line-height: 1; + font-size: 0; + text-indent: -9999px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); +} + +.category-dot-pure::before, +.category-dot-pure::after { + content: none !important; +} + + +/* Hover efekt */ +.category-dot:hover { + transform: scale(1.3) !important; + box-shadow: 0 2px 6px rgba(0,0,0,0.4) !important; +} + + +.list-title { + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + max-width: 70% !important; + display: inline-block !important; +} + +/* Bardzo małe ekrany */ +@media (max-width: 420px) { + .list-title { + max-width: 60% !important; + } +} + +/* ===== 2026 app refresh ===== */ +:root { + --app-bg: #07111f; + --app-bg-soft: #0d1b2d; + --app-surface: rgba(11, 23, 39, 0.88); + --app-surface-strong: rgba(15, 28, 46, 0.98); + --app-surface-muted: rgba(255, 255, 255, 0.04); + --app-border: rgba(255, 255, 255, 0.1); + --app-border-strong: rgba(255, 255, 255, 0.16); + --app-text: #f3f8ff; + --app-text-muted: #9fb0c8; + --app-success: #27d07d; + --app-warning: #f6c453; + --app-danger: #ff6b7a; + --app-shadow: 0 18px 50px rgba(0, 0, 0, 0.28); + --app-radius: 22px; +} + +html, body { + min-height: 100%; + background-color: var(--app-bg); + background-image: + radial-gradient(circle at top left, rgba(39, 208, 125, 0.18), transparent 24%), + radial-gradient(circle at top right, rgba(74, 144, 226, 0.16), transparent 22%), + linear-gradient(180deg, #09111d 0%, #08121f 38%, #060d18 100%); + background-repeat: no-repeat; + color: var(--app-text); +} + +html { + color-scheme: dark; +} + +body.app-body { + position: relative; + min-height: 100vh; + min-height: 100dvh; + min-height: 100svh; + display: flex; + flex-direction: column; + margin: 0; + font-feature-settings: "ss01" on, "cv02" on; + overflow-x: hidden; +} + + +@supports (padding: env(safe-area-inset-top)) { + html, body { + min-height: calc(100% + env(safe-area-inset-top, 0px) + env(safe-area-inset-bottom, 0px)); + } +} + +.app-backdrop { + position: fixed; + inset: 0; + pointer-events: none; + background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent 28%); +} + +.app-header { + z-index: 1035; + padding: calc(0.75rem + env(safe-area-inset-top, 0px)) 0 0; + backdrop-filter: blur(12px); +} + +.app-navbar { + background: transparent; +} + +.app-navbar .container-xxl { + border: 1px solid var(--app-border); + background: rgba(6, 15, 27, 0.74); + backdrop-filter: blur(16px); + border-radius: 999px; + min-height: 68px; + box-shadow: var(--app-shadow); +} + +.app-brand { + display: inline-flex; + align-items: center; + gap: 0.85rem; + font-weight: 800; + color: var(--app-text) !important; +} + +.app-brand__icon { + display: inline-grid; + place-items: center; + width: 2.6rem; + height: 2.6rem; + border-radius: 16px; + background: linear-gradient(135deg, rgba(39,208,125,0.22), rgba(74,144,226,0.18)); + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08); +} + +.app-brand__title { color: #ffffff; } +.app-brand__accent { color: #7ce4a8; margin-left: 0.3rem; } + +.app-navbar__actions, +.app-navbar__meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.app-user-chip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.45rem 0.4rem 0.75rem; + border-radius: 999px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); +} + +.app-user-chip__label { + font-size: 0.75rem; + color: var(--app-text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.app-main { + flex: 1 0 auto; + padding: 1rem 0 2.5rem; +} + +.app-content-frame { + padding: 0.25rem 0 0; +} + +.app-footer { + margin-top: auto; + padding: 1rem 0 calc(1.35rem + env(safe-area-inset-bottom, 0px)); +} + +.app-footer__inner { + border-top: 1px solid rgba(255,255,255,0.08); + padding-top: 1.25rem; +} + +h1, h2, h3, h4, h5, h6 { + color: #ffffff; + letter-spacing: -0.02em; +} + +.card, +.modal-content, +.dropdown-menu, +.list-group-item, +.table, +.alert, +.pagination .page-link, +.nav-tabs, +.input-group-text, +.form-control, +.form-select, +.btn, +.progress, +.toast { + border-radius: 18px; +} + +.card, +.modal-content, +.table-responsive, +.alert, +.list-group-item, +.pagination .page-link, +.nav-tabs, +.input-group-text, +.form-control, +.form-select, +.progress, +.toast, +.page-link, +.table, +.btn-group > .btn { + border-color: var(--app-border) !important; +} + +.card, +.modal-content, +.table-responsive, +.alert, +.list-group-item, +.progress, +.toast { + background: var(--app-surface) !important; + box-shadow: var(--app-shadow); + backdrop-filter: blur(10px); +} + +.card-body, +.modal-body, +.modal-header, +.modal-footer { + background: transparent; +} + +.bg-dark, +.table-dark, +.list-group-item.bg-dark, +.modal-content.bg-dark, +.card.bg-dark, +.card.bg-secondary, +.list-group-item.item-not-checked { + background: var(--app-surface) !important; + color: var(--app-text) !important; +} + +.card.bg-secondary.bg-opacity-10, +#share-card { + background: linear-gradient(180deg, rgba(16, 29, 49, 0.96), rgba(10, 20, 36, 0.94)) !important; +} + +.table-dark { + --bs-table-bg: transparent; + --bs-table-striped-bg: rgba(255,255,255,0.03); + --bs-table-hover-bg: rgba(255,255,255,0.05); + --bs-table-color: var(--app-text); + margin-bottom: 0; +} + +.table > :not(caption) > * > * { + padding: 0.9rem 1rem; + border-bottom-color: rgba(255,255,255,0.08); +} + +.list-group { + gap: 0.8rem; +} + +.list-group-item { + margin-bottom: 0; + padding: 1rem 1rem; + color: var(--app-text) !important; +} + +.list-group-item.bg-success { + background: linear-gradient(135deg, rgba(39,208,125,0.92), rgba(22,150,91,0.96)) !important; +} + +.list-group-item.bg-warning { + background: linear-gradient(135deg, rgba(246,196,83,0.96), rgba(224,164,26,0.96)) !important; + color: #1c1b17 !important; +} + +.btn { + border-radius: 14px; + font-weight: 600; + padding: 0.7rem 1rem; + box-shadow: none; +} + +.btn-sm { + padding: 0.55rem 0.85rem; + border-radius: 12px; +} + +.btn-success, +.btn-outline-success:hover { + background: linear-gradient(135deg, #29d17d, #1ea860); + border-color: rgba(41,209,125,0.9); +} + +.btn-outline-light, +.btn-outline-secondary, +.btn-outline-warning, +.btn-outline-primary, +.btn-outline-success { + background: rgba(255,255,255,0.02); +} + +.btn:hover, +.btn:focus { + transform: translateY(-1px); +} + +.form-control, +.form-select, +.input-group-text { + min-height: 48px; + background: rgba(5, 13, 23, 0.86) !important; + color: var(--app-text) !important; + box-shadow: none !important; +} + +.form-control::placeholder { color: rgba(210, 224, 244, 0.45); } +.form-control:focus, +.form-select:focus { + border-color: rgba(39,208,125,0.5) !important; + box-shadow: 0 0 0 0.2rem rgba(39,208,125,0.15) !important; +} + +.nav-tabs { + gap: 0.5rem; + border-bottom: none; + background: rgba(255,255,255,0.03); + padding: 0.4rem; +} + +.nav-tabs .nav-link { + border-radius: 14px; + color: var(--app-text-muted); + border: none; + padding: 0.8rem 1rem; +} + +.nav-tabs .nav-link.active { + background: rgba(39,208,125,0.12); + color: #fff; +} + +.progress { + overflow: hidden; + background: rgba(255,255,255,0.06); + min-height: 1rem; +} + +.badge { + border: 1px solid rgba(255,255,255,0.08); +} + +.pagination .page-link { + background: rgba(255,255,255,0.03); + color: var(--app-text); + margin: 0 0.15rem; +} + +.pagination .page-item.active .page-link { + background: rgba(39,208,125,0.18); + color: #fff; +} + +.modal-content { + overflow: hidden; +} + +.toast-container { z-index: 1200; } + +#items .list-group-item { + border-radius: 18px !important; + padding: 1rem 1rem; +} + +#items .btn-group { + gap: 0.4rem; +} + +#items .btn-group .btn { + border-radius: 12px !important; +} + +.large-checkbox { + width: 1.35rem; + height: 1.35rem; + accent-color: #29d17d; +} + +#share-card .badge, +#total-expense1, +#total-expense2, +#total-expense { + background: transparent; + color: #dfffea !important; +} + +#share-card, +.card, +.table-responsive, +.alert, +.modal-content, +#expenseChartWrapper, +#categoryChartWrapper { + border-radius: var(--app-radius) !important; +} + +.endpoint-login .app-content-frame, +.endpoint-system_auth .app-content-frame, +.endpoint-page_not_found .app-content-frame, +.endpoint-forbidden .app-content-frame { + max-width: 560px; + margin: 3rem auto 0; +} + +.endpoint-main_page .list-group-item, +.endpoint-expenses .card, +.endpoint-admin_panel .card, +.endpoint-view_list .card, +.endpoint-shared_list .card, +.endpoint-edit_my_list .card, +[class*="endpoint-admin_"] .card { + overflow: hidden; +} + +input[type="checkbox"].form-check-input { + width: 2.9rem; + height: 1.5rem; +} + +hr { + border-color: rgba(255,255,255,0.08); +} + +@media (max-width: 991.98px) { + .app-header { + padding-top: 0.55rem; + } + + .app-navbar .container-xxl { + border-radius: 26px; + padding-top: 0.8rem; + padding-bottom: 0.8rem; + align-items: flex-start; + } + + .app-navbar__actions, + .app-navbar__meta { + width: 100%; + justify-content: flex-start; + } +} + +@media (max-width: 767.98px) { + .app-main { + padding-top: 0.7rem; + } + + .card-body, + .list-group-item, + .modal-body, + .modal-header, + .modal-footer, + .table > :not(caption) > * > * { + padding-left: 0.85rem; + padding-right: 0.85rem; + } + + .btn-group, + .d-flex.gap-2, + .d-flex.gap-3 { + gap: 0.45rem !important; + } + + .btn-group > .btn, + .btn.w-100, + .input-group > .btn { + min-height: 46px; + } + + .input-group { + flex-wrap: wrap; + gap: 0.55rem; + } + + .input-group > .form-control, + .input-group > .form-select, + .input-group > .btn, + .input-group > .input-group-text { + width: 100% !important; + flex: 1 1 100% !important; + border-radius: 14px !important; + max-width: 100% !important; + } + + #items .d-flex.align-items-center.gap-2.flex-grow-1 { + width: 100%; + align-items: flex-start !important; + } + + #items .btn-group { + width: 100%; + margin-top: 0.85rem; + } + + #items .btn-group .btn { + flex: 1 1 0; + } + + .table-responsive { + border-radius: 18px; + } + + .pagination { + flex-wrap: wrap; + gap: 0.25rem; + justify-content: center; + } +} + +/* ========================================================= + Compact minimalist pass +========================================================= */ +:root { + --app-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + --app-radius: 14px; +} + +body.app-body { + font-size: 0.96rem; + line-height: 1.4; +} + +.app-header { + padding: 0.35rem 0 0; +} + +.app-navbar .container-xxl { + min-height: 54px; + border-radius: 18px; + box-shadow: 0 8px 22px rgba(0,0,0,0.16); +} + +.app-brand { + gap: 0.6rem; + font-size: 0.98rem; +} + +.app-brand__icon { + width: 2.1rem; + height: 2.1rem; + border-radius: 12px; + font-size: 1rem; +} + +.app-brand__title, +.app-brand__accent { + font-size: 0.98rem; +} + +.app-user-chip { + padding: 0.28rem 0.38rem 0.28rem 0.58rem; + gap: 0.4rem; +} + +.app-user-chip__label { + font-size: 0.68rem; + letter-spacing: 0.06em; +} + +.app-main { + padding: 0.65rem 0 1.4rem; +} + +.app-content-frame { + padding-top: 0.1rem; +} + +.app-footer { + margin-top: auto; + padding: 0.5rem 0 calc(0.85rem + env(safe-area-inset-bottom, 0px)); +} + +.app-footer__inner { + padding-top: 0.75rem; +} + +h1, h2, h3, h4, h5, h6 { + letter-spacing: -0.015em; + line-height: 1.15; +} + +h1, .h1 { font-size: clamp(1.45rem, 2vw, 1.9rem); } +h2, .h2 { font-size: clamp(1.2rem, 1.8vw, 1.5rem); } +h3, .h3 { font-size: clamp(1.02rem, 1.5vw, 1.2rem); } + +.card, +.modal-content, +.dropdown-menu, +.list-group-item, +.table, +.alert, +.pagination .page-link, +.nav-tabs, +.input-group-text, +.form-control, +.form-select, +.btn, +.progress, +.toast { + border-radius: 12px; +} + +.card, +.modal-content, +.table-responsive, +.alert, +.list-group-item, +.progress, +.toast { + box-shadow: 0 4px 14px rgba(0,0,0,0.12); + backdrop-filter: blur(8px); +} + +.card-header, +.card-footer, +.card-body, +.modal-header, +.modal-body, +.modal-footer { + padding: 0.75rem 0.85rem; +} + +.table > :not(caption) > * > * { + padding: 0.62rem 0.7rem; +} + +.table-responsive table { + min-width: 860px; +} + +.list-group { + gap: 0.5rem; +} + +.list-group-item { + padding: 0.72rem 0.8rem; +} + +.alert { + padding: 0.7rem 0.85rem; + margin-bottom: 0.8rem; +} + +.badge { + font-weight: 600; + padding: 0.38em 0.58em; +} + +.btn { + border-radius: 10px; + font-weight: 600; + padding: 0.52rem 0.8rem; + min-height: 40px; +} + +.btn-sm { + padding: 0.4rem 0.64rem; + min-height: 34px; + border-radius: 9px; +} + +.form-control, +.form-select, +.input-group-text { + min-height: 40px; + padding: 0.5rem 0.72rem; +} + +textarea.form-control { + min-height: 96px; +} + +.progress { + min-height: 0.8rem; +} + +.progress-label { + font-size: 0.72rem; + font-weight: 600; +} + +.nav-tabs { + gap: 0.35rem; + padding: 0.25rem; +} + +.nav-tabs .nav-link { + padding: 0.55rem 0.7rem; + border-radius: 10px; +} + +#items .list-group-item { + border-radius: 12px !important; + padding: 0.75rem 0.8rem; +} + +#items .btn-group { + gap: 0.25rem; +} + +#items .btn-group .btn { + border-radius: 9px !important; +} + +input[type="checkbox"].form-check-input { + width: 2.5rem; + height: 1.35rem; +} + +.large-checkbox { + width: 1.2rem; + height: 1.2rem; +} + +.toast { + padding: 0; +} + +.endpoint-main_page .card h2, +.endpoint-expenses .card h2, +.endpoint-edit_my_list .card h2, +.endpoint-login .card h2, +.endpoint-system_auth .card h2, +.endpoint-admin_panel .card h2, +[class*="endpoint-admin_"] .card h2 { + margin-bottom: 0.35rem; +} + +.endpoint-main_page .card p, +.endpoint-expenses .card p, +.endpoint-edit_my_list .card p, +.endpoint-login .card p, +.endpoint-system_auth .card p, +.endpoint-admin_panel .card p, +[class*="endpoint-admin_"] .card p { + margin-bottom: 0; +} + +@media (max-width: 991.98px) { + .app-navbar .container-xxl { + border-radius: 16px; + padding-top: 0.55rem; + padding-bottom: 0.55rem; + } +} + +@media (max-width: 767.98px) { + body.app-body { + font-size: 0.93rem; + } + + .app-header { + padding-top: 0.25rem; + } + + .app-main { + padding-top: 0.45rem; + } + + .app-navbar .container-xxl { + min-height: 50px; + border-radius: 14px; + } + + .app-brand { + gap: 0.45rem; + font-size: 0.92rem; + } + + .app-brand__icon { + width: 1.9rem; + height: 1.9rem; + border-radius: 10px; + } + + .app-user-chip { + padding: 0.22rem 0.32rem 0.22rem 0.5rem; + } + + .card-header, + .card-footer, + .card-body, + .modal-header, + .modal-body, + .modal-footer, + .list-group-item, + .table > :not(caption) > * > * { + padding-left: 0.68rem; + padding-right: 0.68rem; + } + + .list-group-item, + #items .list-group-item { + padding-top: 0.62rem; + padding-bottom: 0.62rem; + } + + .btn-group, + .d-flex.gap-2, + .d-flex.gap-3 { + gap: 0.35rem !important; + } + + .btn-group > .btn, + .btn.w-100, + .input-group > .btn, + .btn, + .form-control, + .form-select, + .input-group-text { + min-height: 38px; + } + + .input-group { + gap: 0.4rem; + } + + .table-responsive { + border-radius: 12px; + } + + .progress-label { + font-size: 0.66rem; + } + + .app-footer { + padding-bottom: calc(0.8rem + env(safe-area-inset-bottom, 0px)); + } +} + +/* === unified UI refresh: forms / tables / admin tools === */ +:root { + --ui-surface-1: rgba(10, 14, 24, 0.78); + --ui-surface-2: rgba(18, 25, 39, 0.92); + --ui-surface-3: rgba(33, 44, 67, 0.88); + --ui-border: rgba(255, 255, 255, 0.12); + --ui-border-strong: rgba(255, 255, 255, 0.18); + --ui-text-soft: rgba(255, 255, 255, 0.72); + --ui-success-soft: rgba(25, 135, 84, 0.18); + --ui-warning-soft: rgba(255, 193, 7, 0.16); +} + +.card, +.table-responsive, +.modal-content, +.dropdown-menu, +.toast, +.alert, +.list-group-item { + border: 1px solid var(--ui-border); +} + +.card.bg-dark, +.modal-content.bg-dark, +.dropdown-menu-dark, +.list-group-item.bg-dark, +.table-dark { + background: linear-gradient(180deg, var(--ui-surface-2), rgba(8, 12, 20, 0.96)) !important; +} + +.card.bg-secondary.bg-opacity-10, +.admin-shortcuts, +#bulkBar .card { + background: linear-gradient(180deg, rgba(22, 29, 45, 0.88), rgba(12, 18, 30, 0.88)) !important; +} + +.form-label, +label.form-label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.45rem; + font-size: 0.92rem; + font-weight: 600; + color: rgba(255,255,255,0.84); +} + +.form-text, +.text-secondary, +.text-white-50 { + color: var(--ui-text-soft) !important; +} + +.form-control, +.form-select, +.input-group-text, +textarea.form-control, +.form-control-color { + background: rgba(255,255,255,0.04) !important; + border: 1px solid var(--ui-border-strong) !important; + color: #fff !important; + box-shadow: none; + transition: border-color .18s ease, box-shadow .18s ease, background-color .18s ease, transform .18s ease; +} + +.form-control::placeholder, +textarea.form-control::placeholder { + color: rgba(255,255,255,0.42); +} + +.form-control:focus, +.form-select:focus, +textarea.form-control:focus, +.form-check-input:focus, +.btn:focus, +.btn:focus-visible { + border-color: rgba(25, 135, 84, 0.6) !important; + box-shadow: 0 0 0 0.2rem rgba(25, 135, 84, 0.16) !important; +} + +.input-group > .form-control, +.input-group > .form-select, +.input-group > .btn, +.input-group > .input-group-text { + min-height: 42px; +} + +textarea.form-control { + line-height: 1.45; + resize: vertical; +} + +.form-check { + padding: 0.65rem 0.9rem 0.65rem 2.8rem; + background: rgba(255,255,255,0.04); + border: 1px solid var(--ui-border); + border-radius: 12px; +} + +.form-check.form-switch { + min-height: 42px; +} + +.form-check-input { + background-color: rgba(255,255,255,0.14); + border-color: rgba(255,255,255,0.22); +} + +.btn { + letter-spacing: 0.01em; + transition: transform .18s ease, box-shadow .18s ease, background-color .18s ease, border-color .18s ease; +} + +.btn:hover, +.btn:focus-visible { + transform: translateY(-1px); +} + +.btn-success, +.btn-outline-success, +.btn-outline-light:hover, +.btn-outline-light:focus-visible, +.btn-outline-secondary:hover, +.btn-outline-secondary:focus-visible { + box-shadow: 0 10px 24px rgba(0,0,0,0.16); +} + +.btn-outline-light, +.btn-outline-secondary, +.btn-outline-warning, +.btn-outline-danger, +.btn-outline-success { + background: rgba(255,255,255,0.03); +} + +.btn-outline-light:hover, +.btn-outline-light:focus-visible { + background: rgba(255,255,255,0.1); +} + +.btn-outline-secondary:hover, +.btn-outline-secondary:focus-visible { + background: rgba(108, 117, 125, 0.18); +} + +.btn-outline-warning:hover, +.btn-outline-warning:focus-visible { + background: var(--ui-warning-soft); +} + +.btn-outline-danger:hover, +.btn-outline-danger:focus-visible { + background: rgba(220, 53, 69, 0.16); +} + +.btn-outline-success:hover, +.btn-outline-success:focus-visible { + background: var(--ui-success-soft); +} + +.btn-group-compact, +.admin-shortcuts .d-flex, +.stack-form, +.page-actions { + gap: 0.4rem; +} + +.btn-group-compact .btn { + padding: 0.3rem 0.55rem; + font-size: 0.82rem; +} + +.btn-group-compact .btn-text { + font-size: 0.78rem; +} + +.table-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 0.85rem; +} + +.table-toolbar__search { + max-width: 420px; + width: 100%; +} + +.table-toolbar__meta { + min-width: 120px; + text-align: right; +} + +.table { + --bs-table-bg: transparent; + --bs-table-striped-bg: rgba(255,255,255,0.02); + --bs-table-hover-bg: transparent; + --bs-table-border-color: rgba(255,255,255,0.08); + margin-bottom: 0; +} + +.table > :not(caption) > * > * { + border-bottom-width: 1px; + vertical-align: middle; +} + +.table thead th { + position: sticky; + top: 0; + z-index: 1; + background: rgba(11, 17, 28, 0.98) !important; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.74rem; + color: rgba(255,255,255,0.72); + border-bottom-color: rgba(255,255,255,0.14); +} + +.table tbody tr { + transition: background-color .15s ease, transform .15s ease; +} + +.table tbody tr:hover > * { + background: rgba(255,255,255,0.04) !important; +} + +.table td code, +.api-code-block code { + display: inline-block; + padding: 0.28rem 0.48rem; + border-radius: 8px; + background: rgba(255,255,255,0.08); + color: #d6f5e6; +} + +.api-code-block { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.pagination .page-link { + background: rgba(255,255,255,0.03); + border-color: var(--ui-border); + color: #fff; +} + +.pagination .page-item.active .page-link { + background: rgba(25, 135, 84, 0.95); + border-color: rgba(25, 135, 84, 0.95); +} + +.dropdown-item { + border-radius: 8px; +} + +.dropdown-item:hover, +.dropdown-item:focus { + background: rgba(255,255,255,0.08); +} + +form[data-unsaved-warning="true"].is-dirty { + position: relative; +} + +form[data-unsaved-warning="true"].is-dirty::after { + content: 'Niezapisane zmiany'; + position: sticky; + bottom: 0.75rem; + left: 100%; + display: inline-flex; + margin-top: 1rem; + padding: 0.38rem 0.68rem; + border-radius: 999px; + background: rgba(255, 193, 7, 0.18); + border: 1px solid rgba(255, 193, 7, 0.36); + color: #ffe08a; + font-size: 0.76rem; + font-weight: 700; +} + +.ui-password-toggle { + min-width: 52px; +} + +.ui-password-toggle.is-active { + background: rgba(255,255,255,0.1); +} + +.app-content-frame > h2 + .card, +.app-content-frame > .card:first-of-type { + margin-top: 0; +} + +@media (max-width: 767.98px) { + .table-toolbar { + align-items: stretch; + } + + .table-toolbar__meta { + text-align: left; + } + + .admin-shortcuts .btn { + flex: 1 1 calc(50% - 0.55rem); + } + + form[data-unsaved-warning="true"].is-dirty::after { + left: auto; + bottom: auto; + position: static; + margin-top: 0.75rem; + } +} + + +.admin-page-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.85rem; + flex-wrap: wrap; +} + +[data-admin-nav-body] { + display: flex; +} + +@media (max-width: 767.98px) { + [data-admin-nav-body] { + display: none; + width: 100%; + } + + [data-admin-nav-body].is-open { + display: flex; + } + + .admin-page-head > * { + width: 100%; + } + + .admin-page-head .btn { + width: 100%; + } + + .table-responsive table.table, + .is-admin-area table.table { + min-width: 100%; + } + + .table-responsive table.table thead, + .is-admin-area table.table thead { + display: none; + } + + .table-responsive table.table, + .table-responsive table.table tbody, + .table-responsive table.table tr, + .table-responsive table.table td, + .is-admin-area table.table, + .is-admin-area table.table tbody, + .is-admin-area table.table tr, + .is-admin-area table.table td { + display: block; + width: 100%; + } + + .table-responsive table.table tbody, + .is-admin-area table.table tbody { + display: grid; + gap: 0.8rem; + } + + .table-responsive table.table tr, + .is-admin-area table.table tr { + border: 1px solid rgba(255,255,255,0.08); + border-radius: 16px; + padding: 0.35rem 0.55rem; + background: rgba(255,255,255,0.02); + box-shadow: 0 8px 24px rgba(0,0,0,0.16); + } + + .table-responsive table.table td, + .is-admin-area table.table td { + border: 0; + padding: 0.5rem 0.35rem; + } + + .table-responsive table.table td::before, + .is-admin-area table.table td::before { + content: attr(data-label); + display: block; + margin-bottom: 0.18rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(255,255,255,0.58); + } + + .table-responsive table.table td:last-child, + .is-admin-area table.table td:last-child { + padding-bottom: 0.2rem; + } + + .table-responsive table.table td .btn, + .table-responsive table.table td .input-group, + .table-responsive table.table td .form-select, + .table-responsive table.table td .form-control, + .is-admin-area table.table td .btn, + .is-admin-area table.table td .input-group, + .is-admin-area table.table td .form-select, + .is-admin-area table.table td .form-control { + width: 100%; + } + + .table-responsive table.table td .d-flex, + .table-responsive table.table td .btn-group, + .is-admin-area table.table td .d-flex, + .is-admin-area table.table td .btn-group { + flex-wrap: wrap; + } +} + + +.list-action-block .input-group .btn, +.list-action-block .btn, +.endpoint-shared_list .input-group .btn, +.endpoint-shared_list .btn { + min-height: 44px; +} + +.endpoint-shared_list .input-group, +.list-action-block .input-group { + align-items: stretch; +} + +@media (max-width: 767.98px) { + .endpoint-admin_panel .table-responsive table thead { + display: none; + } + .endpoint-admin_panel .table-responsive table, + .endpoint-admin_panel .table-responsive tbody, + .endpoint-admin_panel .table-responsive tr, + .endpoint-admin_panel .table-responsive td { + display: block; + width: 100%; + } + .endpoint-admin_panel .table-responsive tr { + border: 1px solid rgba(255,255,255,.08); + border-radius: 16px; + margin-bottom: 1rem; + padding: .75rem; + background: rgba(255,255,255,.02); + } + .endpoint-admin_panel .table-responsive td { + border: 0; + padding: .35rem 0; + } +} + + +/* responsive fixes 2026-03 */ +.app-navbar .container-xxl {flex-wrap: nowrap; align-items: center;} +.app-navbar__actions {display:flex; flex-wrap:wrap; gap:.5rem; justify-content:flex-end;} +.app-navbar__actions .btn {white-space: nowrap;} +.table-responsive {overflow-x: clip;} +.table-responsive table {width:100%; min-width:0 !important;} +@media (max-width: 991.98px) { + .app-navbar .container-xxl {display:grid; grid-template-columns:auto 1fr auto; gap:.5rem; align-items:center;} + .app-navbar__meta {display:none;} + .app-brand {min-width:0;} + .app-brand__title,.app-brand__accent {font-size:1rem;} + .app-navbar__actions {max-width:100%; gap:.35rem;} + .app-navbar__actions .btn {padding:.35rem .55rem; font-size:.78rem;} +} +@media (max-width: 430px) { + .app-navbar .container-xxl {grid-template-columns:minmax(0,1fr) auto; } + .app-brand {overflow:hidden;} + .app-brand > span:last-child {overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} + .app-navbar__actions {grid-column:1 / -1; justify-content:stretch;} + .app-navbar__actions .btn {flex:1 1 calc(50% - .35rem); text-align:center;} +} +@media (max-width: 767.98px) { + .app-content-frame .table-responsive table.table, + .app-content-frame table.table:not(.keep-horizontal) {display:block; width:100%;} + .app-content-frame .table-responsive table.table thead, + .app-content-frame table.table:not(.keep-horizontal) thead {display:none;} + .app-content-frame .table-responsive table.table tbody, + .app-content-frame .table-responsive table.table tr, + .app-content-frame .table-responsive table.table td, + .app-content-frame table.table:not(.keep-horizontal) tbody, + .app-content-frame table.table:not(.keep-horizontal) tr, + .app-content-frame table.table:not(.keep-horizontal) td {display:block; width:100%;} + .app-content-frame .table-responsive table.table tr, + .app-content-frame table.table:not(.keep-horizontal) tr {border:1px solid var(--dark-300); border-radius:1rem; margin-bottom:.85rem; padding:.35rem .25rem; background:var(--dark-700);} + .app-content-frame .table-responsive table.table td, + .app-content-frame table.table:not(.keep-horizontal) td {border:none; padding:.5rem .75rem; text-align:left !important;} + .app-content-frame .table-responsive table.table td::before, + .app-content-frame table.table:not(.keep-horizontal) td::before {content:attr(data-label); display:block; font-size:.72rem; color:#9ba3aa; margin-bottom:.2rem; text-transform:uppercase;} + .app-content-frame .table-responsive {overflow:visible;} +} + +/* fix: admin tables, api tokens, share page, navbar */ +.admin-table-responsive { + overflow-x: auto !important; + -webkit-overflow-scrolling: touch; +} + + +.admin-table-responsive--wide table { + min-width: 1180px; +} + +.admin-table-responsive--full table { + width: 100% !important; + min-width: 100% !important; + table-layout: auto; +} + +.endpoint-edit_categories .admin-table-responsive--full table th, +.endpoint-edit_categories .admin-table-responsive--full table td, +.endpoint-api_tokens .admin-table-responsive--full table th, +.endpoint-api_tokens .admin-table-responsive--full table td { + white-space: normal; + vertical-align: middle; +} + +.endpoint-edit_categories .admin-table-responsive--full table th:last-child, +.endpoint-edit_categories .admin-table-responsive--full table td:last-child { + width: 30%; +} + +.is-admin-area .table-responsive td, +.is-admin-area .table-responsive th { + white-space: normal; +} + +.api-chip { + display: inline-block; + max-width: 22rem; + padding: .28rem .55rem; + border-radius: .75rem; + background: rgba(255,255,255,.08); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; +} + +.api-chip--wrap { + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.form-check-spaced { + display: flex; + align-items: flex-start; + gap: .7rem; + padding-left: 0; + margin-bottom: .65rem; +} + +.form-check-spaced .form-check-input { + position: static; + margin: .2rem 0 0; + flex: 0 0 auto; +} + +.form-check-spaced .form-check-label { + margin: 0; + line-height: 1.35; +} + +.list-item-actions { + gap: .4rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.list-item-actions .btn { + border-radius: .8rem !important; + min-width: 2.6rem; +} + +.share-submit-btn { + min-width: 8rem; + font-weight: 600; +} + +.endpoint-list_share .input-group, +.endpoint-shared_list .input-group { + align-items: stretch; +} + +.endpoint-list_share .input-group > .form-control, +.endpoint-list_share .input-group > .btn, +.endpoint-shared_list .input-group > .form-control, +.endpoint-shared_list .input-group > .btn { + min-height: 46px; +} + +.endpoint-list_share .input-group > .btn, +.endpoint-shared_list .input-group > .btn { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; +} + +.app-navbar .container-xxl { + row-gap: .55rem; +} + +.app-navbar__actions { + min-width: 0; +} + +.app-navbar__actions .btn { + min-width: 0; +} + +@media (max-width: 767.98px) { + .table-responsive { + overflow-x: auto !important; + } + + .is-admin-area .table-responsive table.table.keep-horizontal, + .endpoint-api_tokens .table-responsive table.table, + .endpoint-admin_panel .table-responsive table.table, + .endpoint-lists_access .table-responsive table.table, + .endpoint-user_management .table-responsive table.table, + .endpoint-edit_categories .table-responsive table.table { + display: table; + width: max-content; + min-width: 980px !important; + } + + .endpoint-api_tokens .admin-table-responsive--full table.table, + .endpoint-edit_categories .admin-table-responsive--full table.table { + width: 100%; + min-width: 980px !important; + } + + .is-admin-area .table-responsive table.table.keep-horizontal thead, + .endpoint-api_tokens .table-responsive table.table thead, + .endpoint-admin_panel .table-responsive table.table thead, + .endpoint-lists_access .table-responsive table.table thead, + .endpoint-user_management .table-responsive table.table thead { + display: table-header-group; + } + + .is-admin-area .table-responsive table.table.keep-horizontal tbody, + .endpoint-api_tokens .table-responsive table.table tbody, + .endpoint-admin_panel .table-responsive table.table tbody, + .endpoint-lists_access .table-responsive table.table tbody, + .endpoint-user_management .table-responsive table.table tbody { + display: table-row-group; + } + + .is-admin-area .table-responsive table.table.keep-horizontal tr, + .endpoint-api_tokens .table-responsive table.table tr, + .endpoint-admin_panel .table-responsive table.table tr, + .endpoint-lists_access .table-responsive table.table tr, + .endpoint-user_management .table-responsive table.table tr { + display: table-row; + border: 0; + padding: 0; + background: transparent; + box-shadow: none; + } + + .is-admin-area .table-responsive table.table.keep-horizontal td, + .endpoint-api_tokens .table-responsive table.table td, + .endpoint-admin_panel .table-responsive table.table td, + .endpoint-lists_access .table-responsive table.table td, + .endpoint-user_management .table-responsive table.table td { + display: table-cell; + width: auto; + border-top: 1px solid var(--dark-450); + padding: .65rem .75rem; + } + + .endpoint-api_tokens .table-responsive table.table td::before, + .endpoint-admin_panel .table-responsive table.table td::before, + .endpoint-lists_access .table-responsive table.table td::before, + .endpoint-user_management .table-responsive table.table td::before { + display: none; + content: none; + } +} + +@media (max-width: 575.98px) { + .app-navbar .container-xxl { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + } + + .app-navbar__meta { + display: none; + } + + .app-brand { + min-width: 0; + overflow: hidden; + } + + .app-brand > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app-navbar__actions { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .45rem; + width: 100%; + } + + .app-navbar__actions .btn { + width: 100%; + padding: .45rem .5rem; + font-size: .78rem; + } + + .share-submit-btn { + min-width: 6.75rem; + } + + .list-item-actions { + width: 100%; + justify-content: flex-start; + margin-top: .5rem; + } +} + + +/* admin/settings consistency fixes */ +.form-switch-compact .form-check-input { + width: 2.35rem; + height: 1.2rem; + margin-top: .1rem; +} +.form-switch-compact .form-check-label { + padding-top: .02rem; +} +.form-check-spaced { + gap: .45rem; +} +.access-editor .input-group > .form-control, +.access-editor .input-group > .btn { + min-height: 40px; +} +.endpoint-admin_edit_categories .table-responsive, +.endpoint-admin_lists_access .table-responsive, +.endpoint-admin_settings .table-responsive, +.endpoint-list_products .table-responsive { + overflow-x: auto !important; +} +.endpoint-admin_edit_categories .table-responsive table.table.keep-horizontal, +.endpoint-admin_lists_access .table-responsive table.table.keep-horizontal, +.endpoint-list_products .table-responsive table.table.keep-horizontal { + min-width: 1000px !important; +} +.endpoint-admin_edit_categories .app-content-frame, +.endpoint-admin_lists_access .app-content-frame, +.endpoint-admin_settings .app-content-frame, +.endpoint-list_products .app-content-frame { + overflow: visible; +} +@media (max-width: 767.98px) { + .form-switch-compact .form-check-input { width: 2rem; height: 1.05rem; } +} + + +/* v4.1 admin/table/share fixes */ +.admin-table-responsive table { + width: 100%; + min-width: 100%; +} +.admin-table-responsive--wide table, +.table-responsive .keep-horizontal { + width: max-content; + min-width: 100%; +} +.endpoint-admin_panel .admin-table-responsive--wide table { + width: 100%; + min-width: 100%; +} +.endpoint-admin_panel .admin-panel-table th:last-child, +.endpoint-admin_panel .admin-panel-table td:last-child { + width: 1%; + white-space: nowrap; +} +.endpoint-admin_lists_access .admin-table-responsive--wide table { + min-width: 1120px; +} +.endpoint-admin_lists_access .access-editor .tokens { + min-height: 2rem; +} +.endpoint-admin_lists_access .access-editor .token, +.endpoint-admin_edit_my_list .access-editor .token { + max-width: 100%; +} +.endpoint-admin_lists_access .access-editor .token span, +.endpoint-admin_edit_my_list .access-editor .token span { + pointer-events: none; +} +.endpoint-admin_lists_access .mono { + white-space: nowrap; +} +.form-check-spaced { + gap: .35rem; +} +.form-check-spaced .form-check-input, +input[type="checkbox"].form-check-input, +.table-select-checkbox { + width: .95rem !important; + height: .95rem !important; + min-width: .95rem !important; + min-height: .95rem !important; + margin-top: .18rem; +} +.form-switch-compact .form-check-input { + width: 1.8rem !important; + height: .95rem !important; +} +.large-checkbox { + transform: none; + transform-origin: center; +} + +.shopping-item-main > .large-checkbox { + flex: 0 0 1.5em; + align-self: center; +} +.list-item-actions { + display: flex; + align-items: center; + gap: .45rem; + flex-wrap: wrap; +} +.list-item-actions .btn { + min-width: 2.25rem; + padding: .42rem .62rem; +} +.endpoint-list_share .list-group-item, +.endpoint-shared_list .list-group-item { + gap: .75rem; +} +.endpoint-list_share .list-item-actions, +.endpoint-shared_list .list-item-actions { + margin-left: auto; +} +.endpoint-list_share .input-group, +.endpoint-shared_list .input-group { + flex-wrap: nowrap; +} +.endpoint-list_share .input-group > .form-control, +.endpoint-shared_list .input-group > .form-control { + min-width: 0; +} +.endpoint-list_share .share-submit-btn, +.endpoint-shared_list .share-submit-btn { + min-width: 7.25rem; + border-radius: .9rem !important; +} +@media (max-width: 991.98px) { + .endpoint-admin_panel .admin-panel-table { + min-width: 1000px; + } +} +@media (max-width: 767.98px) { + .endpoint-admin_panel .admin-table-responsive--wide table, + .endpoint-admin_lists_access .admin-table-responsive--wide table, + .endpoint-api_tokens .admin-table-responsive--wide table { + width: max-content; + min-width: 980px !important; + } + .endpoint-list_share .input-group, + .endpoint-shared_list .input-group { + flex-wrap: wrap; + } + .endpoint-list_share .share-submit-btn, + .endpoint-shared_list .share-submit-btn { + width: 100%; + } +} +@media (max-width: 430px) { + .app-brand__icon { + width: 2rem; + height: 2rem; + font-size: 1rem; + } + .app-brand__title, .app-brand__accent { + font-size: 1rem; + } + .app-navbar__actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .app-navbar__actions .btn { + padding: .38rem .45rem; + font-size: .74rem; + } +} + +.endpoint-admin_api_tokens .admin-table-responsive--wide table { width: 100%; min-width: 100%; } +@media (max-width: 767.98px) { .endpoint-admin_api_tokens .admin-table-responsive--wide table { width:max-content; min-width: 980px !important; } } +.table-responsive { overflow-x: auto; } + + +/* v6 tweaks */ +.create-list-switch, +.hide-purchased-switch { + display: inline-flex; + align-items: center; + gap: .5rem; + padding: .45rem .8rem .45rem 2.35rem; + min-height: 0; + width: auto; + background: rgba(255,255,255,0.04); + border: 1px solid var(--ui-border); + border-radius: 12px; +} +.create-list-switch .form-check-input, +.hide-purchased-switch .form-check-input { + width: 2rem !important; + height: 1rem !important; + margin-top: 0; +} +.create-list-switch .form-check-label, +.hide-purchased-switch .form-check-label { + margin-left: .15rem; +} +.endpoint-admin_lists_access .card > .card-body > .table-responsive, +.endpoint-admin_api_tokens .card > .card-body > .table-responsive { + width: 100%; +} +.endpoint-admin_lists_access .table.keep-horizontal, +.endpoint-admin_api_tokens .table.keep-horizontal { + min-width: 100%; +} + + +/* v7.1 share/main fixes */ +.create-list-checkbox { + align-items: center; + gap: .55rem; +} +.create-list-checkbox .form-check-input { + margin-top: 0; +} +.endpoint-list_share #items .list-group-item, +.endpoint-shared_list #items .list-group-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: .75rem; +} +.endpoint-list_share #items .list-group-item > .d-flex.flex-grow-1, +.endpoint-shared_list #items .list-group-item > .d-flex.flex-grow-1 { + min-width: 0; + flex: 1 1 auto; +} +.endpoint-list_share .list-item-actions, +.endpoint-shared_list .list-item-actions { + flex: 0 0 auto; + margin-left: auto; + justify-content: flex-end; +} +.endpoint-list_share .list-item-actions .btn, +.endpoint-shared_list .list-item-actions .btn { + min-width: 2.5rem; +} +.endpoint-list_share .hide-purchased-switch, +.endpoint-shared_list .hide-purchased-switch { + align-items: center; +} +.endpoint-list_share .hide-purchased-switch .form-check-input, +.endpoint-shared_list .hide-purchased-switch .form-check-input { + margin-top: 0; +} +@media (max-width: 767.98px) { + .endpoint-list_share #items .list-group-item, + .endpoint-shared_list #items .list-group-item { + align-items: flex-start; + } + .endpoint-list_share .list-item-actions, + .endpoint-shared_list .list-item-actions { + width: 100%; + margin-left: 0; + justify-content: flex-start; + } +} + + +/* v9.1 switch and share consistency fixes */ +.create-list-input-group > .form-control { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} +.create-list-input-group > #tempToggle { + min-width: 9.75rem; + font-weight: 600; + white-space: nowrap; +} +.hide-purchased-switch.form-check { + display: inline-flex; + align-items: center; + gap: .7rem; + padding: .55rem .95rem; + padding-left: .95rem; + border-radius: 14px; + background: rgba(255,255,255,.04); + border: 1px solid var(--ui-border); +} +.hide-purchased-switch .form-check-input { + flex: 0 0 auto; + float: none; + width: 2.9em !important; + height: 1.5em !important; + margin: 0 !important; + cursor: pointer; +} +.hide-purchased-switch .form-check-label { + margin: 0 !important; + white-space: nowrap; + cursor: pointer; +} +.share-page-toolbar { + gap: .75rem; +} +.share-page-toolbar__spacer { + flex: 1 1 auto; +} +.endpoint-list_share .list-item-actions, +.endpoint-shared_list .list-item-actions { + gap: .5rem; +} +.endpoint-list_share .list-item-actions .btn, +.endpoint-shared_list .list-item-actions .btn { + min-width: 2.75rem; + min-height: 2.5rem; + padding: .5rem .72rem; +} +.endpoint-list_share .app-navbar__actions .btn, +.endpoint-shared_list .app-navbar__actions .btn { + border-radius: .9rem !important; +} +@media (max-width: 767.98px) { + .create-list-input-group { + flex-wrap: nowrap !important; + } + .create-list-input-group > .form-control { + min-width: 0; + } + .create-list-input-group > #tempToggle { + min-width: 8.75rem; + font-size: .92rem; + } + .share-page-toolbar { + justify-content: flex-end; + } + .share-page-toolbar__spacer { + display: none; + } + .hide-purchased-switch { + padding-left: 2.95rem; + } +} + + +/* unified bootstrap-like switches */ +.switch-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: .8rem; +} + +.form-check.form-switch.app-switch { + display: inline-flex; + align-items: center; + gap: .75rem; + min-height: auto; + margin: 0; + padding: .72rem .95rem; + padding-left: .95rem; + background: rgba(255,255,255,.04); + border: 1px solid var(--ui-border); + border-radius: 14px; +} + +.form-check.form-switch.app-switch .form-check-input { + float: none; + flex: 0 0 auto; + width: 2.9em !important; + height: 1.55em !important; + margin: 0 !important; + cursor: pointer; + background-color: var(--dark-400) !important; + border-color: var(--dark-300) !important; +} + +.form-check.form-switch.app-switch .form-check-input:checked { + background-color: var(--primary) !important; + border-color: var(--primary-border) !important; +} + +.form-check.form-switch.app-switch .form-check-input:focus { + box-shadow: 0 0 0 .18rem rgba(24, 64, 118, .22) !important; +} + +.form-check.form-switch.app-switch .form-check-label { + margin: 0 !important; + line-height: 1.35; + cursor: pointer; +} + +.form-check.form-switch.app-switch.form-switch-compact { + width: 100%; + justify-content: flex-start; +} + +.form-check.form-switch.app-switch.form-switch-compact .form-check-input { + width: 2.9em !important; + height: 1.55em !important; +} + +.hide-purchased-switch.form-check.app-switch { + width: auto; +} + +.endpoint-edit_my_list .switch-grid .app-switch, +.endpoint-admin_edit_list .switch-grid .app-switch { + width: 100%; +} + +@media (max-width: 767.98px) { + .switch-grid { + grid-template-columns: 1fr; + } + + .hide-purchased-switch.form-check.app-switch { + width: 100%; + } +} + + +/* final UX polish 2026-03-14 */ +:root { + --nav-btn-height: 2.8rem; +} + +.app-navbar .container-xxl { + display: flex; + align-items: center; + justify-content: space-between; + gap: .8rem; + flex-wrap: nowrap; +} + +.app-navbar__actions { + display: flex; + align-items: stretch; + justify-content: flex-end; + gap: .5rem; + flex-wrap: nowrap; + min-width: 0; +} + +.app-navbar__actions .btn, +.app-nav-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--nav-btn-height); + padding: .6rem .95rem; + white-space: nowrap; + line-height: 1; +} + +.app-navbar__actions .btn > span, +.app-nav-action > span { + display: inline-flex; + align-items: center; +} + +.form-check.form-switch.app-switch { + min-height: 3.2rem; + padding: .78rem 1rem; + border-radius: 16px; +} + +.form-check.form-switch.app-switch .form-check-input { + width: 3.15em !important; + height: 1.7em !important; + background-position: left center; +} + +.form-check.form-switch.app-switch .form-check-label { + display: inline-flex; + align-items: center; + min-height: 1.7rem; + font-weight: 500; +} + +.hide-purchased-switch.form-check.app-switch { + width: auto; + max-width: 100%; +} + +.endpoint-edit_my_list .switch-grid { + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.endpoint-edit_my_list .switch-grid .app-switch, +.endpoint-admin_edit_list .switch-grid .app-switch { + width: 100%; + min-height: 3.35rem; +} + +/* boxed checks in api token form */ +.endpoint-admin_api_tokens .stack-form .form-check-spaced, +.endpoint-api_tokens .stack-form .form-check-spaced { + align-items: center; + gap: .85rem; + margin: 0 0 .72rem; + padding: .75rem .9rem; + border: 1px solid var(--ui-border); + border-radius: 14px; + background: rgba(255,255,255,.04); +} + +.endpoint-admin_api_tokens .stack-form .form-check-spaced .form-check-input, +.endpoint-api_tokens .stack-form .form-check-spaced .form-check-input { + margin: 0; +} + +.endpoint-admin_api_tokens .stack-form .form-check-spaced .form-check-label, +.endpoint-api_tokens .stack-form .form-check-spaced .form-check-label { + flex: 1 1 auto; +} + +/* admin tables full width on desktop, scroll only on smaller screens */ +.endpoint-admin_panel .table-responsive, +.endpoint-admin_lists_access .table-responsive { + overflow-x: auto; +} + +.endpoint-admin_panel .table-responsive > table.table, +.endpoint-admin_lists_access .table-responsive > table.table { + width: 100% !important; + min-width: 100% !important; + table-layout: auto; +} + +.endpoint-admin_lists_access td:nth-child(6) { + min-width: 19rem; +} + +.endpoint-admin_lists_access td:nth-child(6) > .d-flex, +.endpoint-admin_lists_access td:nth-child(6) > .text-warning { + width: 100%; +} + +.endpoint-admin_lists_access td:nth-child(6) > .text-warning { + display: block; +} + +/* share page toolbar and header buttons */ +.share-page-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: .75rem; + width: 100%; +} + +.share-page-toolbar .form-check { + margin-bottom: 0; +} + +.endpoint-list_share .app-navbar__actions, +.endpoint-shared_list .app-navbar__actions { + align-items: stretch; +} + +.endpoint-list_share .app-navbar__actions .btn, +.endpoint-shared_list .app-navbar__actions .btn { + min-height: var(--nav-btn-height); +} + +@media (max-width: 991.98px) { + .app-navbar .container-xxl { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: .6rem; + } + + .app-navbar__meta { + display: none; + } + + .app-brand { + min-width: 0; + overflow: hidden; + } + + .app-brand > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app-navbar__actions { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .45rem; + width: 100%; + } + + .app-navbar__actions .btn, + .app-nav-action { + width: 100%; + padding: .55rem .6rem; + } + + .endpoint-admin_panel .table-responsive > table.table, + .endpoint-admin_lists_access .table-responsive > table.table { + width: max-content !important; + min-width: 980px !important; + } +} + +@media (max-width: 767.98px) { + .share-page-toolbar { + justify-content: stretch; + } + + .hide-purchased-switch.form-check.app-switch { + width: 100%; + } + + .endpoint-edit_my_list .switch-grid { + grid-template-columns: 1fr; + } +} + + +/* final polish v2 */ +:root { + --nav-btn-height: 2.35rem; +} + +.app-navbar__actions { + gap: .4rem; +} + +.app-navbar__actions .btn, +.app-nav-action { + min-height: var(--nav-btn-height); + padding: .42rem .78rem; + font-size: .92rem; + border-radius: .9rem !important; +} + +.form-check.form-switch.app-switch { + min-height: 2.95rem; + padding: .65rem .9rem; +} + +.form-check.form-switch.app-switch .form-check-input { + width: 2.75em !important; + height: 1.45em !important; + transition: background-position .18s ease-in-out, background-color .18s ease-in-out, border-color .18s ease-in-out, box-shadow .18s ease-in-out !important; +} + +.form-check.form-switch.app-switch .form-check-label { + min-height: 1.45rem; +} + +.endpoint-admin_templates .table-responsive { + overflow-x: auto; +} + +.endpoint-admin_templates .table-responsive > table.table { + width: 100% !important; + min-width: 100% !important; + table-layout: auto; +} + +@media (max-width: 991.98px) { + .app-navbar__actions .btn, + .app-nav-action { + font-size: .86rem; + padding: .48rem .6rem; + } + + .endpoint-admin_templates .table-responsive > table.table { + width: max-content !important; + min-width: 900px !important; + } +} + + +/* responsive mobile category badges + smaller animated switches */ +.mobile-list-heading { + width: 100%; + min-width: 0; + max-width: 100%; + justify-content: flex-start; +} + +.mobile-list-heading__title { + min-width: 0; +} + +.mobile-category-badges { + display: inline-flex; + align-items: center; + gap: .3rem; + min-width: 0; + max-width: 100%; +} + +.mobile-category-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: .68rem; + line-height: 1; + padding: .26rem .52rem; + opacity: .95; +} + +.mobile-category-badge__dot { + display: none; + width: .55rem; + height: .55rem; + border-radius: 999px; + background: currentColor; +} + +.mobile-category-badges.is-compact .mobile-category-badge { + width: .9rem; + min-width: .9rem; + height: .9rem; + padding: 0; + border-radius: 999px; +} + +.mobile-category-badges.is-compact .mobile-category-badge__text { + display: none; +} + +.mobile-category-badges.is-compact .mobile-category-badge__dot { + display: block; +} + +.form-check.form-switch.app-switch { + min-height: 2.75rem; + padding: .58rem .82rem; +} + +.form-check.form-switch.app-switch .form-check-input, +.hide-purchased-switch .form-check-input { + width: 2.45em !important; + height: 1.3em !important; + background-position: left center !important; + transition: background-position .18s ease-in-out, background-color .18s ease-in-out, border-color .18s ease-in-out, box-shadow .18s ease-in-out !important; +} + +.form-check.form-switch.app-switch .form-check-input:checked, +.hide-purchased-switch .form-check-input:checked { + background-position: right center !important; +} + +.form-check.form-switch.app-switch .form-check-label { + min-height: 1.3rem; +} + +.hide-purchased-switch.form-check.app-switch { + padding: .5rem .82rem; +} + +@media (max-width: 576px) { + .mobile-list-heading { + display: inline-flex; + max-width: 100%; + } + + .mobile-list-heading__title { + max-width: 100%; + } +} + + +.endpoint-main_page .list-group-item > .main-list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + flex-wrap: nowrap; +} + +.endpoint-main_page .list-main-meta { + min-width: 0; + flex: 1 1 auto; +} + +.endpoint-main_page .list-main-title { + display: flex; + align-items: center; + align-content: center; + flex-wrap: wrap; + gap: 0.15rem; + min-width: 0; + line-height: 1; +} + +.endpoint-main_page .list-main-actions { + flex: 0 0 auto; + align-self: center; +} + +@media (max-width: 575.98px) { + .endpoint-main_page .list-group-item > .main-list-row { + flex-direction: column; + align-items: stretch; + } + + .endpoint-main_page .list-main-actions { + width: 100%; + } +} + +/* mobile UX fixes 2026-03-14 */ +.list-main-title__link { + min-width: 0; + display: inline-flex; + align-items: center; + align-content: center; + flex-wrap: wrap; + gap: .15rem; + line-height: 1; +} + +.shopping-item-row { + gap: .75rem; +} + +.shopping-item-main { + min-width: 0; + flex: 1 1 auto; + flex-wrap: wrap; +} + +.shopping-item-main span[id^="name-"] { + min-width: 0; +} + +.shopping-item-actions { + flex: 0 0 auto; + margin-left: auto; + align-self: flex-start; +} + +.shopping-compact-input-group { + flex-wrap: nowrap !important; + align-items: stretch; +} + +.shopping-qty-input { + flex: 0 0 4.5rem; + max-width: 4.5rem; +} + +.shopping-compact-submit { + flex: 0 0 auto; + width: auto; + white-space: nowrap; +} + +.ui-password-group { + flex-wrap: nowrap; +} + +.ui-password-group > .form-control { + min-width: 0; +} + +.ui-password-group > .ui-password-toggle { + flex: 0 0 auto; + width: auto; + min-width: 3rem; +} + +@media (max-width: 991.98px) { + .app-navbar__actions { + grid-template-columns: repeat(auto-fit, minmax(8.25rem, max-content)); + justify-content: end; + } + + .app-navbar__actions .btn, + .app-nav-action { + width: auto; + min-width: 8.25rem; + justify-self: end; + } +} + +@media (max-width: 575.98px) { + .endpoint-main_page .list-group-item > .main-list-row { + flex-direction: row; + align-items: center; + } + + .endpoint-main_page .list-main-actions { + width: auto; + margin-left: auto; + } + + .endpoint-main_page .list-main-actions .btn { + padding: .38rem .52rem; + min-width: 2.35rem; + } + + .endpoint-main_page .list-main-title { + display: flex; + flex-wrap: wrap; + gap: .15rem; + } + + .endpoint-main_page .list-main-meta { + flex: 1 1 auto; + min-width: 0; + } + + .endpoint-main_page .list-main-title__link { + min-width: 0; + max-width: 100%; + } + + .shopping-item-row { + align-items: flex-start !important; + } + + .shopping-item-actions { + width: auto; + margin-top: 0; + margin-left: auto; + justify-content: flex-end; + } + + .shopping-item-actions .btn { + min-width: 2.35rem; + padding: .38rem .52rem; + } + + .shopping-compact-input-group { + display: flex; + } + + .shopping-compact-input-group > .form-control { + min-width: 0; + } + + .shopping-qty-input { + flex-basis: 4rem; + max-width: 4rem; + } + + .shopping-compact-submit { + min-width: auto; + padding-left: .8rem; + padding-right: .8rem; + } + + .ui-password-group > .ui-password-toggle { + min-width: 2.75rem; + padding-left: .7rem; + padding-right: .7rem; + } +} + + +/* UX refactor 2026-03-14 b */ +.app-navbar-toggler { + border-color: rgba(255,255,255,.28); + padding: .3rem .55rem; +} + +.app-navbar-toggler:focus { + box-shadow: 0 0 0 .2rem rgba(255,255,255,.1); +} + +.app-navbar-toggler .navbar-toggler-icon { + filter: invert(1) grayscale(1); +} + +#createListTempToggle, +.create-list-temp-toggle { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; +} + +.create-list-input-group > .form-control { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.create-list-input-group > .create-list-temp-toggle { + background: transparent; + white-space: nowrap; +} + +.list-toolbar { + width: 100%; +} + +.list-toolbar--share { + justify-content: flex-end; +} + +.hide-purchased-switch--minimal { + border: 0; + background: transparent; + padding: 0; + margin-left: auto; +} + +.shopping-item-row { + padding: .8rem .95rem; +} + +.shopping-item-main { + display: flex; + align-items: center; + gap: .75rem; + width: 100%; +} + +.shopping-item-content { + flex: 1 1 auto; + min-width: 0; +} + +.shopping-item-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: .75rem; +} + +.shopping-item-text { + min-width: 0; + display: flex; + align-items: center; + gap: .35rem; + flex-wrap: wrap; +} + +.shopping-item-name { + min-width: 0; + overflow-wrap: break-word; + word-break: normal; +} + +.shopping-item-text .info-line { + flex-basis: 100%; + margin-top: .1rem; + overflow-wrap: break-word; + word-break: normal; +} + +.shopping-item-actions { + display: inline-flex; + flex-wrap: nowrap; + gap: .35rem; +} + +.shopping-product-input-group > .shopping-product-name-input, +.shopping-expense-input-group > .shopping-expense-amount-input { + flex: 1 1 auto; +} + +.shopping-product-input-group > .shopping-qty-input { + flex: 0 0 4.5rem; + max-width: 4.5rem; + text-align: center; +} + +.shopping-expense-input-group > .shopping-compact-submit, +.shopping-product-input-group > .shopping-compact-submit { + flex: 0 0 auto; +} + +.endpoint-list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-list_share .shopping-product-input-group > .shopping-compact-submit, +.endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-list .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-top-right-radius: .9rem !important; + border-bottom-right-radius: .9rem !important; + margin-left: 0; +} + +.endpoint-list .shopping-product-input-group, +.endpoint-list_share .shopping-product-input-group, +.endpoint-shared_list .shopping-product-input-group, +.endpoint-list .shopping-expense-input-group, +.endpoint-list_share .shopping-expense-input-group, +.endpoint-shared_list .shopping-expense-input-group { + flex-wrap: nowrap; +} + +@media (max-width: 991.98px) { + .navbar-collapse .app-navbar__actions { + padding-top: .6rem; + justify-content: flex-end; + } +} + +@media (max-width: 575.98px) { + .app-navbar__actions { + width: 100%; + justify-content: flex-end; + } + + .app-navbar__actions .btn, + .app-nav-action { + min-width: 0; + width: auto; + } + + .shopping-item-main { + gap: .55rem; + } + + .shopping-item-head { + gap: .45rem; + } + + .shopping-item-actions { + margin-left: auto; + align-self: flex-start; + } + + .shopping-item-actions .btn { + min-width: 2.2rem; + padding: .34rem .48rem; + } + + .shopping-product-input-group > .shopping-product-name-input, + .shopping-expense-input-group > .shopping-expense-amount-input { + flex: 0 0 60%; + min-width: 0; + } + + .shopping-product-input-group > .shopping-qty-input { + flex: 0 0 15%; + max-width: 15%; + min-width: 0; + } + + .shopping-product-input-group > .shopping-compact-submit { + flex: 0 0 25%; + width: 25%; + min-width: 0; + padding-left: .55rem; + padding-right: .55rem; + font-size: .95rem; + } + + .shopping-expense-input-group > .shopping-compact-submit { + padding-left: .7rem; + padding-right: .7rem; + } + + .list-toolbar { + align-items: flex-start !important; + } + + .list-toolbar__sort { + flex: 0 0 auto; + } + + .hide-purchased-switch--minimal { + font-size: .95rem; + } +} + + +/* UX tweak 2026-03-14 c: hamburger with full labels */ +@media (max-width: 991.98px) { + .navbar-collapse .app-navbar__actions { + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + gap: .5rem; + } + + .navbar-collapse .app-navbar__actions .btn, + .navbar-collapse .app-nav-action { + width: 100%; + min-width: 0; + justify-content: flex-start; + text-align: left; + padding-left: .9rem; + padding-right: .9rem; + } + + .navbar-collapse .app-navbar__actions .btn > span, + .navbar-collapse .app-nav-action > span { + display: inline !important; + } +} + + +/* form input/button unification fix 2026-03-15 */ +.endpoint-list .shopping-product-input-group, +.endpoint-list_share .shopping-product-input-group, +.endpoint-shared_list .shopping-product-input-group, +.endpoint-list .shopping-expense-input-group, +.endpoint-list_share .shopping-expense-input-group, +.endpoint-shared_list .shopping-expense-input-group { + display: flex; + flex-wrap: nowrap !important; + align-items: stretch; + gap: 0 !important; +} + +.endpoint-list .shopping-product-input-group > .form-control, +.endpoint-list_share .shopping-product-input-group > .form-control, +.endpoint-shared_list .shopping-product-input-group > .form-control, +.endpoint-list .shopping-expense-input-group > .form-control, +.endpoint-list_share .shopping-expense-input-group > .form-control, +.endpoint-shared_list .shopping-expense-input-group > .form-control, +.endpoint-list .shopping-product-input-group > .btn, +.endpoint-list_share .shopping-product-input-group > .btn, +.endpoint-shared_list .shopping-product-input-group > .btn, +.endpoint-list .shopping-expense-input-group > .btn, +.endpoint-list_share .shopping-expense-input-group > .btn, +.endpoint-shared_list .shopping-expense-input-group > .btn { + position: relative; + min-height: 46px; + box-shadow: none; +} + +.endpoint-list .shopping-product-input-group > .shopping-product-name-input, +.endpoint-list_share .shopping-product-input-group > .shopping-product-name-input, +.endpoint-shared_list .shopping-product-input-group > .shopping-product-name-input, +.endpoint-list .shopping-expense-input-group > .shopping-expense-amount-input, +.endpoint-list_share .shopping-expense-input-group > .shopping-expense-amount-input, +.endpoint-shared_list .shopping-expense-input-group > .shopping-expense-amount-input { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.endpoint-list .shopping-product-input-group > .shopping-qty-input, +.endpoint-list_share .shopping-product-input-group > .shopping-qty-input, +.endpoint-shared_list .shopping-product-input-group > .shopping-qty-input { + border-radius: 0 !important; + border-left-width: 0 !important; +} + +.endpoint-list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-list_share .shopping-product-input-group > .shopping-compact-submit, +.endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-list .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .35rem; + margin-left: 0 !important; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-top-right-radius: .9rem !important; + border-bottom-right-radius: .9rem !important; + border-left-width: 0 !important; +} + +.endpoint-list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-list .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-list_share .shopping-product-input-group > .shopping-compact-submit, +.endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-list .share-submit-btn, +.endpoint-list_share .share-submit-btn, +.endpoint-shared_list .share-submit-btn { + min-width: 7.25rem; +} + +.shopping-btn-icon { + line-height: 1; +} + +.shopping-btn-label { + line-height: 1; +} + +@media (max-width: 767.98px) { + .endpoint-list .shopping-product-input-group, + .endpoint-list_share .shopping-product-input-group, + .endpoint-shared_list .shopping-product-input-group, + .endpoint-list .shopping-expense-input-group, + .endpoint-list_share .shopping-expense-input-group, + .endpoint-shared_list .shopping-expense-input-group { + width: 100%; + } + + .endpoint-list .shopping-product-input-group > .shopping-product-name-input, + .endpoint-list_share .shopping-product-input-group > .shopping-product-name-input, + .endpoint-shared_list .shopping-product-input-group > .shopping-product-name-input { + flex: 0 0 60% !important; + max-width: 60% !important; + min-width: 0; + } + + .endpoint-list .shopping-product-input-group > .shopping-qty-input, + .endpoint-list_share .shopping-product-input-group > .shopping-qty-input, + .endpoint-shared_list .shopping-product-input-group > .shopping-qty-input { + flex: 0 0 15% !important; + max-width: 15% !important; + min-width: 0; + padding-left: .35rem; + padding-right: .35rem; + } + + .endpoint-list .shopping-product-input-group > .shopping-compact-submit, + .endpoint-list_share .shopping-product-input-group > .shopping-compact-submit, + .endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit { + flex: 0 0 25% !important; + width: 25% !important; + min-width: 0 !important; + padding-left: .4rem; + padding-right: .4rem; + } + + .endpoint-list .shopping-expense-input-group > .shopping-expense-amount-input, + .endpoint-list_share .shopping-expense-input-group > .shopping-expense-amount-input, + .endpoint-shared_list .shopping-expense-input-group > .shopping-expense-amount-input { + flex: 1 1 auto !important; + min-width: 0; + } + + .endpoint-list .shopping-expense-input-group > .shopping-compact-submit, + .endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit, + .endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit { + flex: 0 0 5rem !important; + width: 5rem !important; + min-width: 5rem !important; + padding-left: .35rem; + padding-right: .35rem; + } + + .endpoint-list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-label, + .endpoint-list_share .shopping-product-input-group > .shopping-compact-submit .shopping-btn-label, + .endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-label, + .endpoint-list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-label, + .endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-label, + .endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-label { + display: none; + } + + .endpoint-list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-icon, + .endpoint-list_share .shopping-product-input-group > .shopping-compact-submit .shopping-btn-icon, + .endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-icon, + .endpoint-list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-icon, + .endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-icon, + .endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-icon { + margin: 0; + font-size: 1rem; + } +} + + +/* endpoint fix for /list route */ +.endpoint-view_list .shopping-product-input-group, +.endpoint-view_list .shopping-expense-input-group { + display: flex; + flex-wrap: nowrap !important; + align-items: stretch; + gap: 0 !important; + width: 100%; +} + +.endpoint-view_list .shopping-product-input-group > .form-control, +.endpoint-view_list .shopping-expense-input-group > .form-control, +.endpoint-view_list .shopping-product-input-group > .btn, +.endpoint-view_list .shopping-expense-input-group > .btn { + position: relative; + min-height: 46px; + box-shadow: none; +} + +.endpoint-view_list .shopping-product-input-group > .shopping-product-name-input, +.endpoint-view_list .shopping-expense-input-group > .shopping-expense-amount-input { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.endpoint-view_list .shopping-product-input-group > .shopping-qty-input { + border-radius: 0 !important; + border-left-width: 0 !important; +} + +.endpoint-view_list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-view_list .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-view_list .share-submit-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .35rem; + margin-left: 0 !important; + min-width: 7.25rem; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-top-right-radius: .9rem !important; + border-bottom-right-radius: .9rem !important; + border-left-width: 0 !important; +} + +@media (max-width: 767.98px) { + .endpoint-view_list .shopping-product-input-group > .shopping-product-name-input { + flex: 0 0 60% !important; + max-width: 60% !important; + min-width: 0; + } + + .endpoint-view_list .shopping-product-input-group > .shopping-qty-input { + flex: 0 0 15% !important; + max-width: 15% !important; + min-width: 0; + padding-left: .35rem; + padding-right: .35rem; + } + + .endpoint-view_list .shopping-product-input-group > .shopping-compact-submit { + flex: 0 0 25% !important; + width: 25% !important; + min-width: 0 !important; + padding-left: .4rem; + padding-right: .4rem; + } + + .endpoint-view_list .shopping-expense-input-group > .shopping-expense-amount-input { + flex: 1 1 auto !important; + min-width: 0; + } + + .endpoint-view_list .shopping-expense-input-group > .shopping-compact-submit { + flex: 0 0 5rem !important; + width: 5rem !important; + min-width: 5rem !important; + padding-left: .35rem; + padding-right: .35rem; + } + + .endpoint-view_list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-label, + .endpoint-view_list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-label { + display: none; + } + + .endpoint-view_list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-icon, + .endpoint-view_list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-icon { + margin: 0; + font-size: 1rem; + } +} + +/* --- JS render + progress bar consistency patch --- */ +#progress-bar-purchased { + background: linear-gradient(135deg, rgba(39,208,125,0.96), rgba(22,150,91,0.98)) !important; +} + +#progress-bar-not-purchased { + background: linear-gradient(135deg, rgba(246,196,83,0.96), rgba(224,164,26,0.98)) !important; +} + +#progress-bar-remaining { + background: rgba(255,255,255,0.08) !important; +} + + +/* v10.2 item row consistency and mobile share fixes */ +.shopping-item-text { + line-height: 1.35; +} + +.shopping-item-text .info-line { + display: inline; + flex-basis: auto; + margin-top: 0; + white-space: normal; +} + +.shopping-item-text .info-line > span { + display: inline; +} + +.shopping-item-text .item-added-by-meta { + color: currentColor; + opacity: .72; + font-size: .92em; +} + +.shopping-item-text .item-added-by-meta b { + font-weight: 600; +} + +.shopping-item-name, +.shopping-item-text .info-line { + overflow-wrap: break-word; + word-break: normal; +} + +.shopping-item-actions { + flex: 0 0 auto; +} + +@media (max-width: 575.98px) { + .shopping-item-head { + flex-wrap: wrap; + align-items: flex-start; + } + + .shopping-item-text { + flex: 1 1 100%; + min-width: 0; + } + + .shopping-item-actions { + width: 100%; + margin-left: 0; + justify-content: flex-end; + } +} + + +/* v10.3 alignment fixes for list/share */ +.hide-purchased-switch--right { + display: inline-flex !important; + align-items: center; + justify-content: flex-end; + gap: .6rem; + width: auto; + margin-left: auto; + padding: 0 !important; + border: 0 !important; + background: transparent !important; + box-shadow: none !important; +} + +.hide-purchased-switch--right::before, +.hide-purchased-switch--right::after { + display: none !important; +} + +.hide-purchased-switch--right .form-check-input { + order: 1; + margin: 0; + float: none; + flex: 0 0 auto; +} + +.hide-purchased-switch--right .form-check-label { + order: 0; + margin: 0 !important; + white-space: nowrap; +} + +.list-action-row { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + gap: .65rem; +} + +.list-action-row__form { + display: flex; + flex: 1 1 50%; +} + +.list-action-row__btn { + width: 100%; +} + +.list-action-row > .list-action-row__btn { + flex: 1 1 50%; +} + +.shopping-item-head { + flex-wrap: nowrap; + align-items: center; +} + +.shopping-item-text { + flex: 1 1 auto; + min-width: 0; +} + +.shopping-item-text .info-line { + flex-basis: auto; +} + +.shopping-item-actions { + align-items: center; + justify-content: flex-end; + flex: 0 0 auto; + white-space: nowrap; +} + +.shopping-item-actions .btn { + flex: 0 0 auto; +} + +@media (max-width: 575.98px) { + .shopping-item-main { + align-items: center; + } + + .shopping-item-head { + flex-wrap: nowrap; + align-items: center; + gap: .4rem; + } + + .shopping-item-text { + flex: 1 1 auto; + min-width: 0; + gap: .25rem; + } + + .shopping-item-actions { + width: auto; + margin-left: auto; + gap: .25rem; + } + + .shopping-item-actions .btn { + min-width: 2rem; + padding: .3rem .42rem; + } + + .hide-purchased-switch--right { + width: auto; + max-width: 100%; + } + + .list-action-row { + gap: .5rem; + } + + .list-action-row > .list-action-row__btn, + .list-action-row__form { + flex: 1 1 50%; + min-width: 0; + } + + .list-action-row__btn { + padding-left: .55rem; + padding-right: .55rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + + +/* layout polish 2026-03-15: toolbar, sorting and item alignment */ +.list-header-toolbar { + width: 100%; +} + +.list-header-toolbar > h2 { + flex: 1 1 auto; + min-width: 0; +} + +.list-header-toolbar .list-toolbar { + flex: 0 0 auto; + width: auto; + margin-left: auto; +} + +.list-toolbar__sort.btn { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + border-width: 1px !important; +} + +.drag-handle { + display: none !important; + cursor: grab; +} + +body.sorting-active .drag-handle { + display: inline-flex !important; +} + +body.sorting-active .shopping-item-row { + cursor: grab; +} + +body.sorting-active .shopping-item-row:active, +body.sorting-active .drag-handle:active { + cursor: grabbing; +} + +body.sorting-active .shopping-item-row .large-checkbox { + pointer-events: none; +} + +.endpoint-list_share .shopping-item-name, +.endpoint-list_share .shopping-item-text .info-line, +.endpoint-shared_list .shopping-item-name, +.endpoint-shared_list .shopping-item-text .info-line { + overflow-wrap: break-word; + word-break: normal; +} + +.endpoint-list_share input[type="checkbox"].large-checkbox::before, +.endpoint-shared_list input[type="checkbox"].large-checkbox::before, +.endpoint-view_list input[type="checkbox"].large-checkbox::before { + font-size: 1.7em; +} + +@media (max-width: 575.98px) { + .endpoint-view_list .list-toolbar { + display: grid !important; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center !important; + gap: .4rem !important; + flex-wrap: nowrap !important; + } + + .endpoint-view_list .list-toolbar__sort.btn { + min-width: 0; + padding: .35rem .55rem; + font-size: .82rem; + } + + .endpoint-view_list .hide-purchased-switch--right { + min-width: 0; + gap: .25rem; + font-size: .82rem; + } + + .endpoint-view_list .hide-purchased-switch--right .form-check-label { + margin-left: .25rem !important; + white-space: nowrap; + } + + .endpoint-view_list .hide-purchased-switch--right .form-check-input { + transform: scale(.92); + transform-origin: center; + } + + .list-header-toolbar { + align-items: flex-start !important; + } + + .list-header-toolbar .list-toolbar { + width: 100%; + justify-content: flex-end !important; + } +} + +/* hotfix 2026-03-15 v3: /share item layout parity with /list */ +.endpoint-list_share .shopping-item-row, +.endpoint-shared_list .shopping-item-row { + overflow: hidden; +} + +.endpoint-list_share .shopping-item-main, +.endpoint-shared_list .shopping-item-main { + align-items: center; +} + +.endpoint-list_share .shopping-item-content, +.endpoint-shared_list .shopping-item-content { + flex: 1 1 auto; + min-width: 0; + width: 100%; +} + +.endpoint-list_share .shopping-item-head, +.endpoint-shared_list .shopping-item-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + column-gap: .65rem; + row-gap: .35rem; +} + +.endpoint-list_share .shopping-item-text, +.endpoint-shared_list .shopping-item-text { + display: flex; + flex-wrap: wrap; + align-items: center; + align-content: center; + min-width: 0; + width: 100%; + gap: .35rem; +} + +.endpoint-list_share .shopping-item-name, +.endpoint-shared_list .shopping-item-name { + display: inline; + min-width: 0; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.endpoint-list_share .shopping-item-text .info-line, +.endpoint-shared_list .shopping-item-text .info-line { + display: block; + flex: 0 0 100%; + width: 100%; + margin-top: .15rem; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.endpoint-list_share .shopping-item-actions, +.endpoint-shared_list .shopping-item-actions { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + align-self: center; + justify-content: flex-end; + margin-left: 0; + white-space: nowrap; +} + +.endpoint-list_share .shopping-item-actions .btn, +.endpoint-shared_list .shopping-item-actions .btn { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.endpoint-list_share .shopping-item-main > .large-checkbox, +.endpoint-shared_list .shopping-item-main > .large-checkbox, +.endpoint-view_list .shopping-item-main > .large-checkbox { + align-self: center; +} + +@media (max-width: 575.98px) { + .endpoint-list_share .shopping-item-head, + .endpoint-shared_list .shopping-item-head { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + } + + .endpoint-list_share .shopping-item-actions, + .endpoint-shared_list .shopping-item-actions { + align-self: start; + gap: .3rem; + } + + .endpoint-list_share .shopping-item-actions .btn, + .endpoint-shared_list .shopping-item-actions .btn { + min-width: 2.15rem; + padding: .32rem .45rem; + } +} + +/* hotfix 2026-03-15 v4: /share parity with /list */ +.endpoint-list_share .shopping-item-row, +.endpoint-shared_list .shopping-item-row { + padding: .8rem .95rem; +} + +.endpoint-list_share .shopping-item-main, +.endpoint-shared_list .shopping-item-main { + display: flex; + align-items: center; + gap: .75rem; + width: 100%; +} + +.endpoint-list_share .shopping-item-content, +.endpoint-shared_list .shopping-item-content { + flex: 1 1 auto; + min-width: 0; + width: auto; +} + +.endpoint-list_share .shopping-item-head, +.endpoint-shared_list .shopping-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; +} + +.endpoint-list_share .shopping-item-text, +.endpoint-shared_list .shopping-item-text { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + align-content: center; + gap: .35rem; + flex-wrap: wrap; +} + +.endpoint-list_share .shopping-item-name, +.endpoint-shared_list .shopping-item-name { + display: inline; + min-width: 0; + max-width: 100%; + white-space: normal; + overflow-wrap: break-word; + word-break: normal; +} + +.endpoint-list_share .shopping-item-text .badge, +.endpoint-shared_list .shopping-item-text .badge, +.endpoint-list_share .shopping-item-text .info-line, +.endpoint-shared_list .shopping-item-text .info-line { + align-self: center; +} + +.endpoint-list_share .shopping-item-text .info-line, +.endpoint-shared_list .shopping-item-text .info-line { + display: block; + flex: 0 0 100%; + width: 100%; + margin-top: .1rem; + white-space: normal; + overflow-wrap: break-word; + word-break: normal; +} + +.endpoint-list_share .shopping-item-actions, +.endpoint-shared_list .shopping-item-actions { + display: inline-flex; + flex: 0 0 auto; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: .35rem; + white-space: nowrap; +} + +.endpoint-list_share .shopping-item-actions .btn, +.endpoint-shared_list .shopping-item-actions .btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.25rem; + padding: .34rem .48rem; +} + +.endpoint-list_share .shopping-item-main > .large-checkbox, +.endpoint-shared_list .shopping-item-main > .large-checkbox { + flex: 0 0 auto; + align-self: center; +} + +.endpoint-list_share input[type="checkbox"].large-checkbox::before, +.endpoint-shared_list input[type="checkbox"].large-checkbox::before { + font-size: 1.75em; +} + +@media (max-width: 575.98px) { + .endpoint-list_share .shopping-item-main, + .endpoint-shared_list .shopping-item-main { + gap: .55rem; + } + + .endpoint-list_share .shopping-item-head, + .endpoint-shared_list .shopping-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: .45rem; + } + + .endpoint-list_share .shopping-item-text, + .endpoint-shared_list .shopping-item-text { + flex: 1 1 auto; + min-width: 0; + gap: .25rem; + } + + .endpoint-list_share .shopping-item-actions, + .endpoint-shared_list .shopping-item-actions { + width: auto; + margin-left: auto; + gap: .25rem; + } + + .endpoint-list_share .shopping-item-actions .btn, + .endpoint-shared_list .shopping-item-actions .btn { + min-width: 2rem; + padding: .3rem .42rem; + } +} + +/* mobile menu simplification 2026-03-15 */ +.app-mobile-menu { + display: flex; + align-items: center; +} + +.app-mobile-menu__toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border-radius: 14px; + background: rgba(255,255,255,0.04); +} + +.app-mobile-menu__panel { + min-width: 14rem; + padding: .4rem; + border-radius: 16px; + background: linear-gradient(180deg, rgba(14,22,35,0.98), rgba(9,15,26,0.98)) !important; + border: 1px solid rgba(255,255,255,0.1); + box-shadow: 0 18px 36px rgba(0,0,0,0.28); +} + +.app-mobile-menu__item { + display: flex; + align-items: center; + gap: .55rem; + min-height: 2.6rem; + padding: .55rem .75rem; + color: #fff; + border-radius: 12px; +} + +.app-mobile-menu__item:hover, +.app-mobile-menu__item:focus { + background: rgba(255,255,255,0.08); + color: #fff; +} + +@media (min-width: 992px) { + .app-mobile-menu { + display: none !important; + } +} + + +/* v5.1 create-list temp toggle mobile/Desktop fix */ +.create-list-input-group { + display: flex; + flex-wrap: nowrap !important; + align-items: stretch; + gap: 0 !important; +} + +.create-list-input-group > .form-control { + flex: 1 1 auto !important; + width: 1% !important; + min-width: 0 !important; + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.create-list-input-group > .create-list-temp-toggle, +.create-list-input-group > #tempToggle { + flex: 0 0 auto !important; + width: auto !important; + min-width: 10rem; + margin-left: -1px; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-top-right-radius: 14px !important; + border-bottom-right-radius: 14px !important; + background: rgba(255,255,255,0.03) !important; + border-color: var(--app-border) !important; + color: var(--app-text) !important; + transition: background-color .18s ease, border-color .18s ease, color .18s ease, box-shadow .18s ease; +} + +.create-list-input-group > .create-list-temp-toggle.is-active, +.create-list-input-group > #tempToggle.is-active { + background: rgba(41, 209, 125, 0.16) !important; + border-color: rgba(41, 209, 125, 0.72) !important; + color: #9bf0c1 !important; + box-shadow: inset 0 0 0 1px rgba(41, 209, 125, 0.15); +} + +.create-list-input-group > .create-list-temp-toggle:hover, +.create-list-input-group > #tempToggle:hover, +.create-list-input-group > .create-list-temp-toggle:focus, +.create-list-input-group > #tempToggle:focus { + background: rgba(255,255,255,0.06) !important; + color: var(--app-text) !important; +} + +.create-list-input-group > .create-list-temp-toggle.is-active:hover, +.create-list-input-group > #tempToggle.is-active:hover, +.create-list-input-group > .create-list-temp-toggle.is-active:focus, +.create-list-input-group > #tempToggle.is-active:focus { + background: rgba(41, 209, 125, 0.2) !important; + color: #b7f7d2 !important; +} + +@media (max-width: 767.98px) { + .create-list-input-group { + gap: 0 !important; + } + + .create-list-input-group > .form-control { + padding-left: .9rem; + padding-right: .75rem; + } + + .create-list-input-group > .create-list-temp-toggle, + .create-list-input-group > #tempToggle { + min-width: 8.75rem; + padding-left: .85rem; + padding-right: .85rem; + font-size: .92rem; + letter-spacing: 0; + } +} + + +/* Login/auth password field fixes */ +.endpoint-login form .form-control, +.endpoint-system_auth form .form-control { + min-height: 42px; + border-radius: 14px !important; +} + +.endpoint-login .ui-password-group, +.endpoint-system_auth .ui-password-group { + display: flex !important; + flex-wrap: nowrap !important; + align-items: stretch !important; + gap: 0 !important; +} + +.endpoint-login .ui-password-group > .form-control, +.endpoint-system_auth .ui-password-group > .form-control { + width: auto !important; + flex: 1 1 auto !important; + max-width: none !important; + border-radius: 14px 0 0 14px !important; + border-right: 0 !important; +} + +.endpoint-login .ui-password-group > .ui-password-toggle, +.endpoint-system_auth .ui-password-group > .ui-password-toggle { + appearance: none; + -webkit-appearance: none; + display: inline-flex !important; + align-items: center; + justify-content: center; + flex: 0 0 46px !important; + width: 46px !important; + min-width: 46px !important; + padding: 0 !important; + margin: 0 !important; + color: rgba(255,255,255,.78); + background: #1f2738 !important; + border: 1px solid var(--bs-border-color, #495057) !important; + border-left: 0 !important; + border-radius: 0 14px 14px 0 !important; + outline: none !important; + box-shadow: none !important; + line-height: 1; + font-size: 1rem; +} + +.endpoint-login .ui-password-group > .ui-password-toggle:hover, +.endpoint-login .ui-password-group > .ui-password-toggle:focus, +.endpoint-system_auth .ui-password-group > .ui-password-toggle:hover, +.endpoint-system_auth .ui-password-group > .ui-password-toggle:focus { + color: #fff; + background: #253047 !important; + outline: none !important; + box-shadow: none !important; +} + +.endpoint-login .ui-password-group > .ui-password-toggle.is-active, +.endpoint-system_auth .ui-password-group > .ui-password-toggle.is-active { + background: #2a3550 !important; +} + +@media (max-width: 575.98px) { + .endpoint-login .ui-password-group, + .endpoint-system_auth .ui-password-group { + width: 100%; + } + + .endpoint-login .ui-password-group > .form-control, + .endpoint-system_auth .ui-password-group > .form-control { + width: auto !important; + flex: 1 1 auto !important; + } + + .endpoint-login .ui-password-group > .ui-password-toggle, + .endpoint-system_auth .ui-password-group > .ui-password-toggle { + flex: 0 0 44px !important; + width: 44px !important; + min-width: 44px !important; + } +} + +/* final hotfix 2026-03-17: list/share parity, pending spinner, auth inputs */ +.shopping-item-row { + position: relative; +} + +.shopping-item-spinner { + position: absolute; + top: .7rem; + right: .7rem; + z-index: 2; + pointer-events: none; +} + +.shopping-item-row.is-pending .shopping-item-actions { + opacity: .72; +} + +.shopping-item-actions { + display: inline-flex; + flex: 0 0 auto; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: .35rem; + min-height: 2.35rem; +} + +.shopping-action-btn { + display: inline-flex !important; + align-items: center; + justify-content: center; + width: 2.35rem; + height: 2.35rem; + min-width: 2.35rem; + padding: 0 !important; + line-height: 1; + border-radius: .7rem !important; + flex: 0 0 2.35rem; +} + +.shopping-action-btn--wide { + width: auto; + min-width: 5.9rem; + padding: 0 .8rem !important; + flex: 0 0 auto; +} +.shopping-action-btn--countdown { + width: auto !important; + min-width: 3.2rem !important; + padding: 0 .65rem !important; + font-variant-numeric: tabular-nums; + opacity: 1 !important; +} + + +.endpoint-list_share .shopping-item-actions, +.endpoint-shared_list .shopping-item-actions, +.endpoint-list .shopping-item-actions { + min-height: 2.35rem; +} + +.endpoint-list_share .shopping-action-btn, +.endpoint-shared_list .shopping-action-btn, +.endpoint-list .shopping-action-btn { + width: 2.35rem; + height: 2.35rem; + min-width: 2.35rem; + border-radius: .7rem !important; +} + +.endpoint-list_share .shopping-action-btn--wide, +.endpoint-shared_list .shopping-action-btn--wide, +.endpoint-list .shopping-action-btn--wide { + width: auto; + min-width: 5.9rem; +} +.endpoint-list_share .shopping-action-btn--countdown, +.endpoint-shared_list .shopping-action-btn--countdown, +.endpoint-list .shopping-action-btn--countdown { + width: auto; + min-width: 3.2rem; +} + + +@media (max-width: 575.98px) { + .shopping-item-spinner { + top: .55rem; + right: .55rem; + } + + .shopping-action-btn, + .endpoint-list_share .shopping-action-btn, + .endpoint-shared_list .shopping-action-btn, + .endpoint-list .shopping-action-btn { + width: 2.15rem; + height: 2.15rem; + min-width: 2.15rem; + border-radius: .65rem !important; + } + + .shopping-action-btn--wide, + .endpoint-list_share .shopping-action-btn--wide, + .endpoint-shared_list .shopping-action-btn--wide, + .endpoint-list .shopping-action-btn--wide { + width: auto; + min-width: 5.4rem; + padding: 0 .72rem !important; + } + + .shopping-action-btn--countdown, + .endpoint-list_share .shopping-action-btn--countdown, + .endpoint-shared_list .shopping-action-btn--countdown, + .endpoint-list .shopping-action-btn--countdown { + min-width: 3rem; + padding: 0 .55rem !important; + } +} + +.endpoint-login .card .form-control, +.endpoint-system_auth .card .form-control, +.endpoint-user_management .ui-password-group > .form-control, +.endpoint-user_management .modal .ui-password-group > .form-control { + min-height: 42px; + border-radius: 14px !important; +} + +.endpoint-user_management .ui-password-group, +.endpoint-user_management .modal .ui-password-group { + display: flex !important; + flex-wrap: nowrap !important; + align-items: stretch !important; + gap: 0 !important; +} + +.endpoint-user_management .ui-password-group > .form-control, +.endpoint-user_management .modal .ui-password-group > .form-control { + flex: 1 1 auto !important; + width: auto !important; + max-width: none !important; + border-radius: 14px 0 0 14px !important; + border-right: 0 !important; +} + +.endpoint-user_management .ui-password-group > .ui-password-toggle, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle { + appearance: none; + -webkit-appearance: none; + display: inline-flex !important; + align-items: center; + justify-content: center; + flex: 0 0 46px !important; + width: 46px !important; + min-width: 46px !important; + padding: 0 !important; + margin: 0 !important; + color: rgba(255,255,255,.78); + background: #1f2738 !important; + border: 1px solid var(--bs-border-color, #495057) !important; + border-left: 0 !important; + border-radius: 0 14px 14px 0 !important; + outline: none !important; + box-shadow: none !important; + line-height: 1; + font-size: 1rem; +} + +.endpoint-user_management .ui-password-group > .ui-password-toggle:hover, +.endpoint-user_management .ui-password-group > .ui-password-toggle:focus, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle:hover, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle:focus { + color: #fff; + background: #253047 !important; + box-shadow: none !important; +} + + +/* v14 fixes: share/list action parity + sort handle visibility */ +.endpoint-list_share .shopping-item-actions, +.endpoint-shared_list .shopping-item-actions, +.endpoint-view_list .shopping-item-actions, +.endpoint-list .shopping-item-actions { + gap: .35rem !important; + min-height: 2.35rem !important; +} + +.endpoint-list_share .shopping-action-btn, +.endpoint-shared_list .shopping-action-btn, +.endpoint-view_list .shopping-action-btn, +.endpoint-list .shopping-action-btn { + width: 2.35rem !important; + height: 2.35rem !important; + min-width: 2.35rem !important; + min-height: 2.35rem !important; + padding: 0 !important; + border-radius: .7rem !important; + font-size: 1rem !important; + line-height: 1 !important; +} + +.endpoint-list_share .shopping-action-btn--wide, +.endpoint-shared_list .shopping-action-btn--wide, +.endpoint-view_list .shopping-action-btn--wide, +.endpoint-list .shopping-action-btn--wide { + width: auto !important; + min-width: 5.9rem !important; + padding: 0 .8rem !important; +} +.endpoint-list_share .shopping-action-btn--countdown, +.endpoint-shared_list .shopping-action-btn--countdown, +.endpoint-view_list .shopping-action-btn--countdown, +.endpoint-list .shopping-action-btn--countdown { + width: auto !important; + min-width: 3.2rem !important; + padding: 0 .65rem !important; +} + + +.endpoint-list_share .shopping-action-btn > *, +.endpoint-shared_list .shopping-action-btn > *, +.endpoint-view_list .shopping-action-btn > *, +.endpoint-list .shopping-action-btn > * { + line-height: 1 !important; +} + +@media (max-width: 575.98px) { + .endpoint-list_share .shopping-action-btn, + .endpoint-shared_list .shopping-action-btn, + .endpoint-view_list .shopping-action-btn, + .endpoint-list .shopping-action-btn { + width: 2.15rem !important; + height: 2.15rem !important; + min-width: 2.15rem !important; + min-height: 2.15rem !important; + border-radius: .65rem !important; + } + + .endpoint-list_share .shopping-action-btn--wide, + .endpoint-shared_list .shopping-action-btn--wide, + .endpoint-view_list .shopping-action-btn--wide, + .endpoint-list .shopping-action-btn--wide { + min-width: 5.4rem !important; + padding: 0 .72rem !important; + } +} + .endpoint-list_share .shopping-action-btn--countdown, + .endpoint-shared_list .shopping-action-btn--countdown, + .endpoint-view_list .shopping-action-btn--countdown, + .endpoint-list .shopping-action-btn--countdown { + min-width: 3rem !important; + padding: 0 .55rem !important; + } +} + +body:not(.sorting-active) .drag-handle { + display: none !important; +} + + +/* final hotfix 2026-03-17: consistent password toggle on auth/admin */ +.ui-password-group { + display: flex !important; + flex-wrap: nowrap !important; + align-items: stretch !important; + gap: 0 !important; + width: 100%; +} + +.ui-password-group > .form-control { + flex: 1 1 auto !important; + width: 1% !important; + min-width: 0 !important; + max-width: none !important; + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; + border-right: 0 !important; +} + +.ui-password-group > .ui-password-toggle { + appearance: none; + -webkit-appearance: none; + display: inline-flex !important; + align-items: center; + justify-content: center; + flex: 0 0 46px !important; + width: 46px !important; + min-width: 46px !important; + padding: 0 !important; + margin: 0 !important; + background: var(--dark-700) !important; + color: var(--text-strong) !important; + border: 1px solid var(--dark-300) !important; + border-left: 0 !important; + border-top-right-radius: 14px !important; + border-bottom-right-radius: 14px !important; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + box-shadow: none !important; + outline: none !important; + line-height: 1; + transition: background-color .18s ease, border-color .18s ease, color .18s ease, box-shadow .18s ease; +} + +.ui-password-group > .ui-password-toggle:hover, +.ui-password-group > .ui-password-toggle:focus, +.ui-password-group > .ui-password-toggle:focus-visible { + background: var(--dark-800) !important; + color: #fff !important; + border-color: var(--primary) !important; + box-shadow: 0 0 0 .25rem rgba(24, 64, 118, .18) !important; +} + +.ui-password-group > .ui-password-toggle.is-active { + background: #2a3550 !important; + color: #fff !important; +} + +@media (max-width: 575.98px) { + .ui-password-group > .ui-password-toggle { + flex-basis: 44px !important; + width: 44px !important; + min-width: 44px !important; + } +} + + +/* final hotfix 2026-03-17b: password toggle parity on login/system-auth/admin-users */ +.ui-password-group { + display: flex !important; + flex-wrap: nowrap !important; + align-items: stretch !important; + width: 100% !important; +} + +.ui-password-group > .form-control { + flex: 1 1 auto !important; + width: 1% !important; + min-width: 0 !important; + min-height: 42px !important; + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; + border-right: 0 !important; +} + +.ui-password-group > .ui-password-toggle { + appearance: none !important; + -webkit-appearance: none !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + flex: 0 0 46px !important; + width: 46px !important; + min-width: 46px !important; + min-height: 42px !important; + padding: 0 !important; + margin: 0 !important; + cursor: pointer !important; + background-color: var(--dark-700) !important; + background-image: none !important; + color: var(--text-strong) !important; + border: 1px solid var(--dark-300) !important; + border-left: 0 !important; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-top-right-radius: 14px !important; + border-bottom-right-radius: 14px !important; + box-shadow: none !important; + line-height: 1 !important; +} + +.ui-password-group > .ui-password-toggle:hover, +.ui-password-group > .ui-password-toggle:focus, +.ui-password-group > .ui-password-toggle:focus-visible { + background-color: var(--dark-800) !important; + color: #fff !important; + border-color: var(--primary) !important; + box-shadow: 0 0 0 .25rem rgba(24, 64, 118, .18) !important; + outline: none !important; +} + +.ui-password-group > .ui-password-toggle.is-active { + background-color: var(--dark-800) !important; + color: #fff !important; +} + +.ui-password-group > .ui-password-toggle > * { + pointer-events: none !important; +} + +.endpoint-login .ui-password-group > .ui-password-toggle, +.endpoint-system_auth .ui-password-group > .ui-password-toggle, +.endpoint-user_management .ui-password-group > .ui-password-toggle, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle { + background-color: var(--dark-700) !important; + color: var(--text-strong) !important; + border-color: var(--dark-300) !important; +} + +.endpoint-login .ui-password-group > .ui-password-toggle:hover, +.endpoint-login .ui-password-group > .ui-password-toggle:focus, +.endpoint-login .ui-password-group > .ui-password-toggle:focus-visible, +.endpoint-system_auth .ui-password-group > .ui-password-toggle:hover, +.endpoint-system_auth .ui-password-group > .ui-password-toggle:focus, +.endpoint-system_auth .ui-password-group > .ui-password-toggle:focus-visible, +.endpoint-user_management .ui-password-group > .ui-password-toggle:hover, +.endpoint-user_management .ui-password-group > .ui-password-toggle:focus, +.endpoint-user_management .ui-password-group > .ui-password-toggle:focus-visible, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle:hover, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle:focus, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle:focus-visible { + background-color: var(--dark-800) !important; + border-color: var(--primary) !important; +} + +@media (max-width: 575.98px) { + .ui-password-group > .ui-password-toggle { + flex-basis: 44px !important; + width: 44px !important; + min-width: 44px !important; + } +} + + +/* wyróżnienie pola dodawania produktu */ +.endpoint-list .shopping-entry-card, +.endpoint-list_share .shopping-entry-card, +.endpoint-shared_list .shopping-entry-card, +.endpoint-view_list .shopping-entry-card { + background: linear-gradient(180deg, rgba(25, 135, 84, 0.16), rgba(13, 17, 23, 0.92)); + border: 1px solid rgba(25, 135, 84, 0.42); + border-radius: 1rem; + padding: .9rem; + box-shadow: 0 .5rem 1.2rem rgba(0, 0, 0, 0.18); +} + +.endpoint-list .shopping-entry-card__label, +.endpoint-list_share .shopping-entry-card__label, +.endpoint-shared_list .shopping-entry-card__label, +.endpoint-view_list .shopping-entry-card__label { + display: inline-flex; + align-items: center; + gap: .4rem; + margin-bottom: .2rem; + font-size: .95rem; + font-weight: 700; + color: #d1f7df; +} + +.endpoint-list .shopping-entry-card__hint, +.endpoint-list_share .shopping-entry-card__hint, +.endpoint-shared_list .shopping-entry-card__hint, +.endpoint-view_list .shopping-entry-card__hint { + margin-bottom: .75rem; + color: rgba(255, 255, 255, 0.72); + font-size: .82rem; + line-height: 1.35; +} + +.endpoint-list .shopping-entry-card .shopping-product-input-group, +.endpoint-list_share .shopping-entry-card .shopping-product-input-group, +.endpoint-shared_list .shopping-entry-card .shopping-product-input-group, +.endpoint-view_list .shopping-entry-card .shopping-product-input-group { + margin-bottom: 0 !important; +} + +.endpoint-list .shopping-entry-card .shopping-product-input-group > .form-control, +.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .form-control, +.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .form-control, +.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .form-control { + border-color: rgba(25, 135, 84, 0.55) !important; + background: rgba(17, 24, 39, 0.95) !important; +} + +.endpoint-list .shopping-entry-card .shopping-product-input-group > .form-control::placeholder, +.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .form-control::placeholder, +.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .form-control::placeholder, +.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .form-control::placeholder { + color: rgba(255, 255, 255, 0.62); +} + +.endpoint-list .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus, +.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus, +.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus, +.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus { + box-shadow: inset 0 0 0 1px rgba(25, 135, 84, 0.25), 0 0 0 .2rem rgba(25, 135, 84, 0.18); +} + +@media (max-width: 767.98px) { + .endpoint-list .shopping-entry-card, + .endpoint-list_share .shopping-entry-card, + .endpoint-shared_list .shopping-entry-card, + .endpoint-view_list .shopping-entry-card { + padding: .8rem; + border-radius: .95rem; + } + + .endpoint-list .shopping-entry-card__label, + .endpoint-list_share .shopping-entry-card__label, + .endpoint-shared_list .shopping-entry-card__label, + .endpoint-view_list .shopping-entry-card__label { + font-size: .92rem; + } + + .endpoint-list .shopping-entry-card__hint, + .endpoint-list_share .shopping-entry-card__hint, + .endpoint-shared_list .shopping-entry-card__hint, + .endpoint-view_list .shopping-entry-card__hint { + font-size: .78rem; + margin-bottom: .65rem; + } +} + +/* ========== /expenses mobile fixes: separate range pickers + better wrapping ========== */ +.endpoint-expenses .expenses-range-toolbar { + width: 100%; +} + +.endpoint-expenses .expenses-range-group { + flex-wrap: wrap; + justify-content: center; + width: 100%; +} + +.endpoint-expenses .expenses-range-group > .btn { + white-space: nowrap; +} + +.endpoint-expenses .expenses-date-range { + align-items: stretch; +} + +@media (max-width: 767.98px) { + .endpoint-expenses .expenses-range-toolbar { + justify-content: stretch !important; + overflow: visible; + padding-bottom: 0; + } + + .endpoint-expenses .expenses-range-group { + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; + width: 100%; + min-width: 0; + } + + .endpoint-expenses .expenses-table-toolbar .expenses-range-group { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .endpoint-expenses .expenses-range-group > .btn { + flex: initial !important; + width: 100%; + min-width: 0; + padding-inline: 0.55rem; + font-size: 0.95rem; + } + + .endpoint-expenses .expenses-date-range { + display: grid !important; + grid-template-columns: 52px minmax(0, 1fr); + gap: 0.55rem; + width: 100%; + max-width: 100% !important; + flex-wrap: wrap !important; + } + + .endpoint-expenses .expenses-date-range > .input-group-text, + .endpoint-expenses .expenses-date-range > .form-control, + .endpoint-expenses .expenses-date-range > .btn { + width: 100% !important; + min-width: 0 !important; + flex: initial !important; + border-radius: 0.85rem !important; + } + + .endpoint-expenses .expenses-date-range > .btn { + grid-column: 1 / -1; + } +} + + +/* /share expense entry card aligned with product card */ +.endpoint-list .shopping-entry-card--expense, +.endpoint-list_share .shopping-entry-card--expense, +.endpoint-shared_list .shopping-entry-card--expense, +.endpoint-view_list .shopping-entry-card--expense { + background: linear-gradient(180deg, rgba(13, 110, 253, 0.16), rgba(13, 17, 23, 0.92)); + border-color: rgba(13, 110, 253, 0.42); +} + +.endpoint-list .shopping-entry-card--expense .shopping-entry-card__label, +.endpoint-list_share .shopping-entry-card--expense .shopping-entry-card__label, +.endpoint-shared_list .shopping-entry-card--expense .shopping-entry-card__label, +.endpoint-view_list .shopping-entry-card--expense .shopping-entry-card__label { + color: #d7e9ff; +} + +.endpoint-list .shopping-entry-card--expense .shopping-expense-input-group, +.endpoint-list_share .shopping-entry-card--expense .shopping-expense-input-group, +.endpoint-shared_list .shopping-entry-card--expense .shopping-expense-input-group, +.endpoint-view_list .shopping-entry-card--expense .shopping-expense-input-group { + margin-bottom: 0 !important; +} + +.endpoint-list .shopping-entry-card--expense .shopping-expense-input-group > .form-control, +.endpoint-list_share .shopping-entry-card--expense .shopping-expense-input-group > .form-control, +.endpoint-shared_list .shopping-entry-card--expense .shopping-expense-input-group > .form-control, +.endpoint-view_list .shopping-entry-card--expense .shopping-expense-input-group > .form-control { + border-color: rgba(13, 110, 253, 0.55) !important; + background: rgba(17, 24, 39, 0.95) !important; +} + +.endpoint-list .shopping-entry-card--expense .shopping-expense-input-group > .form-control::placeholder, +.endpoint-list_share .shopping-entry-card--expense .shopping-expense-input-group > .form-control::placeholder, +.endpoint-shared_list .shopping-entry-card--expense .shopping-expense-input-group > .form-control::placeholder, +.endpoint-view_list .shopping-entry-card--expense .shopping-expense-input-group > .form-control::placeholder { + color: rgba(255, 255, 255, 0.62); +} + +.endpoint-list .shopping-entry-card--expense .shopping-expense-input-group > .shopping-expense-amount-input:focus, +.endpoint-list_share .shopping-entry-card--expense .shopping-expense-input-group > .shopping-expense-amount-input:focus, +.endpoint-shared_list .shopping-entry-card--expense .shopping-expense-input-group > .shopping-expense-amount-input:focus, +.endpoint-view_list .shopping-entry-card--expense .shopping-expense-input-group > .shopping-expense-amount-input:focus { + box-shadow: inset 0 0 0 1px rgba(13, 110, 253, 0.25), 0 0 0 .2rem rgba(13, 110, 253, 0.18); +} + +.endpoint-list .share-submit-btn--expense, +.endpoint-list_share .share-submit-btn--expense, +.endpoint-shared_list .share-submit-btn--expense, +.endpoint-view_list .share-submit-btn--expense { + color: #8ec5ff; + border-color: rgba(13, 110, 253, 0.72) !important; + background: rgba(13, 110, 253, 0.12); +} + +.endpoint-list .share-submit-btn--expense:hover, +.endpoint-list_share .share-submit-btn--expense:hover, +.endpoint-shared_list .share-submit-btn--expense:hover, +.endpoint-view_list .share-submit-btn--expense:hover, +.endpoint-list .share-submit-btn--expense:focus, +.endpoint-list_share .share-submit-btn--expense:focus, +.endpoint-shared_list .share-submit-btn--expense:focus, +.endpoint-view_list .share-submit-btn--expense:focus { + color: #fff; + border-color: rgba(13, 110, 253, 0.9) !important; + background: rgba(13, 110, 253, 0.22); + box-shadow: 0 0 0 .2rem rgba(13, 110, 253, 0.16); +} + +/* UX polish 2026-03-19: list quick actions card */ +.list-quick-actions { + display: grid; + gap: .9rem; + padding: 1rem; + border: 1px solid rgba(255,255,255,.08); + border-radius: 1rem; + background: linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02)); + box-shadow: 0 .5rem 1.25rem rgba(0,0,0,.14); +} + +.list-quick-actions__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: .75rem; +} + +.list-quick-actions__eyebrow { + font-size: .72rem; + letter-spacing: .08em; + text-transform: uppercase; + color: rgba(255,255,255,.58); + margin-bottom: .15rem; +} + +.list-quick-actions__title { + font-size: 1rem; + font-weight: 700; + color: #fff; + line-height: 1.2; +} + +.list-quick-actions__hint { + font-size: .82rem; + color: rgba(255,255,255,.62); + text-align: right; + max-width: 18rem; +} + +.list-quick-actions__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .75rem; +} + +.list-quick-actions__form { + display: block; +} + +.list-quick-actions__action.btn { + width: 100%; + min-height: 78px; + display: flex; + align-items: flex-start; + gap: .75rem; + padding: .9rem 1rem; + border-radius: .95rem; + text-align: left; + white-space: normal; +} + +.list-quick-actions__action--primary.btn { + border-color: rgba(255,255,255,.2); + background: rgba(255,255,255,.03); +} + +.list-quick-actions__action--secondary.btn { + border-color: rgba(13,110,253,.5); + background: rgba(13,110,253,.08); +} + +.list-quick-actions__icon { + flex: 0 0 auto; + font-size: 1.05rem; + line-height: 1; + margin-top: .1rem; +} + +.list-quick-actions__content { + display: grid; + gap: .2rem; + min-width: 0; +} + +.list-quick-actions__label { + font-size: .95rem; + font-weight: 600; + color: #fff; + line-height: 1.25; +} + +.list-quick-actions__desc { + font-size: .81rem; + color: rgba(255,255,255,.66); + line-height: 1.35; +} + +@media (max-width: 767.98px) { + .list-quick-actions { + padding: .9rem; + gap: .75rem; + } + + .list-quick-actions__header { + flex-direction: column; + gap: .35rem; + } + + .list-quick-actions__hint { + max-width: none; + text-align: left; + } + + .list-quick-actions__grid { + grid-template-columns: 1fr; + } + + .list-quick-actions__action.btn { + min-height: 72px; + padding: .85rem .9rem; + } +} + + +/* mobile user chip 2026-03-19 */ +.app-navbar__meta--mobile { + display: none; +} + +.app-user-chip--mobile { + max-width: min(46vw, 15rem); + min-width: 0; + padding-left: .6rem; + padding-right: .4rem; +} + +.app-user-chip--mobile .badge { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 991.98px) { + .app-navbar__meta--mobile { + display: flex !important; + width: auto; + justify-content: flex-end; + min-width: 0; + } + + .app-user-chip--mobile { + display: inline-flex; + } +} + +@media (max-width: 575.98px) { + .app-brand__icon { + width: 2.25rem; + height: 2.25rem; + } + + .app-user-chip--mobile { + gap: .35rem; + padding: .34rem .38rem .34rem .5rem; + } + + .app-user-chip--mobile .app-user-chip__label { + font-size: .62rem; + letter-spacing: .05em; + } + + .app-user-chip--mobile .badge { + font-size: .72rem; + max-width: 5.9rem; + } +} + + +/* mobile navbar layout fix 2026-03-19 */ +@media (max-width: 991.98px) { + .app-navbar .container-xxl { + grid-template-columns: minmax(0, 1fr) auto auto; + } + + .app-navbar__meta--mobile { + grid-column: 2; + justify-self: end; + min-width: 0; + max-width: min(42vw, 12rem); + } + + .app-mobile-menu { + grid-column: 3; + justify-self: end; + } +} + +@media (max-width: 430px) { + .app-navbar .container-xxl { + grid-template-columns: minmax(0, 1fr) auto auto; + gap: .45rem; + } + + .app-user-chip--mobile { + max-width: min(38vw, 8.5rem); + } + + .app-user-chip--mobile .app-user-chip__label { + display: none; + } +} + +/* --- Main page list progress consistency --- */ +.endpoint-main_page .list-group-item { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; +} + +.endpoint-main_page .main-list-progress-wrap { + display: block; + width: 100%; + flex: 0 0 100%; + margin-top: 0.8rem !important; +} + +.endpoint-main_page .list-group-item > .main-list-row + .main-list-progress-wrap { + align-self: stretch; +} + +.endpoint-main_page .main-list-progress { + width: 100%; + height: 16px; + margin-top: 0 !important; + border: 1px solid rgba(255, 255, 255, 0.08); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)), + var(--dark-700) !important; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 4px 10px rgba(0, 0, 0, 0.18); +} + +.endpoint-main_page .main-list-progress .progress-bar.bg-success { + background: linear-gradient(135deg, rgba(40, 199, 111, 0.98), rgba(22, 163, 74, 0.98)) !important; +} + +.endpoint-main_page .main-list-progress .progress-bar.bg-warning { + background: linear-gradient(135deg, rgba(245, 189, 65, 0.98), rgba(217, 119, 6, 0.98)) !important; +} + +.endpoint-main_page .main-list-progress .progress-bar.bg-transparent { + background: rgba(255, 255, 255, 0.08) !important; +} + +.endpoint-main_page .main-list-progress__label { + max-width: calc(100% - 0.85rem); + padding: 0 0.45rem; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); + letter-spacing: 0.01em; +} + +@media (max-width: 575.98px) { + .endpoint-main_page .main-list-progress { + height: 15px; + } + + .endpoint-main_page .main-list-progress__label { + font-size: 0.64rem; + } +} + +/* --- Main page progress summary cards --- */ +.endpoint-main_page #mainStatsCollapse.collapsing, +.endpoint-main_page #mainStatsCollapse.show { + overflow: visible; +} + +.endpoint-main_page .main-summary-card { + height: 100%; + padding: 1rem 1rem 1.05rem; + border-radius: 1rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)), rgba(9, 16, 28, 0.88); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2); +} + +.endpoint-main_page .main-summary-card__eyebrow { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.65); + margin-bottom: 0.2rem; +} + +.endpoint-main_page .main-summary-card__title { + font-size: 1.05rem; +} + +.endpoint-main_page .main-summary-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.7rem; +} + +.endpoint-main_page .main-summary-stat { + padding: 0.65rem 0.75rem; + border-radius: 0.85rem; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.endpoint-main_page .main-summary-stat__label { + display: block; + font-size: 0.73rem; + color: rgba(255, 255, 255, 0.66); + margin-bottom: 0.15rem; +} + + +@media (max-width: 575.98px) { + .endpoint-main_page .main-summary-card { + padding: 0.9rem; + } + + .endpoint-main_page .main-summary-stats { + grid-template-columns: 1fr; + } +} + + +/* mobile hotfix 2026-03-25: keep share-list item text top-aligned next to wide restore button */ +@media (max-width: 575.98px) { + .endpoint-list_share .shopping-item-head, + .endpoint-shared_list .shopping-item-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + column-gap: .45rem; + } + + .endpoint-list_share .shopping-item-actions, + .endpoint-shared_list .shopping-item-actions { + align-self: start; + margin-left: 0; + justify-self: end; + } +} diff --git a/shopping_app/static/css/style_nieuporzadkowane.css b/shopping_app/static/css/style_nieuporzadkowane.css new file mode 100644 index 0000000..67d7787 --- /dev/null +++ b/shopping_app/static/css/style_nieuporzadkowane.css @@ -0,0 +1,5858 @@ +/* ========================================================= + Variables (single source of truth) +========================================================= */ +:root { + /* brand / info */ + --primary: #184076; + --primary-border: #153866; + --primary-text: #e6f0ff; + + --info: var(--primary); + --info-border: var(--primary-border); + --info-text: var(--primary-text); + + /* success */ + --success: #1c6930; + --success-border: #165024; + --success-text: #eaffea; + + /* warning */ + --warning: #665c1e; + --warning-border: #4d4415; + --warning-text: #fffbe5; + + /* danger */ + --danger: #6e1a1e; + --danger-border: #531417; + --danger-text: #ffeaea; + + /* neutrals / dark */ + --dark-900: #181a1b; + --dark-800: #1c1f22; + --dark-750: #1f2225; + --dark-700: #212529; + --dark-650: #23272a; + --dark-600: #2a2d31; + --dark-550: #2b2f33; + --dark-500: #2c2f33; + --dark-480: #2c3034; + --dark-470: #2a2d31; + --dark-450: #3a3f44; + --dark-400: #343a40; + --dark-350: #3d4248; + --dark-300: #495057; + + --text-strong: #f8f9fa; + --text: #e2e3e5; + --text-dim: #e1e1e1; + --muted: #6c757d; + + /* defaults */ + --progress-default: #3d7bd6; +} + +/* ========================================================= + Utilities & Sizes +========================================================= */ +.large-checkbox { + width: 1.5em; + height: 1.5em; +} + +.clickable-item { + cursor: pointer; +} + +.progress-thin { + height: 12px; +} + +.item-not-checked { + background-color: var(--dark-500) !important; + color: #fff !important; +} + +#empty-placeholder { + font-style: italic; + pointer-events: none; +} + +.fade-out { + opacity: 0; + transition: opacity 0.5s ease; +} + +@media (pointer: fine) { + .only-mobile { + display: none !important; + } +} + +/* Bootstrap bg overrides via variables */ +.bg-success { + background-color: var(--success) !important; +} + +.bg-warning { + background-color: var(--warning) !important; +} + +/* ========================================================= + Buttons +========================================================= */ +/* Primary */ +.btn-primary { + background-color: var(--primary) !important; + border-color: var(--primary-border) !important; +} + +.btn-primary:hover, +.btn-primary:focus, +.btn-primary:active { + background-color: #13315f !important; + border-color: #10284f !important; +} + +/* Success */ +.btn-success { + background-color: var(--success) !important; + border-color: var(--success-border) !important; + color: #fff !important; +} + +.btn-success:hover, +.btn-success:focus, +.btn-success:active { + background-color: #155627 !important; + border-color: #124521 !important; + color: #fff !important; +} + +/* Warning */ +.btn-warning { + background-color: var(--warning) !important; + border-color: var(--warning-border) !important; + color: var(--warning-text) !important; +} + +.btn-warning:hover, +.btn-warning:focus, +.btn-warning:active { + background-color: #5c4c17 !important; + border-color: #3e3610 !important; + color: var(--warning-text) !important; +} + +/* Outline */ +.btn-outline-success { + color: var(--success) !important; + border-color: var(--success) !important; +} + +.btn-outline-success:hover, +.btn-outline-success:focus, +.btn-outline-success:active { + background-color: var(--success) !important; + border-color: var(--success-border) !important; + color: #fff !important; +} + +.btn-outline-warning { + color: #d9c97a !important; + border-color: var(--warning) !important; +} + +.btn-outline-warning:hover, +.btn-outline-warning:focus, +.btn-outline-warning:active { + background-color: var(--warning) !important; + border-color: var(--warning-border) !important; + color: var(--warning-text) !important; +} + +/* File input button */ +input[type="file"]::file-selector-button { + background-color: #1b4a29; + color: #f0f0f0; + border: none; + padding: .5em 1em; + border-radius: 4px; + font-weight: bold; + cursor: pointer; + transition: background .2s; +} + +/* ========================================================= + Forms (inputs, selects, switches, placeholders) +========================================================= */ +.form-select, +.form-control, +textarea.form-control { + background-color: var(--dark-700) !important; + color: var(--text-strong) !important; + border: 1px solid var(--dark-300) !important; +} + +.form-select:focus, +.form-control:focus, +textarea.form-control:focus { + background-color: var(--dark-800) !important; + border-color: var(--primary) !important; + color: #fff !important; + box-shadow: 0 0 0 .25rem rgba(24, 64, 118, .35) !important; +} + +.form-control:disabled, +textarea.form-control:disabled { + background-color: var(--dark-550) !important; + color: var(--muted) !important; + cursor: not-allowed; +} + +/* Switch */ +.form-switch .form-check-input { + background-color: var(--dark-400) !important; + border-color: var(--dark-300) !important; +} + +.form-switch .form-check-input:checked { + background-color: var(--primary) !important; + border-color: var(--primary-border) !important; +} + +/* Placeholders */ +.form-control::placeholder, +.bg-dark .form-control::placeholder { + color: #aaa !important; + opacity: 1 !important; +} + +/* Paired corners (utility) */ +#tempToggle { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.create-list-input-group > input.form-control { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +/* XXL custom checkbox */ +input[type="checkbox"].large-checkbox { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + width: 1.5em; + height: 1.5em; + margin: 0; + padding: 0; + outline: none; + background: none; + cursor: pointer; + position: relative; + vertical-align: middle; +} + +input[type="checkbox"].large-checkbox::before { + content: '✗'; + color: #dc3545; + font-size: 1.6em; + font-weight: 700; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + transition: color .2s; +} + +input[type="checkbox"].large-checkbox:checked::before { + content: '✓'; + color: #fff; +} + +input[type="checkbox"].large-checkbox:disabled::before { + opacity: .5; + cursor: not-allowed; +} + +input[type="checkbox"].large-checkbox:disabled { + cursor: not-allowed; +} + +/* Tom-Select / TS */ +.tom-dark .ts-control { + background-color: var(--dark-700) !important; + color: #fff !important; + border: 1px solid var(--dark-300) !important; + border-radius: .375rem; + min-height: 38px; + padding: .25rem .5rem; + box-sizing: border-box; +} + +.tom-dark .ts-control .item { + background-color: var(--dark-400) !important; + color: #fff !important; + border-radius: .25rem; + padding: 2px 8px; + margin-right: 4px; +} + +.ts-dropdown { + background-color: var(--dark-700) !important; + color: #fff !important; + border: 1px solid var(--dark-300); + border-radius: .375rem; + z-index: 9999 !important; + max-height: 300px; + overflow-y: auto; +} + +.ts-dropdown .active { + background-color: var(--dark-300) !important; + color: #fff !important; +} + +td select.tom-dark { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +/* ========================================================= + Alerts, Badges, Background helpers +========================================================= */ +/* Alerts */ +.alert-success { + background-color: #225d36 !important; + color: var(--success-text) !important; + border-color: #174428 !important; +} + +.alert-danger { + background-color: #7a1f23 !important; + color: var(--danger-text) !important; + border-color: #531417 !important; +} + +.alert-info { + background-color: var(--primary) !important; + color: var(--primary-text) !important; + border-color: var(--primary-border) !important; +} + +.alert-warning { + background-color: var(--warning) !important; + color: var(--warning-text) !important; + border-color: var(--warning-border) !important; +} + +.alert-light { + background-color: #3a3f44 !important; + color: var(--text-strong) !important; + border-color: var(--dark-480) !important; +} + +/* Badges */ +.badge.bg-success, +.badge.text-bg-success { + background-color: #225d36 !important; + color: var(--success-text) !important; +} + +.badge.bg-danger, +.badge.text-bg-danger { + background-color: #7a1f23 !important; + color: var(--danger-text) !important; +} + +.badge.bg-info, +.badge.text-bg-info { + background-color: #1d3a4d !important; + color: #eaf6ff !important; +} + +.badge.bg-warning, +.badge.text-bg-warning { + background-color: var(--warning) !important; + color: var(--warning-text) !important; +} + +.badge.bg-secondary, +.badge.text-bg-secondary { + background-color: var(--dark-400) !important; + color: #e2e3e5 !important; +} + +.badge.bg-primary, +.badge.text-bg-primary { + background-color: var(--primary) !important; + color: var(--primary-text) !important; +} + +.badge.bg-light, +.badge.text-bg-light { + background-color: var(--dark-350) !important; + color: #f1f3f5 !important; +} + +.badge.bg-dark, +.badge.text-bg-dark { + background-color: var(--dark-900) !important; + color: var(--text-strong) !important; +} + +/* ========================================================= + Progress +========================================================= */ +.progress-dark { + background-color: var(--dark-700) !important; + border-radius: 20px !important; + overflow: hidden; +} + +.progress { + background-color: #2a2d31 !important; + border-radius: 20px !important; +} + +.progress-bar { + border-radius: 0 !important; + transition: width .4s ease, background-color .4s ease; + background-color: var(--progress-default) !important; +} + +.progress-bar:first-child { + border-top-left-radius: 20px !important; + border-bottom-left-radius: 20px !important; +} + +.progress-bar:last-child { + border-top-right-radius: 20px !important; + border-bottom-right-radius: 20px !important; +} + +.progress-bar.bg-success { + background-color: var(--success) !important; +} + +.progress-bar.bg-danger { + background-color: var(--danger) !important; +} + +.progress-bar.bg-warning { + background-color: var(--warning) !important; + color: #fff !important; +} + +.progress-bar.bg-info { + background-color: #16425a !important; +} + +/* Label (parent must be position-relative) */ +.progress-label { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + white-space: nowrap; +} + +/* ========================================================= + Cards & Tables +========================================================= */ +.card { + background-color: var(--dark-500) !important; + border: 1px solid var(--dark-450) !important; + color: var(--text) !important; +} + +.card-header, +.card-footer { + background-color: var(--dark-650) !important; + border-color: var(--dark-450) !important; + color: #f1f3f5 !important; +} + +.card .table { + border-radius: 0 !important; + overflow: hidden; + margin-bottom: 0; +} + +.table-responsive { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.table-responsive table { + min-width: 1000px; +} + +.table-dark.table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(255, 255, 255, .025); +} + +.table-dark tbody tr:hover { + background-color: rgba(255, 255, 255, .04); +} + +.table-dark thead th { + background-color: var(--dark-800); + color: var(--text-dim); + font-weight: 500; + border-bottom: 1px solid var(--dark-450); +} + +.table-dark td, +.table-dark th { + padding: .6rem .75rem; + vertical-align: middle; + border-top: 1px solid var(--dark-450); +} + +/* ========================================================= + Navs & Pagination +========================================================= */ +.nav-tabs .nav-link.active, +.nav-tabs .nav-item.show .nav-link { + background-color: var(--dark-500) !important; + color: var(--text-strong) !important; + border-color: var(--dark-450) var(--dark-450) var(--dark-500) !important; +} + +.page-link { + color: #e0e0e0 !important; + background-color: var(--dark-750) !important; + border: 1px solid var(--dark-450) !important; +} + +.page-link:hover, +.page-link:focus { + color: #fff !important; + background-color: var(--dark-400) !important; + border-color: var(--dark-300) !important; +} + +.page-item.active .page-link { + color: #fff !important; + background-color: var(--primary) !important; + border-color: var(--primary-border) !important; +} + +.page-item.disabled .page-link { + color: var(--muted) !important; + background-color: var(--dark-550) !important; + border-color: var(--dark-450) !important; +} + +/* ========================================================= + Lists & Misc UI +========================================================= */ +.list-group-item { + display: flex; + align-items: center; + justify-content: space-between; +} + +.list-group-item:first-child, +.list-group-item:last-child { + border-radius: 0 !important; +} + +#items li.hide-purchased { + display: none !important; +} + +#mass-add-list li { + transition: background .2s; +} + +#mass-add-list li.active { + background: #198754 !important; + color: #fff !important; + border: 1px solid #000 !important; +} + +.quantity-input { + width: 60px; + background: var(--dark-400); + color: #fff; + border: 1px solid var(--dark-300); + border-radius: 4px; + text-align: center; +} + +.add-btn { + margin-left: 10px; +} + +.quantity-controls { + min-width: 120px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; +} + +/* ========================================================= + Toasts & Info Bar +========================================================= */ +.toast { + animation: fadeInUp .5s ease; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Base toast when not using text-bg-* */ +.toast:not([class*="text-bg-"]) { + background-color: var(--dark-500) !important; + color: #f1f1f1 !important; + border: 1px solid var(--dark-450) !important; + animation: fadeInUp .5s ease; +} + +.toast .toast-body { + color: inherit !important; +} + +.toast .btn-close { + filter: invert(1) grayscale(100%) brightness(200%); +} + +/* Typed toasts (override Bootstrap text-bg-*) */ +.toast.text-bg-primary { + background-color: var(--info) !important; + color: var(--info-text) !important; + border-color: var(--info-border) !important; +} + +.toast.text-bg-info { + background-color: var(--info) !important; + color: var(--info-text) !important; + border-color: var(--info-border) !important; +} + +.toast.text-bg-success { + background-color: var(--success) !important; + color: var(--success-text) !important; + border-color: var(--success-border) !important; +} + +.toast.text-bg-warning { + background-color: var(--warning) !important; + color: var(--warning-text) !important; + border-color: var(--warning-border) !important; +} + +.toast.text-bg-danger { + background-color: var(--danger) !important; + color: var(--danger-text) !important; + border-color: var(--danger-border) !important; +} + +.toast-body { + color: #fff !important; + font-weight: 500 !important; +} + +.info-bar-fixed { + width: 100%; + color: var(--text-strong); + background-color: var(--dark-700); + border-radius: 12px 12px 0 0; + text-align: center; + padding: 10px 10px; + font-size: .95rem; + box-sizing: border-box; + margin-top: 2rem; + box-shadow: 0 -1px 4px rgba(0, 0, 0, .25); +} + +@media (max-width: 768px) { + .info-bar-fixed { + position: static; + font-size: .85rem; + padding: 8px 4px; + border-radius: 0; + } +} + +/* ========================================================= + Modals (incl. fullscreen chart modal) +========================================================= */ +.modal-content { + background-color: var(--dark-470) !important; + color: #f1f1f1 !important; + border: 1px solid var(--dark-450) !important; +} + +.modal-header, +.modal-footer { + background-color: var(--dark-650) !important; + border-color: var(--dark-450) !important; +} + +/* Fullscreen chart modal */ +#chartFullscreenModal .modal-dialog { + max-width: 100vw; + width: 100vw; + margin: 0; +} + +#chartFullscreenModal .modal-content { + height: 100vh; + border-radius: 0; +} + +#chartFullscreenModal .modal-body { + display: flex; + flex: 1 1 auto; + padding: 0; + overflow: hidden; +} + +#chartFullscreenCanvas { + display: block; + width: 100%; + height: 100%; +} + +/* ========================================================= + Dropdown (TS already above) — active +========================================================= */ +.ts-dropdown .active { + background-color: var(--dark-300) !important; +} + +.list-group-item.bg-success { + background-color: var(--success) !important; + border-color: var(--success-border) !important; + color: var(--success-text) !important; + --bs-bg-opacity: 1 !important; +} + +.list-group-item.bg-warning { + background-color: var(--warning) !important; + border-color: var(--warning-border) !important; + color: var(--warning-text) !important; + --bs-bg-opacity: 1 !important; +} + +.btn-outline-light { + color: #f8f9fa !important; + border-color: #f8f9fa !important; + background-color: transparent !important; + /* brak białego tła domyślnie */ +} + +.btn-outline-light:hover, +.btn-outline-light:focus { + background-color: #6c757d !important; + /* szare, jak wcześniej */ + color: #fff !important; + border-color: #6c757d !important; +} + +.btn-outline-light:active, +.btn-outline-light.active, +.show>.btn-outline-light.dropdown-toggle { + background-color: #5a6268 !important; + /* ciemniejsze szare na active */ + color: #fff !important; + border-color: #545b62 !important; +} + +.btn-outline-info { + color: var(--info) !important; + border-color: var(--info) !important; + background-color: transparent !important; +} + +.btn-outline-info:hover, +.btn-outline-info:focus { + background-color: #1d4d8c !important; + border-color: #1d4d8c !important; + color: var(--info-text) !important; +} + +.btn-outline-info:active, +.btn-outline-info.active, +.show>.btn-outline-info.dropdown-toggle { + background-color: var(--info) !important; + border-color: var(--info-border) !important; + color: var(--info-text) !important; +} + +/* Tekstowe kolory */ +.text-success { + color: var(--success) !important; +} + +.text-warning { + color: var(--warning) !important; +} + +.text-info { + color: var(--info) !important; +} + +.text-danger { + color: var(--danger) !important; +} + + +.settings-category-card { + background: rgba(255,255,255,.03); + border: 1px solid rgba(255,255,255,.09); + border-radius: 16px; + padding: 1rem; + height: 100%; +} + +.settings-category-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; +} + +.settings-category-name { + font-size: 1rem; + font-weight: 700; + line-height: 1.2; +} + +.settings-override-badge { + white-space: nowrap; +} + +.settings-color-controls { + display: flex; + align-items: stretch; + gap: .75rem; +} + +.settings-color-controls .category-color { + width: 72px; + min-width: 72px; + height: auto; + padding: .35rem; + border-radius: 14px !important; + border: 1px solid rgba(255,255,255,.14); + background: rgba(255,255,255,.04); +} + +.settings-color-actions { + display: flex; + flex: 1 1 auto; +} + +.settings-color-actions .btn { + flex: 1 1 0; + min-height: 44px; + border-radius: 14px !important; + display: inline-flex; + align-items: center; + justify-content: center; + text-align: center; +} + +.settings-color-actions .btn + .btn { + margin-left: .5rem; +} + +@media (min-width: 992px) { + .settings-category-name { + font-size: 1.08rem; + } +} + +@media (max-width: 767.98px) { + .settings-category-card { + padding: .9rem; + } + + .settings-color-controls { + flex-direction: column; + } + + .settings-color-controls .category-color { + width: 100%; + min-width: 0; + height: 48px; + } + + .settings-color-actions { + flex-direction: column; + } + + .settings-color-actions .btn + .btn { + margin-left: 0; + margin-top: .5rem; + } +} + +/* ========== Kolorowe wskaźniki pod pickerem ========== */ +.color-indicators .indicator { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: .5rem; +} + +.color-indicators .bar { + height: 10px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,.25); + box-shadow: inset 0 0 0 1px rgba(0,0,0,.25); +} + +/* ========== Swatch + zapisy heksowe ========== */ +.swatch { + width: 16px; + height: 16px; + border-radius: 50%; + display: inline-block; + border: 1px solid rgba(0,0,0,.15); +} + +.hex, +.hex-label { + font-variant-numeric: lining-nums; + letter-spacing: .2px; +} + +/* ========== OCR textarea ========== */ +.settings-ocr-textarea { + font: inherit; + line-height: 1.45; +} + +/* ========== Odznaka poziomu czułości ========== */ +.sens-badge { font-weight: 600; } +.sens-low { background: rgba(108,117,125,.25); color: #ced4da; } /* szary */ +.sens-mid { background: rgba(13,110,253,.25); color: #9ec5fe; } /* niebieski */ +.sens-high { background: rgba(220,53,69,.25); color: #f1aeb5; } /* czerwony */ + +/* ========================================================= + COMPACT: przyciski akcji na listach + - Desktop: standard Bootstrap + - <=576px: kompakt +========================================================= */ + +/* <=420px: tylko emoji */ +@media (max-width: 420px) { + .btn-group-compact .btn-text { + display: none !important; + } + + .btn-group-compact .btn { + padding: 0.22rem 0.45rem; + min-width: auto; + font-size: 0.9rem; + line-height: 1.1; + } +} + +/* 421–576px: lekko ciaśniej, ale tekst zostaje */ +@media (min-width: 421px) and (max-width: 576px) { + .btn-group-compact .btn { + padding: 0.25rem 0.5rem; + font-size: 0.82rem; + line-height: 1.1; + } + + .btn-group-compact .btn-text { + font-size: 0.75rem; + } +} + +/* Medium-narrow screens */ +@media (min-width: 421px) and (max-width: 576px) { + .btn-group-compact .btn { + padding: 0.24rem 0.45rem; /* ciaśniej */ + font-size: 0.82rem; + line-height: 1.1; + } + + .btn-group-compact .btn-text { + font-size: 0.75rem; + } +} + + +/* ================================================ + RESPONSIVE NAVBAR + ================================================ */ + +/* Wąskie ekrany (np. iPhone 11) */ +@media (max-width: 420px) { + + .navbar .container-fluid { + gap: 4px; + } + + .navbar-brand-compact { + font-size: 0.9rem !important; + margin-right: 0.25rem; + white-space: nowrap; + } + .navbar-brand-compact .navbar-brand-text { + font-size: 0.95em; + } + + .user-info-compact { + font-size: 0.72rem !important; + line-height: 0.9; + white-space: nowrap; + } + .user-info-compact .badge { + font-size: 0.68rem; + padding: 0.2rem 0.45rem; + } + + .nav-buttons-compact .nav-btn-text { + display: none !important; + } + .nav-buttons-compact { + gap: 0.35rem !important; + flex-wrap: nowrap; + } + .nav-buttons-compact .btn { + padding: 0.22rem 0.45rem; + min-width: auto; + line-height: 1.1; + } +} + + +/* Małe ekrany (np. 421-576px) */ +@media (min-width: 421px) and (max-width: 576px) { + .navbar .container-fluid { + gap: 8px; + } + + .navbar-brand-compact { + font-size: 1.25rem !important; + white-space: nowrap; + } + + .user-info-compact { + font-size: 0.8rem !important; + white-space: nowrap; + } + .user-info-compact .badge { + font-size: 0.75rem; + } + + .nav-buttons-compact { + flex-wrap: nowrap; + } + .nav-buttons-compact .btn { + font-size: 0.8rem; + padding: 0.25rem 0.45rem; + } + .nav-buttons-compact .nav-btn-text { + font-size: 0.75rem; + } +} + +@media (max-width: 420px) { + .user-label-desktop { display: none !important; } + .user-label-mobile { display: inline !important; } +} + +@media (min-width: 421px) { + .user-label-desktop { display: inline !important; } + .user-label-mobile { display: none !important; } +} + +.category-dot-pure { + width: 10px; + height: 10px; + display: inline-block; + flex: 0 0 auto; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.8); + background-clip: padding-box; + vertical-align: middle; + margin-left: 0.35rem; + opacity: 1; + padding: 0; + line-height: 1; + font-size: 0; + text-indent: -9999px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); +} + +.category-dot-pure::before, +.category-dot-pure::after { + content: none !important; +} + + +/* Hover efekt */ +.category-dot:hover { + transform: scale(1.3) !important; + box-shadow: 0 2px 6px rgba(0,0,0,0.4) !important; +} + + +.list-title { + white-space: nowrap !important; + overflow: hidden !important; + text-overflow: ellipsis !important; + max-width: 70% !important; + display: inline-block !important; +} + +/* Bardzo małe ekrany */ +@media (max-width: 420px) { + .list-title { + max-width: 60% !important; + } +} + +/* ===== 2026 app refresh ===== */ +:root { + --app-bg: #07111f; + --app-bg-soft: #0d1b2d; + --app-surface: rgba(11, 23, 39, 0.88); + --app-surface-strong: rgba(15, 28, 46, 0.98); + --app-surface-muted: rgba(255, 255, 255, 0.04); + --app-border: rgba(255, 255, 255, 0.1); + --app-border-strong: rgba(255, 255, 255, 0.16); + --app-text: #f3f8ff; + --app-text-muted: #9fb0c8; + --app-success: #27d07d; + --app-warning: #f6c453; + --app-danger: #ff6b7a; + --app-shadow: 0 18px 50px rgba(0, 0, 0, 0.28); + --app-radius: 22px; +} + +html, body { + min-height: 100%; + background-color: var(--app-bg); + background-image: + radial-gradient(circle at top left, rgba(39, 208, 125, 0.18), transparent 24%), + radial-gradient(circle at top right, rgba(74, 144, 226, 0.16), transparent 22%), + linear-gradient(180deg, #09111d 0%, #08121f 38%, #060d18 100%); + background-repeat: no-repeat; + color: var(--app-text); +} + +html { + color-scheme: dark; +} + +body.app-body { + position: relative; + min-height: 100vh; + min-height: 100dvh; + min-height: 100svh; + display: flex; + flex-direction: column; + margin: 0; + font-feature-settings: "ss01" on, "cv02" on; + overflow-x: hidden; +} + + +@supports (padding: env(safe-area-inset-top)) { + html, body { + min-height: calc(100% + env(safe-area-inset-top, 0px) + env(safe-area-inset-bottom, 0px)); + } +} + +.app-backdrop { + position: fixed; + inset: 0; + pointer-events: none; + background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent 28%); +} + +.app-header { + z-index: 1035; + padding: calc(0.75rem + env(safe-area-inset-top, 0px)) 0 0; + backdrop-filter: blur(12px); +} + +.app-navbar { + background: transparent; +} + +.app-navbar .container-xxl { + border: 1px solid var(--app-border); + background: rgba(6, 15, 27, 0.74); + backdrop-filter: blur(16px); + border-radius: 999px; + min-height: 68px; + box-shadow: var(--app-shadow); +} + +.app-brand { + display: inline-flex; + align-items: center; + gap: 0.85rem; + font-weight: 800; + color: var(--app-text) !important; +} + +.app-brand__icon { + display: inline-grid; + place-items: center; + width: 2.6rem; + height: 2.6rem; + border-radius: 16px; + background: linear-gradient(135deg, rgba(39,208,125,0.22), rgba(74,144,226,0.18)); + box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08); +} + +.app-brand__title { color: #ffffff; } +.app-brand__accent { color: #7ce4a8; margin-left: 0.3rem; } + +.app-navbar__actions, +.app-navbar__meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.app-user-chip { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.45rem 0.4rem 0.75rem; + border-radius: 999px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.08); +} + +.app-user-chip__label { + font-size: 0.75rem; + color: var(--app-text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.app-main { + flex: 1 0 auto; + padding: 1rem 0 2.5rem; +} + +.app-content-frame { + padding: 0.25rem 0 0; +} + +.app-footer { + margin-top: auto; + padding: 1rem 0 calc(1.35rem + env(safe-area-inset-bottom, 0px)); +} + +.app-footer__inner { + border-top: 1px solid rgba(255,255,255,0.08); + padding-top: 1.25rem; +} + +h1, h2, h3, h4, h5, h6 { + color: #ffffff; + letter-spacing: -0.02em; +} + +.card, +.modal-content, +.dropdown-menu, +.list-group-item, +.table, +.alert, +.pagination .page-link, +.nav-tabs, +.input-group-text, +.form-control, +.form-select, +.btn, +.progress, +.toast { + border-radius: 18px; +} + +.card, +.modal-content, +.table-responsive, +.alert, +.list-group-item, +.pagination .page-link, +.nav-tabs, +.input-group-text, +.form-control, +.form-select, +.progress, +.toast, +.page-link, +.table, +.btn-group > .btn { + border-color: var(--app-border) !important; +} + +.card, +.modal-content, +.table-responsive, +.alert, +.list-group-item, +.progress, +.toast { + background: var(--app-surface) !important; + box-shadow: var(--app-shadow); + backdrop-filter: blur(10px); +} + +.card-body, +.modal-body, +.modal-header, +.modal-footer { + background: transparent; +} + +.bg-dark, +.table-dark, +.list-group-item.bg-dark, +.modal-content.bg-dark, +.card.bg-dark, +.card.bg-secondary, +.list-group-item.item-not-checked { + background: var(--app-surface) !important; + color: var(--app-text) !important; +} + +.card.bg-secondary.bg-opacity-10, +#share-card { + background: linear-gradient(180deg, rgba(16, 29, 49, 0.96), rgba(10, 20, 36, 0.94)) !important; +} + +.table-dark { + --bs-table-bg: transparent; + --bs-table-striped-bg: rgba(255,255,255,0.03); + --bs-table-hover-bg: rgba(255,255,255,0.05); + --bs-table-color: var(--app-text); + margin-bottom: 0; +} + +.table > :not(caption) > * > * { + padding: 0.9rem 1rem; + border-bottom-color: rgba(255,255,255,0.08); +} + +.list-group { + gap: 0.8rem; +} + +.list-group-item { + margin-bottom: 0; + padding: 1rem 1rem; + color: var(--app-text) !important; +} + +.list-group-item.bg-success { + background: linear-gradient(135deg, rgba(39,208,125,0.92), rgba(22,150,91,0.96)) !important; +} + +.list-group-item.bg-warning { + background: linear-gradient(135deg, rgba(246,196,83,0.96), rgba(224,164,26,0.96)) !important; + color: #1c1b17 !important; +} + +.btn { + border-radius: 14px; + font-weight: 600; + padding: 0.7rem 1rem; + box-shadow: none; +} + +.btn-sm { + padding: 0.55rem 0.85rem; + border-radius: 12px; +} + +.btn-success, +.btn-outline-success:hover { + background: linear-gradient(135deg, #29d17d, #1ea860); + border-color: rgba(41,209,125,0.9); +} + +.btn-outline-light, +.btn-outline-secondary, +.btn-outline-warning, +.btn-outline-primary, +.btn-outline-success { + background: rgba(255,255,255,0.02); +} + +.btn:hover, +.btn:focus { + transform: translateY(-1px); +} + +.form-control, +.form-select, +.input-group-text { + min-height: 48px; + background: rgba(5, 13, 23, 0.86) !important; + color: var(--app-text) !important; + box-shadow: none !important; +} + +.form-control::placeholder { color: rgba(210, 224, 244, 0.45); } +.form-control:focus, +.form-select:focus { + border-color: rgba(39,208,125,0.5) !important; + box-shadow: 0 0 0 0.2rem rgba(39,208,125,0.15) !important; +} + +.nav-tabs { + gap: 0.5rem; + border-bottom: none; + background: rgba(255,255,255,0.03); + padding: 0.4rem; +} + +.nav-tabs .nav-link { + border-radius: 14px; + color: var(--app-text-muted); + border: none; + padding: 0.8rem 1rem; +} + +.nav-tabs .nav-link.active { + background: rgba(39,208,125,0.12); + color: #fff; +} + +.progress { + overflow: hidden; + background: rgba(255,255,255,0.06); + min-height: 1rem; +} + +.badge { + border: 1px solid rgba(255,255,255,0.08); +} + +.pagination .page-link { + background: rgba(255,255,255,0.03); + color: var(--app-text); + margin: 0 0.15rem; +} + +.pagination .page-item.active .page-link { + background: rgba(39,208,125,0.18); + color: #fff; +} + +.modal-content { + overflow: hidden; +} + +.toast-container { z-index: 1200; } + +#items .list-group-item { + border-radius: 18px !important; + padding: 1rem 1rem; +} + +#items .btn-group { + gap: 0.4rem; +} + +#items .btn-group .btn { + border-radius: 12px !important; +} + +.large-checkbox { + width: 1.35rem; + height: 1.35rem; + accent-color: #29d17d; +} + +#share-card .badge, +#total-expense1, +#total-expense2, +#total-expense { + background: transparent; + color: #dfffea !important; +} + +#share-card, +.card, +.table-responsive, +.alert, +.modal-content, +#expenseChartWrapper, +#categoryChartWrapper { + border-radius: var(--app-radius) !important; +} + +.endpoint-login .app-content-frame, +.endpoint-system_auth .app-content-frame, +.endpoint-page_not_found .app-content-frame, +.endpoint-forbidden .app-content-frame { + max-width: 560px; + margin: 3rem auto 0; +} + +.endpoint-main_page .list-group-item, +.endpoint-expenses .card, +.endpoint-admin_panel .card, +.endpoint-view_list .card, +.endpoint-shared_list .card, +.endpoint-edit_my_list .card, +[class*="endpoint-admin_"] .card { + overflow: hidden; +} + +input[type="checkbox"].form-check-input { + width: 2.9rem; + height: 1.5rem; +} + +hr { + border-color: rgba(255,255,255,0.08); +} + +@media (max-width: 991.98px) { + .app-header { + padding-top: 0.55rem; + } + + .app-navbar .container-xxl { + border-radius: 26px; + padding-top: 0.8rem; + padding-bottom: 0.8rem; + align-items: flex-start; + } + + .app-navbar__actions, + .app-navbar__meta { + width: 100%; + justify-content: flex-start; + } +} + +@media (max-width: 767.98px) { + .app-main { + padding-top: 0.7rem; + } + + .card-body, + .list-group-item, + .modal-body, + .modal-header, + .modal-footer, + .table > :not(caption) > * > * { + padding-left: 0.85rem; + padding-right: 0.85rem; + } + + .btn-group, + .d-flex.gap-2, + .d-flex.gap-3 { + gap: 0.45rem !important; + } + + .btn-group > .btn, + .btn.w-100, + .input-group > .btn { + min-height: 46px; + } + + .input-group { + flex-wrap: wrap; + gap: 0.55rem; + } + + .input-group > .form-control, + .input-group > .form-select, + .input-group > .btn, + .input-group > .input-group-text { + width: 100% !important; + flex: 1 1 100% !important; + border-radius: 14px !important; + max-width: 100% !important; + } + + #items .d-flex.align-items-center.gap-2.flex-grow-1 { + width: 100%; + align-items: flex-start !important; + } + + #items .btn-group { + width: 100%; + margin-top: 0.85rem; + } + + #items .btn-group .btn { + flex: 1 1 0; + } + + .table-responsive { + border-radius: 18px; + } + + .pagination { + flex-wrap: wrap; + gap: 0.25rem; + justify-content: center; + } +} + +/* ========================================================= + Compact minimalist pass +========================================================= */ +:root { + --app-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); + --app-radius: 14px; +} + +body.app-body { + font-size: 0.96rem; + line-height: 1.4; +} + +.app-header { + padding: 0.35rem 0 0; +} + +.app-navbar .container-xxl { + min-height: 54px; + border-radius: 18px; + box-shadow: 0 8px 22px rgba(0,0,0,0.16); +} + +.app-brand { + gap: 0.6rem; + font-size: 0.98rem; +} + +.app-brand__icon { + width: 2.1rem; + height: 2.1rem; + border-radius: 12px; + font-size: 1rem; +} + +.app-brand__title, +.app-brand__accent { + font-size: 0.98rem; +} + +.app-user-chip { + padding: 0.28rem 0.38rem 0.28rem 0.58rem; + gap: 0.4rem; +} + +.app-user-chip__label { + font-size: 0.68rem; + letter-spacing: 0.06em; +} + +.app-main { + padding: 0.65rem 0 1.4rem; +} + +.app-content-frame { + padding-top: 0.1rem; +} + +.app-footer { + margin-top: auto; + padding: 0.5rem 0 calc(0.85rem + env(safe-area-inset-bottom, 0px)); +} + +.app-footer__inner { + padding-top: 0.75rem; +} + +h1, h2, h3, h4, h5, h6 { + letter-spacing: -0.015em; + line-height: 1.15; +} + +h1, .h1 { font-size: clamp(1.45rem, 2vw, 1.9rem); } +h2, .h2 { font-size: clamp(1.2rem, 1.8vw, 1.5rem); } +h3, .h3 { font-size: clamp(1.02rem, 1.5vw, 1.2rem); } + +.card, +.modal-content, +.dropdown-menu, +.list-group-item, +.table, +.alert, +.pagination .page-link, +.nav-tabs, +.input-group-text, +.form-control, +.form-select, +.btn, +.progress, +.toast { + border-radius: 12px; +} + +.card, +.modal-content, +.table-responsive, +.alert, +.list-group-item, +.progress, +.toast { + box-shadow: 0 4px 14px rgba(0,0,0,0.12); + backdrop-filter: blur(8px); +} + +.card-header, +.card-footer, +.card-body, +.modal-header, +.modal-body, +.modal-footer { + padding: 0.75rem 0.85rem; +} + +.table > :not(caption) > * > * { + padding: 0.62rem 0.7rem; +} + +.table-responsive table { + min-width: 860px; +} + +.list-group { + gap: 0.5rem; +} + +.list-group-item { + padding: 0.72rem 0.8rem; +} + +.alert { + padding: 0.7rem 0.85rem; + margin-bottom: 0.8rem; +} + +.badge { + font-weight: 600; + padding: 0.38em 0.58em; +} + +.btn { + border-radius: 10px; + font-weight: 600; + padding: 0.52rem 0.8rem; + min-height: 40px; +} + +.btn-sm { + padding: 0.4rem 0.64rem; + min-height: 34px; + border-radius: 9px; +} + +.form-control, +.form-select, +.input-group-text { + min-height: 40px; + padding: 0.5rem 0.72rem; +} + +textarea.form-control { + min-height: 96px; +} + +.progress { + min-height: 0.8rem; +} + +.progress-label { + font-size: 0.72rem; + font-weight: 600; +} + +.nav-tabs { + gap: 0.35rem; + padding: 0.25rem; +} + +.nav-tabs .nav-link { + padding: 0.55rem 0.7rem; + border-radius: 10px; +} + +#items .list-group-item { + border-radius: 12px !important; + padding: 0.75rem 0.8rem; +} + +#items .btn-group { + gap: 0.25rem; +} + +#items .btn-group .btn { + border-radius: 9px !important; +} + +input[type="checkbox"].form-check-input { + width: 2.5rem; + height: 1.35rem; +} + +.large-checkbox { + width: 1.2rem; + height: 1.2rem; +} + +.toast { + padding: 0; +} + +.endpoint-main_page .card h2, +.endpoint-expenses .card h2, +.endpoint-edit_my_list .card h2, +.endpoint-login .card h2, +.endpoint-system_auth .card h2, +.endpoint-admin_panel .card h2, +[class*="endpoint-admin_"] .card h2 { + margin-bottom: 0.35rem; +} + +.endpoint-main_page .card p, +.endpoint-expenses .card p, +.endpoint-edit_my_list .card p, +.endpoint-login .card p, +.endpoint-system_auth .card p, +.endpoint-admin_panel .card p, +[class*="endpoint-admin_"] .card p { + margin-bottom: 0; +} + +@media (max-width: 991.98px) { + .app-navbar .container-xxl { + border-radius: 16px; + padding-top: 0.55rem; + padding-bottom: 0.55rem; + } +} + +@media (max-width: 767.98px) { + body.app-body { + font-size: 0.93rem; + } + + .app-header { + padding-top: 0.25rem; + } + + .app-main { + padding-top: 0.45rem; + } + + .app-navbar .container-xxl { + min-height: 50px; + border-radius: 14px; + } + + .app-brand { + gap: 0.45rem; + font-size: 0.92rem; + } + + .app-brand__icon { + width: 1.9rem; + height: 1.9rem; + border-radius: 10px; + } + + .app-user-chip { + padding: 0.22rem 0.32rem 0.22rem 0.5rem; + } + + .card-header, + .card-footer, + .card-body, + .modal-header, + .modal-body, + .modal-footer, + .list-group-item, + .table > :not(caption) > * > * { + padding-left: 0.68rem; + padding-right: 0.68rem; + } + + .list-group-item, + #items .list-group-item { + padding-top: 0.62rem; + padding-bottom: 0.62rem; + } + + .btn-group, + .d-flex.gap-2, + .d-flex.gap-3 { + gap: 0.35rem !important; + } + + .btn-group > .btn, + .btn.w-100, + .input-group > .btn, + .btn, + .form-control, + .form-select, + .input-group-text { + min-height: 38px; + } + + .input-group { + gap: 0.4rem; + } + + .table-responsive { + border-radius: 12px; + } + + .progress-label { + font-size: 0.66rem; + } + + .app-footer { + padding-bottom: calc(0.8rem + env(safe-area-inset-bottom, 0px)); + } +} + +/* === unified UI refresh: forms / tables / admin tools === */ +:root { + --ui-surface-1: rgba(10, 14, 24, 0.78); + --ui-surface-2: rgba(18, 25, 39, 0.92); + --ui-surface-3: rgba(33, 44, 67, 0.88); + --ui-border: rgba(255, 255, 255, 0.12); + --ui-border-strong: rgba(255, 255, 255, 0.18); + --ui-text-soft: rgba(255, 255, 255, 0.72); + --ui-success-soft: rgba(25, 135, 84, 0.18); + --ui-warning-soft: rgba(255, 193, 7, 0.16); +} + +.card, +.table-responsive, +.modal-content, +.dropdown-menu, +.toast, +.alert, +.list-group-item { + border: 1px solid var(--ui-border); +} + +.card.bg-dark, +.modal-content.bg-dark, +.dropdown-menu-dark, +.list-group-item.bg-dark, +.table-dark { + background: linear-gradient(180deg, var(--ui-surface-2), rgba(8, 12, 20, 0.96)) !important; +} + +.card.bg-secondary.bg-opacity-10, +.admin-shortcuts, +#bulkBar .card { + background: linear-gradient(180deg, rgba(22, 29, 45, 0.88), rgba(12, 18, 30, 0.88)) !important; +} + +.form-label, +label.form-label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-bottom: 0.45rem; + font-size: 0.92rem; + font-weight: 600; + color: rgba(255,255,255,0.84); +} + +.form-text, +.text-secondary, +.text-white-50 { + color: var(--ui-text-soft) !important; +} + +.form-control, +.form-select, +.input-group-text, +textarea.form-control, +.form-control-color { + background: rgba(255,255,255,0.04) !important; + border: 1px solid var(--ui-border-strong) !important; + color: #fff !important; + box-shadow: none; + transition: border-color .18s ease, box-shadow .18s ease, background-color .18s ease, transform .18s ease; +} + +.form-control::placeholder, +textarea.form-control::placeholder { + color: rgba(255,255,255,0.42); +} + +.form-control:focus, +.form-select:focus, +textarea.form-control:focus, +.form-check-input:focus, +.btn:focus, +.btn:focus-visible { + border-color: rgba(25, 135, 84, 0.6) !important; + box-shadow: 0 0 0 0.2rem rgba(25, 135, 84, 0.16) !important; +} + +.input-group > .form-control, +.input-group > .form-select, +.input-group > .btn, +.input-group > .input-group-text { + min-height: 42px; +} + +textarea.form-control { + line-height: 1.45; + resize: vertical; +} + +.form-check { + padding: 0.65rem 0.9rem 0.65rem 2.8rem; + background: rgba(255,255,255,0.04); + border: 1px solid var(--ui-border); + border-radius: 12px; +} + +.form-check.form-switch { + min-height: 42px; +} + +.form-check-input { + background-color: rgba(255,255,255,0.14); + border-color: rgba(255,255,255,0.22); +} + +.btn { + letter-spacing: 0.01em; + transition: transform .18s ease, box-shadow .18s ease, background-color .18s ease, border-color .18s ease; +} + +.btn:hover, +.btn:focus-visible { + transform: translateY(-1px); +} + +.btn-success, +.btn-outline-success, +.btn-outline-light:hover, +.btn-outline-light:focus-visible, +.btn-outline-secondary:hover, +.btn-outline-secondary:focus-visible { + box-shadow: 0 10px 24px rgba(0,0,0,0.16); +} + +.btn-outline-light, +.btn-outline-secondary, +.btn-outline-warning, +.btn-outline-danger, +.btn-outline-success { + background: rgba(255,255,255,0.03); +} + +.btn-outline-light:hover, +.btn-outline-light:focus-visible { + background: rgba(255,255,255,0.1); +} + +.btn-outline-secondary:hover, +.btn-outline-secondary:focus-visible { + background: rgba(108, 117, 125, 0.18); +} + +.btn-outline-warning:hover, +.btn-outline-warning:focus-visible { + background: var(--ui-warning-soft); +} + +.btn-outline-danger:hover, +.btn-outline-danger:focus-visible { + background: rgba(220, 53, 69, 0.16); +} + +.btn-outline-success:hover, +.btn-outline-success:focus-visible { + background: var(--ui-success-soft); +} + +.btn-group-compact, +.admin-shortcuts .d-flex, +.stack-form, +.page-actions { + gap: 0.4rem; +} + +.btn-group-compact .btn { + padding: 0.3rem 0.55rem; + font-size: 0.82rem; +} + +.btn-group-compact .btn-text { + font-size: 0.78rem; +} + +.table-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 0.85rem; +} + +.table-toolbar__search { + max-width: 420px; + width: 100%; +} + +.table-toolbar__meta { + min-width: 120px; + text-align: right; +} + +.table { + --bs-table-bg: transparent; + --bs-table-striped-bg: rgba(255,255,255,0.02); + --bs-table-hover-bg: transparent; + --bs-table-border-color: rgba(255,255,255,0.08); + margin-bottom: 0; +} + +.table > :not(caption) > * > * { + border-bottom-width: 1px; + vertical-align: middle; +} + +.table thead th { + position: sticky; + top: 0; + z-index: 1; + background: rgba(11, 17, 28, 0.98) !important; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.74rem; + color: rgba(255,255,255,0.72); + border-bottom-color: rgba(255,255,255,0.14); +} + +.table tbody tr { + transition: background-color .15s ease, transform .15s ease; +} + +.table tbody tr:hover > * { + background: rgba(255,255,255,0.04) !important; +} + +.table td code, +.api-code-block code { + display: inline-block; + padding: 0.28rem 0.48rem; + border-radius: 8px; + background: rgba(255,255,255,0.08); + color: #d6f5e6; +} + +.api-code-block { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.pagination .page-link { + background: rgba(255,255,255,0.03); + border-color: var(--ui-border); + color: #fff; +} + +.pagination .page-item.active .page-link { + background: rgba(25, 135, 84, 0.95); + border-color: rgba(25, 135, 84, 0.95); +} + +.dropdown-item { + border-radius: 8px; +} + +.dropdown-item:hover, +.dropdown-item:focus { + background: rgba(255,255,255,0.08); +} + +form[data-unsaved-warning="true"].is-dirty { + position: relative; +} + +form[data-unsaved-warning="true"].is-dirty::after { + content: 'Niezapisane zmiany'; + position: sticky; + bottom: 0.75rem; + left: 100%; + display: inline-flex; + margin-top: 1rem; + padding: 0.38rem 0.68rem; + border-radius: 999px; + background: rgba(255, 193, 7, 0.18); + border: 1px solid rgba(255, 193, 7, 0.36); + color: #ffe08a; + font-size: 0.76rem; + font-weight: 700; +} + +.ui-password-toggle { + min-width: 52px; +} + +.ui-password-toggle.is-active { + background: rgba(255,255,255,0.1); +} + +.app-content-frame > h2 + .card, +.app-content-frame > .card:first-of-type { + margin-top: 0; +} + +@media (max-width: 767.98px) { + .table-toolbar { + align-items: stretch; + } + + .table-toolbar__meta { + text-align: left; + } + + .admin-shortcuts .btn { + flex: 1 1 calc(50% - 0.55rem); + } + + form[data-unsaved-warning="true"].is-dirty::after { + left: auto; + bottom: auto; + position: static; + margin-top: 0.75rem; + } +} + + +.admin-page-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.85rem; + flex-wrap: wrap; +} + +[data-admin-nav-body] { + display: flex; +} + +@media (max-width: 767.98px) { + [data-admin-nav-body] { + display: none; + width: 100%; + } + + [data-admin-nav-body].is-open { + display: flex; + } + + .admin-page-head > * { + width: 100%; + } + + .admin-page-head .btn { + width: 100%; + } + + .table-responsive table.table, + .is-admin-area table.table { + min-width: 100%; + } + + .table-responsive table.table thead, + .is-admin-area table.table thead { + display: none; + } + + .table-responsive table.table, + .table-responsive table.table tbody, + .table-responsive table.table tr, + .table-responsive table.table td, + .is-admin-area table.table, + .is-admin-area table.table tbody, + .is-admin-area table.table tr, + .is-admin-area table.table td { + display: block; + width: 100%; + } + + .table-responsive table.table tbody, + .is-admin-area table.table tbody { + display: grid; + gap: 0.8rem; + } + + .table-responsive table.table tr, + .is-admin-area table.table tr { + border: 1px solid rgba(255,255,255,0.08); + border-radius: 16px; + padding: 0.35rem 0.55rem; + background: rgba(255,255,255,0.02); + box-shadow: 0 8px 24px rgba(0,0,0,0.16); + } + + .table-responsive table.table td, + .is-admin-area table.table td { + border: 0; + padding: 0.5rem 0.35rem; + } + + .table-responsive table.table td::before, + .is-admin-area table.table td::before { + content: attr(data-label); + display: block; + margin-bottom: 0.18rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgba(255,255,255,0.58); + } + + .table-responsive table.table td:last-child, + .is-admin-area table.table td:last-child { + padding-bottom: 0.2rem; + } + + .table-responsive table.table td .btn, + .table-responsive table.table td .input-group, + .table-responsive table.table td .form-select, + .table-responsive table.table td .form-control, + .is-admin-area table.table td .btn, + .is-admin-area table.table td .input-group, + .is-admin-area table.table td .form-select, + .is-admin-area table.table td .form-control { + width: 100%; + } + + .table-responsive table.table td .d-flex, + .table-responsive table.table td .btn-group, + .is-admin-area table.table td .d-flex, + .is-admin-area table.table td .btn-group { + flex-wrap: wrap; + } +} + + +.list-action-block .input-group .btn, +.list-action-block .btn, +.endpoint-shared_list .input-group .btn, +.endpoint-shared_list .btn { + min-height: 44px; +} + +.endpoint-shared_list .input-group, +.list-action-block .input-group { + align-items: stretch; +} + +@media (max-width: 767.98px) { + .endpoint-admin_panel .table-responsive table thead { + display: none; + } + .endpoint-admin_panel .table-responsive table, + .endpoint-admin_panel .table-responsive tbody, + .endpoint-admin_panel .table-responsive tr, + .endpoint-admin_panel .table-responsive td { + display: block; + width: 100%; + } + .endpoint-admin_panel .table-responsive tr { + border: 1px solid rgba(255,255,255,.08); + border-radius: 16px; + margin-bottom: 1rem; + padding: .75rem; + background: rgba(255,255,255,.02); + } + .endpoint-admin_panel .table-responsive td { + border: 0; + padding: .35rem 0; + } +} + + +/* responsive fixes 2026-03 */ +.app-navbar .container-xxl {flex-wrap: nowrap; align-items: center;} +.app-navbar__actions {display:flex; flex-wrap:wrap; gap:.5rem; justify-content:flex-end;} +.app-navbar__actions .btn {white-space: nowrap;} +.table-responsive {overflow-x: clip;} +.table-responsive table {width:100%; min-width:0 !important;} +@media (max-width: 991.98px) { + .app-navbar .container-xxl {display:grid; grid-template-columns:auto 1fr auto; gap:.5rem; align-items:center;} + .app-navbar__meta {display:none;} + .app-brand {min-width:0;} + .app-brand__title,.app-brand__accent {font-size:1rem;} + .app-navbar__actions {max-width:100%; gap:.35rem;} + .app-navbar__actions .btn {padding:.35rem .55rem; font-size:.78rem;} +} +@media (max-width: 430px) { + .app-navbar .container-xxl {grid-template-columns:minmax(0,1fr) auto; } + .app-brand {overflow:hidden;} + .app-brand > span:last-child {overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} + .app-navbar__actions {grid-column:1 / -1; justify-content:stretch;} + .app-navbar__actions .btn {flex:1 1 calc(50% - .35rem); text-align:center;} +} +@media (max-width: 767.98px) { + .app-content-frame .table-responsive table.table, + .app-content-frame table.table:not(.keep-horizontal) {display:block; width:100%;} + .app-content-frame .table-responsive table.table thead, + .app-content-frame table.table:not(.keep-horizontal) thead {display:none;} + .app-content-frame .table-responsive table.table tbody, + .app-content-frame .table-responsive table.table tr, + .app-content-frame .table-responsive table.table td, + .app-content-frame table.table:not(.keep-horizontal) tbody, + .app-content-frame table.table:not(.keep-horizontal) tr, + .app-content-frame table.table:not(.keep-horizontal) td {display:block; width:100%;} + .app-content-frame .table-responsive table.table tr, + .app-content-frame table.table:not(.keep-horizontal) tr {border:1px solid var(--dark-300); border-radius:1rem; margin-bottom:.85rem; padding:.35rem .25rem; background:var(--dark-700);} + .app-content-frame .table-responsive table.table td, + .app-content-frame table.table:not(.keep-horizontal) td {border:none; padding:.5rem .75rem; text-align:left !important;} + .app-content-frame .table-responsive table.table td::before, + .app-content-frame table.table:not(.keep-horizontal) td::before {content:attr(data-label); display:block; font-size:.72rem; color:#9ba3aa; margin-bottom:.2rem; text-transform:uppercase;} + .app-content-frame .table-responsive {overflow:visible;} +} + +/* fix: admin tables, api tokens, share page, navbar */ +.admin-table-responsive { + overflow-x: auto !important; + -webkit-overflow-scrolling: touch; +} + + +.admin-table-responsive--wide table { + min-width: 1180px; +} + +.admin-table-responsive--full table { + width: 100% !important; + min-width: 100% !important; + table-layout: auto; +} + +.endpoint-edit_categories .admin-table-responsive--full table th, +.endpoint-edit_categories .admin-table-responsive--full table td, +.endpoint-api_tokens .admin-table-responsive--full table th, +.endpoint-api_tokens .admin-table-responsive--full table td { + white-space: normal; + vertical-align: middle; +} + +.endpoint-edit_categories .admin-table-responsive--full table th:last-child, +.endpoint-edit_categories .admin-table-responsive--full table td:last-child { + width: 30%; +} + +.is-admin-area .table-responsive td, +.is-admin-area .table-responsive th { + white-space: normal; +} + +.api-chip { + display: inline-block; + max-width: 22rem; + padding: .28rem .55rem; + border-radius: .75rem; + background: rgba(255,255,255,.08); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; +} + +.api-chip--wrap { + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.form-check-spaced { + display: flex; + align-items: flex-start; + gap: .7rem; + padding-left: 0; + margin-bottom: .65rem; +} + +.form-check-spaced .form-check-input { + position: static; + margin: .2rem 0 0; + flex: 0 0 auto; +} + +.form-check-spaced .form-check-label { + margin: 0; + line-height: 1.35; +} + +.list-item-actions { + gap: .4rem; + flex-wrap: wrap; + justify-content: flex-end; +} + +.list-item-actions .btn { + border-radius: .8rem !important; + min-width: 2.6rem; +} + +.share-submit-btn { + min-width: 8rem; + font-weight: 600; +} + +.endpoint-list_share .input-group, +.endpoint-shared_list .input-group { + align-items: stretch; +} + +.endpoint-list_share .input-group > .form-control, +.endpoint-list_share .input-group > .btn, +.endpoint-shared_list .input-group > .form-control, +.endpoint-shared_list .input-group > .btn { + min-height: 46px; +} + +.endpoint-list_share .input-group > .btn, +.endpoint-shared_list .input-group > .btn { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; +} + +.app-navbar .container-xxl { + row-gap: .55rem; +} + +.app-navbar__actions { + min-width: 0; +} + +.app-navbar__actions .btn { + min-width: 0; +} + +@media (max-width: 767.98px) { + .table-responsive { + overflow-x: auto !important; + } + + .is-admin-area .table-responsive table.table.keep-horizontal, + .endpoint-api_tokens .table-responsive table.table, + .endpoint-admin_panel .table-responsive table.table, + .endpoint-lists_access .table-responsive table.table, + .endpoint-user_management .table-responsive table.table, + .endpoint-edit_categories .table-responsive table.table { + display: table; + width: max-content; + min-width: 980px !important; + } + + .endpoint-api_tokens .admin-table-responsive--full table.table, + .endpoint-edit_categories .admin-table-responsive--full table.table { + width: 100%; + min-width: 980px !important; + } + + .is-admin-area .table-responsive table.table.keep-horizontal thead, + .endpoint-api_tokens .table-responsive table.table thead, + .endpoint-admin_panel .table-responsive table.table thead, + .endpoint-lists_access .table-responsive table.table thead, + .endpoint-user_management .table-responsive table.table thead { + display: table-header-group; + } + + .is-admin-area .table-responsive table.table.keep-horizontal tbody, + .endpoint-api_tokens .table-responsive table.table tbody, + .endpoint-admin_panel .table-responsive table.table tbody, + .endpoint-lists_access .table-responsive table.table tbody, + .endpoint-user_management .table-responsive table.table tbody { + display: table-row-group; + } + + .is-admin-area .table-responsive table.table.keep-horizontal tr, + .endpoint-api_tokens .table-responsive table.table tr, + .endpoint-admin_panel .table-responsive table.table tr, + .endpoint-lists_access .table-responsive table.table tr, + .endpoint-user_management .table-responsive table.table tr { + display: table-row; + border: 0; + padding: 0; + background: transparent; + box-shadow: none; + } + + .is-admin-area .table-responsive table.table.keep-horizontal td, + .endpoint-api_tokens .table-responsive table.table td, + .endpoint-admin_panel .table-responsive table.table td, + .endpoint-lists_access .table-responsive table.table td, + .endpoint-user_management .table-responsive table.table td { + display: table-cell; + width: auto; + border-top: 1px solid var(--dark-450); + padding: .65rem .75rem; + } + + .endpoint-api_tokens .table-responsive table.table td::before, + .endpoint-admin_panel .table-responsive table.table td::before, + .endpoint-lists_access .table-responsive table.table td::before, + .endpoint-user_management .table-responsive table.table td::before { + display: none; + content: none; + } +} + +@media (max-width: 575.98px) { + .app-navbar .container-xxl { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + } + + .app-navbar__meta { + display: none; + } + + .app-brand { + min-width: 0; + overflow: hidden; + } + + .app-brand > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app-navbar__actions { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .45rem; + width: 100%; + } + + .app-navbar__actions .btn { + width: 100%; + padding: .45rem .5rem; + font-size: .78rem; + } + + .share-submit-btn { + min-width: 6.75rem; + } + + .list-item-actions { + width: 100%; + justify-content: flex-start; + margin-top: .5rem; + } +} + + +/* admin/settings consistency fixes */ +.form-switch-compact .form-check-input { + width: 2.35rem; + height: 1.2rem; + margin-top: .1rem; +} +.form-switch-compact .form-check-label { + padding-top: .02rem; +} +.form-check-spaced { + gap: .45rem; +} +.access-editor .input-group > .form-control, +.access-editor .input-group > .btn { + min-height: 40px; +} +.endpoint-admin_edit_categories .table-responsive, +.endpoint-admin_lists_access .table-responsive, +.endpoint-admin_settings .table-responsive, +.endpoint-list_products .table-responsive { + overflow-x: auto !important; +} +.endpoint-admin_edit_categories .table-responsive table.table.keep-horizontal, +.endpoint-admin_lists_access .table-responsive table.table.keep-horizontal, +.endpoint-list_products .table-responsive table.table.keep-horizontal { + min-width: 1000px !important; +} +.endpoint-admin_edit_categories .app-content-frame, +.endpoint-admin_lists_access .app-content-frame, +.endpoint-admin_settings .app-content-frame, +.endpoint-list_products .app-content-frame { + overflow: visible; +} +@media (max-width: 767.98px) { + .form-switch-compact .form-check-input { width: 2rem; height: 1.05rem; } +} + + +/* v4.1 admin/table/share fixes */ +.admin-table-responsive table { + width: 100%; + min-width: 100%; +} +.admin-table-responsive--wide table, +.table-responsive .keep-horizontal { + width: max-content; + min-width: 100%; +} +.endpoint-admin_panel .admin-table-responsive--wide table { + width: 100%; + min-width: 100%; +} +.endpoint-admin_panel .admin-panel-table th:last-child, +.endpoint-admin_panel .admin-panel-table td:last-child { + width: 1%; + white-space: nowrap; +} +.endpoint-admin_lists_access .admin-table-responsive--wide table { + min-width: 1120px; +} +.endpoint-admin_lists_access .access-editor .tokens { + min-height: 2rem; +} +.endpoint-admin_lists_access .access-editor .token, +.endpoint-admin_edit_my_list .access-editor .token { + max-width: 100%; +} +.endpoint-admin_lists_access .access-editor .token span, +.endpoint-admin_edit_my_list .access-editor .token span { + pointer-events: none; +} +.endpoint-admin_lists_access .mono { + white-space: nowrap; +} +.form-check-spaced { + gap: .35rem; +} +.form-check-spaced .form-check-input, +input[type="checkbox"].form-check-input, +.table-select-checkbox { + width: .95rem !important; + height: .95rem !important; + min-width: .95rem !important; + min-height: .95rem !important; + margin-top: .18rem; +} +.form-switch-compact .form-check-input { + width: 1.8rem !important; + height: .95rem !important; +} +.large-checkbox { + transform: none; + transform-origin: center; +} + +.shopping-item-main > .large-checkbox { + flex: 0 0 1.5em; + align-self: center; +} +.list-item-actions { + display: flex; + align-items: center; + gap: .45rem; + flex-wrap: wrap; +} +.list-item-actions .btn { + min-width: 2.25rem; + padding: .42rem .62rem; +} +.endpoint-list_share .list-group-item, +.endpoint-shared_list .list-group-item { + gap: .75rem; +} +.endpoint-list_share .list-item-actions, +.endpoint-shared_list .list-item-actions { + margin-left: auto; +} +.endpoint-list_share .input-group, +.endpoint-shared_list .input-group { + flex-wrap: nowrap; +} +.endpoint-list_share .input-group > .form-control, +.endpoint-shared_list .input-group > .form-control { + min-width: 0; +} +.endpoint-list_share .share-submit-btn, +.endpoint-shared_list .share-submit-btn { + min-width: 7.25rem; + border-radius: .9rem !important; +} +@media (max-width: 991.98px) { + .endpoint-admin_panel .admin-panel-table { + min-width: 1000px; + } +} +@media (max-width: 767.98px) { + .endpoint-admin_panel .admin-table-responsive--wide table, + .endpoint-admin_lists_access .admin-table-responsive--wide table, + .endpoint-api_tokens .admin-table-responsive--wide table { + width: max-content; + min-width: 980px !important; + } + .endpoint-list_share .input-group, + .endpoint-shared_list .input-group { + flex-wrap: wrap; + } + .endpoint-list_share .share-submit-btn, + .endpoint-shared_list .share-submit-btn { + width: 100%; + } +} +@media (max-width: 430px) { + .app-brand__icon { + width: 2rem; + height: 2rem; + font-size: 1rem; + } + .app-brand__title, .app-brand__accent { + font-size: 1rem; + } + .app-navbar__actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .app-navbar__actions .btn { + padding: .38rem .45rem; + font-size: .74rem; + } +} + +.endpoint-admin_api_tokens .admin-table-responsive--wide table { width: 100%; min-width: 100%; } +@media (max-width: 767.98px) { .endpoint-admin_api_tokens .admin-table-responsive--wide table { width:max-content; min-width: 980px !important; } } +.table-responsive { overflow-x: auto; } + + +/* v6 tweaks */ +.create-list-switch, +.hide-purchased-switch { + display: inline-flex; + align-items: center; + gap: .5rem; + padding: .45rem .8rem .45rem 2.35rem; + min-height: 0; + width: auto; + background: rgba(255,255,255,0.04); + border: 1px solid var(--ui-border); + border-radius: 12px; +} +.create-list-switch .form-check-input, +.hide-purchased-switch .form-check-input { + width: 2rem !important; + height: 1rem !important; + margin-top: 0; +} +.create-list-switch .form-check-label, +.hide-purchased-switch .form-check-label { + margin-left: .15rem; +} +.endpoint-admin_lists_access .card > .card-body > .table-responsive, +.endpoint-admin_api_tokens .card > .card-body > .table-responsive { + width: 100%; +} +.endpoint-admin_lists_access .table.keep-horizontal, +.endpoint-admin_api_tokens .table.keep-horizontal { + min-width: 100%; +} + + +/* v7.1 share/main fixes */ +.create-list-checkbox { + align-items: center; + gap: .55rem; +} +.create-list-checkbox .form-check-input { + margin-top: 0; +} +.endpoint-list_share #items .list-group-item, +.endpoint-shared_list #items .list-group-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: .75rem; +} +.endpoint-list_share #items .list-group-item > .d-flex.flex-grow-1, +.endpoint-shared_list #items .list-group-item > .d-flex.flex-grow-1 { + min-width: 0; + flex: 1 1 auto; +} +.endpoint-list_share .list-item-actions, +.endpoint-shared_list .list-item-actions { + flex: 0 0 auto; + margin-left: auto; + justify-content: flex-end; +} +.endpoint-list_share .list-item-actions .btn, +.endpoint-shared_list .list-item-actions .btn { + min-width: 2.5rem; +} +.endpoint-list_share .hide-purchased-switch, +.endpoint-shared_list .hide-purchased-switch { + align-items: center; +} +.endpoint-list_share .hide-purchased-switch .form-check-input, +.endpoint-shared_list .hide-purchased-switch .form-check-input { + margin-top: 0; +} +@media (max-width: 767.98px) { + .endpoint-list_share #items .list-group-item, + .endpoint-shared_list #items .list-group-item { + align-items: flex-start; + } + .endpoint-list_share .list-item-actions, + .endpoint-shared_list .list-item-actions { + width: 100%; + margin-left: 0; + justify-content: flex-start; + } +} + + +/* v9.1 switch and share consistency fixes */ +.create-list-input-group > .form-control { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} +.create-list-input-group > #tempToggle { + min-width: 9.75rem; + font-weight: 600; + white-space: nowrap; +} +.hide-purchased-switch.form-check { + display: inline-flex; + align-items: center; + gap: .7rem; + padding: .55rem .95rem; + padding-left: .95rem; + border-radius: 14px; + background: rgba(255,255,255,.04); + border: 1px solid var(--ui-border); +} +.hide-purchased-switch .form-check-input { + flex: 0 0 auto; + float: none; + width: 2.9em !important; + height: 1.5em !important; + margin: 0 !important; + cursor: pointer; +} +.hide-purchased-switch .form-check-label { + margin: 0 !important; + white-space: nowrap; + cursor: pointer; +} +.share-page-toolbar { + gap: .75rem; +} +.share-page-toolbar__spacer { + flex: 1 1 auto; +} +.endpoint-list_share .list-item-actions, +.endpoint-shared_list .list-item-actions { + gap: .5rem; +} +.endpoint-list_share .list-item-actions .btn, +.endpoint-shared_list .list-item-actions .btn { + min-width: 2.75rem; + min-height: 2.5rem; + padding: .5rem .72rem; +} +.endpoint-list_share .app-navbar__actions .btn, +.endpoint-shared_list .app-navbar__actions .btn { + border-radius: .9rem !important; +} +@media (max-width: 767.98px) { + .create-list-input-group { + flex-wrap: nowrap !important; + } + .create-list-input-group > .form-control { + min-width: 0; + } + .create-list-input-group > #tempToggle { + min-width: 8.75rem; + font-size: .92rem; + } + .share-page-toolbar { + justify-content: flex-end; + } + .share-page-toolbar__spacer { + display: none; + } + .hide-purchased-switch { + padding-left: 2.95rem; + } +} + + +/* unified bootstrap-like switches */ +.switch-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: .8rem; +} + +.form-check.form-switch.app-switch { + display: inline-flex; + align-items: center; + gap: .75rem; + min-height: auto; + margin: 0; + padding: .72rem .95rem; + padding-left: .95rem; + background: rgba(255,255,255,.04); + border: 1px solid var(--ui-border); + border-radius: 14px; +} + +.form-check.form-switch.app-switch .form-check-input { + float: none; + flex: 0 0 auto; + width: 2.9em !important; + height: 1.55em !important; + margin: 0 !important; + cursor: pointer; + background-color: var(--dark-400) !important; + border-color: var(--dark-300) !important; +} + +.form-check.form-switch.app-switch .form-check-input:checked { + background-color: var(--primary) !important; + border-color: var(--primary-border) !important; +} + +.form-check.form-switch.app-switch .form-check-input:focus { + box-shadow: 0 0 0 .18rem rgba(24, 64, 118, .22) !important; +} + +.form-check.form-switch.app-switch .form-check-label { + margin: 0 !important; + line-height: 1.35; + cursor: pointer; +} + +.form-check.form-switch.app-switch.form-switch-compact { + width: 100%; + justify-content: flex-start; +} + +.form-check.form-switch.app-switch.form-switch-compact .form-check-input { + width: 2.9em !important; + height: 1.55em !important; +} + +.hide-purchased-switch.form-check.app-switch { + width: auto; +} + +.endpoint-edit_my_list .switch-grid .app-switch, +.endpoint-admin_edit_list .switch-grid .app-switch { + width: 100%; +} + +@media (max-width: 767.98px) { + .switch-grid { + grid-template-columns: 1fr; + } + + .hide-purchased-switch.form-check.app-switch { + width: 100%; + } +} + + +/* final UX polish 2026-03-14 */ +:root { + --nav-btn-height: 2.8rem; +} + +.app-navbar .container-xxl { + display: flex; + align-items: center; + justify-content: space-between; + gap: .8rem; + flex-wrap: nowrap; +} + +.app-navbar__actions { + display: flex; + align-items: stretch; + justify-content: flex-end; + gap: .5rem; + flex-wrap: nowrap; + min-width: 0; +} + +.app-navbar__actions .btn, +.app-nav-action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--nav-btn-height); + padding: .6rem .95rem; + white-space: nowrap; + line-height: 1; +} + +.app-navbar__actions .btn > span, +.app-nav-action > span { + display: inline-flex; + align-items: center; +} + +.form-check.form-switch.app-switch { + min-height: 3.2rem; + padding: .78rem 1rem; + border-radius: 16px; +} + +.form-check.form-switch.app-switch .form-check-input { + width: 3.15em !important; + height: 1.7em !important; + background-position: left center; +} + +.form-check.form-switch.app-switch .form-check-label { + display: inline-flex; + align-items: center; + min-height: 1.7rem; + font-weight: 500; +} + +.hide-purchased-switch.form-check.app-switch { + width: auto; + max-width: 100%; +} + +.endpoint-edit_my_list .switch-grid { + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.endpoint-edit_my_list .switch-grid .app-switch, +.endpoint-admin_edit_list .switch-grid .app-switch { + width: 100%; + min-height: 3.35rem; +} + +/* boxed checks in api token form */ +.endpoint-admin_api_tokens .stack-form .form-check-spaced, +.endpoint-api_tokens .stack-form .form-check-spaced { + align-items: center; + gap: .85rem; + margin: 0 0 .72rem; + padding: .75rem .9rem; + border: 1px solid var(--ui-border); + border-radius: 14px; + background: rgba(255,255,255,.04); +} + +.endpoint-admin_api_tokens .stack-form .form-check-spaced .form-check-input, +.endpoint-api_tokens .stack-form .form-check-spaced .form-check-input { + margin: 0; +} + +.endpoint-admin_api_tokens .stack-form .form-check-spaced .form-check-label, +.endpoint-api_tokens .stack-form .form-check-spaced .form-check-label { + flex: 1 1 auto; +} + +/* admin tables full width on desktop, scroll only on smaller screens */ +.endpoint-admin_panel .table-responsive, +.endpoint-admin_lists_access .table-responsive { + overflow-x: auto; +} + +.endpoint-admin_panel .table-responsive > table.table, +.endpoint-admin_lists_access .table-responsive > table.table { + width: 100% !important; + min-width: 100% !important; + table-layout: auto; +} + +.endpoint-admin_lists_access td:nth-child(6) { + min-width: 19rem; +} + +.endpoint-admin_lists_access td:nth-child(6) > .d-flex, +.endpoint-admin_lists_access td:nth-child(6) > .text-warning { + width: 100%; +} + +.endpoint-admin_lists_access td:nth-child(6) > .text-warning { + display: block; +} + +/* share page toolbar and header buttons */ +.share-page-toolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: .75rem; + width: 100%; +} + +.share-page-toolbar .form-check { + margin-bottom: 0; +} + +.endpoint-list_share .app-navbar__actions, +.endpoint-shared_list .app-navbar__actions { + align-items: stretch; +} + +.endpoint-list_share .app-navbar__actions .btn, +.endpoint-shared_list .app-navbar__actions .btn { + min-height: var(--nav-btn-height); +} + +@media (max-width: 991.98px) { + .app-navbar .container-xxl { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: .6rem; + } + + .app-navbar__meta { + display: none; + } + + .app-brand { + min-width: 0; + overflow: hidden; + } + + .app-brand > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .app-navbar__actions { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .45rem; + width: 100%; + } + + .app-navbar__actions .btn, + .app-nav-action { + width: 100%; + padding: .55rem .6rem; + } + + .endpoint-admin_panel .table-responsive > table.table, + .endpoint-admin_lists_access .table-responsive > table.table { + width: max-content !important; + min-width: 980px !important; + } +} + +@media (max-width: 767.98px) { + .share-page-toolbar { + justify-content: stretch; + } + + .hide-purchased-switch.form-check.app-switch { + width: 100%; + } + + .endpoint-edit_my_list .switch-grid { + grid-template-columns: 1fr; + } +} + + +/* final polish v2 */ +:root { + --nav-btn-height: 2.35rem; +} + +.app-navbar__actions { + gap: .4rem; +} + +.app-navbar__actions .btn, +.app-nav-action { + min-height: var(--nav-btn-height); + padding: .42rem .78rem; + font-size: .92rem; + border-radius: .9rem !important; +} + +.form-check.form-switch.app-switch { + min-height: 2.95rem; + padding: .65rem .9rem; +} + +.form-check.form-switch.app-switch .form-check-input { + width: 2.75em !important; + height: 1.45em !important; + transition: background-position .18s ease-in-out, background-color .18s ease-in-out, border-color .18s ease-in-out, box-shadow .18s ease-in-out !important; +} + +.form-check.form-switch.app-switch .form-check-label { + min-height: 1.45rem; +} + +.endpoint-admin_templates .table-responsive { + overflow-x: auto; +} + +.endpoint-admin_templates .table-responsive > table.table { + width: 100% !important; + min-width: 100% !important; + table-layout: auto; +} + +@media (max-width: 991.98px) { + .app-navbar__actions .btn, + .app-nav-action { + font-size: .86rem; + padding: .48rem .6rem; + } + + .endpoint-admin_templates .table-responsive > table.table { + width: max-content !important; + min-width: 900px !important; + } +} + + +/* responsive mobile category badges + smaller animated switches */ +.mobile-list-heading { + width: 100%; + min-width: 0; + max-width: 100%; + justify-content: flex-start; +} + +.mobile-list-heading__title { + min-width: 0; +} + +.mobile-category-badges { + display: inline-flex; + align-items: center; + gap: .3rem; + min-width: 0; + max-width: 100%; +} + +.mobile-category-badge { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: .68rem; + line-height: 1; + padding: .26rem .52rem; + opacity: .95; +} + +.mobile-category-badge__dot { + display: none; + width: .55rem; + height: .55rem; + border-radius: 999px; + background: currentColor; +} + +.mobile-category-badges.is-compact .mobile-category-badge { + width: .9rem; + min-width: .9rem; + height: .9rem; + padding: 0; + border-radius: 999px; +} + +.mobile-category-badges.is-compact .mobile-category-badge__text { + display: none; +} + +.mobile-category-badges.is-compact .mobile-category-badge__dot { + display: block; +} + +.form-check.form-switch.app-switch { + min-height: 2.75rem; + padding: .58rem .82rem; +} + +.form-check.form-switch.app-switch .form-check-input, +.hide-purchased-switch .form-check-input { + width: 2.45em !important; + height: 1.3em !important; + background-position: left center !important; + transition: background-position .18s ease-in-out, background-color .18s ease-in-out, border-color .18s ease-in-out, box-shadow .18s ease-in-out !important; +} + +.form-check.form-switch.app-switch .form-check-input:checked, +.hide-purchased-switch .form-check-input:checked { + background-position: right center !important; +} + +.form-check.form-switch.app-switch .form-check-label { + min-height: 1.3rem; +} + +.hide-purchased-switch.form-check.app-switch { + padding: .5rem .82rem; +} + +@media (max-width: 576px) { + .mobile-list-heading { + display: inline-flex; + max-width: 100%; + } + + .mobile-list-heading__title { + max-width: 100%; + } +} + + +.endpoint-main_page .list-group-item > .main-list-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + width: 100%; + flex-wrap: nowrap; +} + +.endpoint-main_page .list-main-meta { + min-width: 0; + flex: 1 1 auto; +} + +.endpoint-main_page .list-main-title { + display: flex; + align-items: center; + align-content: center; + flex-wrap: wrap; + gap: 0.15rem; + min-width: 0; + line-height: 1; +} + +.endpoint-main_page .list-main-actions { + flex: 0 0 auto; + align-self: center; +} + +@media (max-width: 575.98px) { + .endpoint-main_page .list-group-item > .main-list-row { + flex-direction: column; + align-items: stretch; + } + + .endpoint-main_page .list-main-actions { + width: 100%; + } +} + +/* mobile UX fixes 2026-03-14 */ +.list-main-title__link { + min-width: 0; + display: inline-flex; + align-items: center; + align-content: center; + flex-wrap: wrap; + gap: .15rem; + line-height: 1; +} + +.shopping-item-row { + gap: .75rem; +} + +.shopping-item-main { + min-width: 0; + flex: 1 1 auto; + flex-wrap: wrap; +} + +.shopping-item-main span[id^="name-"] { + min-width: 0; +} + +.shopping-item-actions { + flex: 0 0 auto; + margin-left: auto; + align-self: flex-start; +} + +.shopping-compact-input-group { + flex-wrap: nowrap !important; + align-items: stretch; +} + +.shopping-qty-input { + flex: 0 0 4.5rem; + max-width: 4.5rem; +} + +.shopping-compact-submit { + flex: 0 0 auto; + width: auto; + white-space: nowrap; +} + +.ui-password-group { + flex-wrap: nowrap; +} + +.ui-password-group > .form-control { + min-width: 0; +} + +.ui-password-group > .ui-password-toggle { + flex: 0 0 auto; + width: auto; + min-width: 3rem; +} + +@media (max-width: 991.98px) { + .app-navbar__actions { + grid-template-columns: repeat(auto-fit, minmax(8.25rem, max-content)); + justify-content: end; + } + + .app-navbar__actions .btn, + .app-nav-action { + width: auto; + min-width: 8.25rem; + justify-self: end; + } +} + +@media (max-width: 575.98px) { + .endpoint-main_page .list-group-item > .main-list-row { + flex-direction: row; + align-items: center; + } + + .endpoint-main_page .list-main-actions { + width: auto; + margin-left: auto; + } + + .endpoint-main_page .list-main-actions .btn { + padding: .38rem .52rem; + min-width: 2.35rem; + } + + .endpoint-main_page .list-main-title { + display: flex; + flex-wrap: wrap; + gap: .15rem; + } + + .endpoint-main_page .list-main-meta { + flex: 1 1 auto; + min-width: 0; + } + + .endpoint-main_page .list-main-title__link { + min-width: 0; + max-width: 100%; + } + + .shopping-item-row { + align-items: flex-start !important; + } + + .shopping-item-actions { + width: auto; + margin-top: 0; + margin-left: auto; + justify-content: flex-end; + } + + .shopping-item-actions .btn { + min-width: 2.35rem; + padding: .38rem .52rem; + } + + .shopping-compact-input-group { + display: flex; + } + + .shopping-compact-input-group > .form-control { + min-width: 0; + } + + .shopping-qty-input { + flex-basis: 4rem; + max-width: 4rem; + } + + .shopping-compact-submit { + min-width: auto; + padding-left: .8rem; + padding-right: .8rem; + } + + .ui-password-group > .ui-password-toggle { + min-width: 2.75rem; + padding-left: .7rem; + padding-right: .7rem; + } +} + + +/* UX refactor 2026-03-14 b */ +.app-navbar-toggler { + border-color: rgba(255,255,255,.28); + padding: .3rem .55rem; +} + +.app-navbar-toggler:focus { + box-shadow: 0 0 0 .2rem rgba(255,255,255,.1); +} + +.app-navbar-toggler .navbar-toggler-icon { + filter: invert(1) grayscale(1); +} + +#createListTempToggle, +.create-list-temp-toggle { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; +} + +.create-list-input-group > .form-control { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.create-list-input-group > .create-list-temp-toggle { + background: transparent; + white-space: nowrap; +} + +.list-toolbar { + width: 100%; +} + +.list-toolbar--share { + justify-content: flex-end; +} + +.hide-purchased-switch--minimal { + border: 0; + background: transparent; + padding: 0; + margin-left: auto; +} + +.shopping-item-row { + padding: .8rem .95rem; +} + +.shopping-item-main { + display: flex; + align-items: center; + gap: .75rem; + width: 100%; +} + +.shopping-item-content { + flex: 1 1 auto; + min-width: 0; +} + +.shopping-item-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: .75rem; +} + +.shopping-item-text { + min-width: 0; + display: flex; + align-items: center; + gap: .35rem; + flex-wrap: wrap; +} + +.shopping-item-name { + min-width: 0; + overflow-wrap: break-word; + word-break: normal; +} + +.shopping-item-text .info-line { + flex-basis: 100%; + margin-top: .1rem; + overflow-wrap: break-word; + word-break: normal; +} + +.shopping-item-actions { + display: inline-flex; + flex-wrap: nowrap; + gap: .35rem; +} + +.shopping-product-input-group > .shopping-product-name-input, +.shopping-expense-input-group > .shopping-expense-amount-input { + flex: 1 1 auto; +} + +.shopping-product-input-group > .shopping-qty-input { + flex: 0 0 4.5rem; + max-width: 4.5rem; + text-align: center; +} + +.shopping-expense-input-group > .shopping-compact-submit, +.shopping-product-input-group > .shopping-compact-submit { + flex: 0 0 auto; +} + +.endpoint-list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-list_share .shopping-product-input-group > .shopping-compact-submit, +.endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-list .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit { + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-top-right-radius: .9rem !important; + border-bottom-right-radius: .9rem !important; + margin-left: 0; +} + +.endpoint-list .shopping-product-input-group, +.endpoint-list_share .shopping-product-input-group, +.endpoint-shared_list .shopping-product-input-group, +.endpoint-list .shopping-expense-input-group, +.endpoint-list_share .shopping-expense-input-group, +.endpoint-shared_list .shopping-expense-input-group { + flex-wrap: nowrap; +} + +@media (max-width: 991.98px) { + .navbar-collapse .app-navbar__actions { + padding-top: .6rem; + justify-content: flex-end; + } +} + +@media (max-width: 575.98px) { + .app-navbar__actions { + width: 100%; + justify-content: flex-end; + } + + .app-navbar__actions .btn, + .app-nav-action { + min-width: 0; + width: auto; + } + + .shopping-item-main { + gap: .55rem; + } + + .shopping-item-head { + gap: .45rem; + } + + .shopping-item-actions { + margin-left: auto; + align-self: flex-start; + } + + .shopping-item-actions .btn { + min-width: 2.2rem; + padding: .34rem .48rem; + } + + .shopping-product-input-group > .shopping-product-name-input, + .shopping-expense-input-group > .shopping-expense-amount-input { + flex: 0 0 60%; + min-width: 0; + } + + .shopping-product-input-group > .shopping-qty-input { + flex: 0 0 15%; + max-width: 15%; + min-width: 0; + } + + .shopping-product-input-group > .shopping-compact-submit { + flex: 0 0 25%; + width: 25%; + min-width: 0; + padding-left: .55rem; + padding-right: .55rem; + font-size: .95rem; + } + + .shopping-expense-input-group > .shopping-compact-submit { + padding-left: .7rem; + padding-right: .7rem; + } + + .list-toolbar { + align-items: flex-start !important; + } + + .list-toolbar__sort { + flex: 0 0 auto; + } + + .hide-purchased-switch--minimal { + font-size: .95rem; + } +} + + +/* UX tweak 2026-03-14 c: hamburger with full labels */ +@media (max-width: 991.98px) { + .navbar-collapse .app-navbar__actions { + width: 100%; + display: flex; + flex-direction: column; + align-items: stretch; + gap: .5rem; + } + + .navbar-collapse .app-navbar__actions .btn, + .navbar-collapse .app-nav-action { + width: 100%; + min-width: 0; + justify-content: flex-start; + text-align: left; + padding-left: .9rem; + padding-right: .9rem; + } + + .navbar-collapse .app-navbar__actions .btn > span, + .navbar-collapse .app-nav-action > span { + display: inline !important; + } +} + + +/* form input/button unification fix 2026-03-15 */ +.endpoint-list .shopping-product-input-group, +.endpoint-list_share .shopping-product-input-group, +.endpoint-shared_list .shopping-product-input-group, +.endpoint-list .shopping-expense-input-group, +.endpoint-list_share .shopping-expense-input-group, +.endpoint-shared_list .shopping-expense-input-group { + display: flex; + flex-wrap: nowrap !important; + align-items: stretch; + gap: 0 !important; +} + +.endpoint-list .shopping-product-input-group > .form-control, +.endpoint-list_share .shopping-product-input-group > .form-control, +.endpoint-shared_list .shopping-product-input-group > .form-control, +.endpoint-list .shopping-expense-input-group > .form-control, +.endpoint-list_share .shopping-expense-input-group > .form-control, +.endpoint-shared_list .shopping-expense-input-group > .form-control, +.endpoint-list .shopping-product-input-group > .btn, +.endpoint-list_share .shopping-product-input-group > .btn, +.endpoint-shared_list .shopping-product-input-group > .btn, +.endpoint-list .shopping-expense-input-group > .btn, +.endpoint-list_share .shopping-expense-input-group > .btn, +.endpoint-shared_list .shopping-expense-input-group > .btn { + position: relative; + min-height: 46px; + box-shadow: none; +} + +.endpoint-list .shopping-product-input-group > .shopping-product-name-input, +.endpoint-list_share .shopping-product-input-group > .shopping-product-name-input, +.endpoint-shared_list .shopping-product-input-group > .shopping-product-name-input, +.endpoint-list .shopping-expense-input-group > .shopping-expense-amount-input, +.endpoint-list_share .shopping-expense-input-group > .shopping-expense-amount-input, +.endpoint-shared_list .shopping-expense-input-group > .shopping-expense-amount-input { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.endpoint-list .shopping-product-input-group > .shopping-qty-input, +.endpoint-list_share .shopping-product-input-group > .shopping-qty-input, +.endpoint-shared_list .shopping-product-input-group > .shopping-qty-input { + border-radius: 0 !important; + border-left-width: 0 !important; +} + +.endpoint-list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-list_share .shopping-product-input-group > .shopping-compact-submit, +.endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-list .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .35rem; + margin-left: 0 !important; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-top-right-radius: .9rem !important; + border-bottom-right-radius: .9rem !important; + border-left-width: 0 !important; +} + +.endpoint-list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-list .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-list_share .shopping-product-input-group > .shopping-compact-submit, +.endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-list .share-submit-btn, +.endpoint-list_share .share-submit-btn, +.endpoint-shared_list .share-submit-btn { + min-width: 7.25rem; +} + +.shopping-btn-icon { + line-height: 1; +} + +.shopping-btn-label { + line-height: 1; +} + +@media (max-width: 767.98px) { + .endpoint-list .shopping-product-input-group, + .endpoint-list_share .shopping-product-input-group, + .endpoint-shared_list .shopping-product-input-group, + .endpoint-list .shopping-expense-input-group, + .endpoint-list_share .shopping-expense-input-group, + .endpoint-shared_list .shopping-expense-input-group { + width: 100%; + } + + .endpoint-list .shopping-product-input-group > .shopping-product-name-input, + .endpoint-list_share .shopping-product-input-group > .shopping-product-name-input, + .endpoint-shared_list .shopping-product-input-group > .shopping-product-name-input { + flex: 0 0 60% !important; + max-width: 60% !important; + min-width: 0; + } + + .endpoint-list .shopping-product-input-group > .shopping-qty-input, + .endpoint-list_share .shopping-product-input-group > .shopping-qty-input, + .endpoint-shared_list .shopping-product-input-group > .shopping-qty-input { + flex: 0 0 15% !important; + max-width: 15% !important; + min-width: 0; + padding-left: .35rem; + padding-right: .35rem; + } + + .endpoint-list .shopping-product-input-group > .shopping-compact-submit, + .endpoint-list_share .shopping-product-input-group > .shopping-compact-submit, + .endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit { + flex: 0 0 25% !important; + width: 25% !important; + min-width: 0 !important; + padding-left: .4rem; + padding-right: .4rem; + } + + .endpoint-list .shopping-expense-input-group > .shopping-expense-amount-input, + .endpoint-list_share .shopping-expense-input-group > .shopping-expense-amount-input, + .endpoint-shared_list .shopping-expense-input-group > .shopping-expense-amount-input { + flex: 1 1 auto !important; + min-width: 0; + } + + .endpoint-list .shopping-expense-input-group > .shopping-compact-submit, + .endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit, + .endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit { + flex: 0 0 5rem !important; + width: 5rem !important; + min-width: 5rem !important; + padding-left: .35rem; + padding-right: .35rem; + } + + .endpoint-list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-label, + .endpoint-list_share .shopping-product-input-group > .shopping-compact-submit .shopping-btn-label, + .endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-label, + .endpoint-list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-label, + .endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-label, + .endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-label { + display: none; + } + + .endpoint-list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-icon, + .endpoint-list_share .shopping-product-input-group > .shopping-compact-submit .shopping-btn-icon, + .endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-icon, + .endpoint-list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-icon, + .endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-icon, + .endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-icon { + margin: 0; + font-size: 1rem; + } +} + + +/* endpoint fix for /list route */ +.endpoint-view_list .shopping-product-input-group, +.endpoint-view_list .shopping-expense-input-group { + display: flex; + flex-wrap: nowrap !important; + align-items: stretch; + gap: 0 !important; + width: 100%; +} + +.endpoint-view_list .shopping-product-input-group > .form-control, +.endpoint-view_list .shopping-expense-input-group > .form-control, +.endpoint-view_list .shopping-product-input-group > .btn, +.endpoint-view_list .shopping-expense-input-group > .btn { + position: relative; + min-height: 46px; + box-shadow: none; +} + +.endpoint-view_list .shopping-product-input-group > .shopping-product-name-input, +.endpoint-view_list .shopping-expense-input-group > .shopping-expense-amount-input { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.endpoint-view_list .shopping-product-input-group > .shopping-qty-input { + border-radius: 0 !important; + border-left-width: 0 !important; +} + +.endpoint-view_list .shopping-product-input-group > .shopping-compact-submit, +.endpoint-view_list .shopping-expense-input-group > .shopping-compact-submit, +.endpoint-view_list .share-submit-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: .35rem; + margin-left: 0 !important; + min-width: 7.25rem; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-top-right-radius: .9rem !important; + border-bottom-right-radius: .9rem !important; + border-left-width: 0 !important; +} + +@media (max-width: 767.98px) { + .endpoint-view_list .shopping-product-input-group > .shopping-product-name-input { + flex: 0 0 60% !important; + max-width: 60% !important; + min-width: 0; + } + + .endpoint-view_list .shopping-product-input-group > .shopping-qty-input { + flex: 0 0 15% !important; + max-width: 15% !important; + min-width: 0; + padding-left: .35rem; + padding-right: .35rem; + } + + .endpoint-view_list .shopping-product-input-group > .shopping-compact-submit { + flex: 0 0 25% !important; + width: 25% !important; + min-width: 0 !important; + padding-left: .4rem; + padding-right: .4rem; + } + + .endpoint-view_list .shopping-expense-input-group > .shopping-expense-amount-input { + flex: 1 1 auto !important; + min-width: 0; + } + + .endpoint-view_list .shopping-expense-input-group > .shopping-compact-submit { + flex: 0 0 5rem !important; + width: 5rem !important; + min-width: 5rem !important; + padding-left: .35rem; + padding-right: .35rem; + } + + .endpoint-view_list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-label, + .endpoint-view_list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-label { + display: none; + } + + .endpoint-view_list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-icon, + .endpoint-view_list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-icon { + margin: 0; + font-size: 1rem; + } +} + +/* --- JS render + progress bar consistency patch --- */ +#progress-bar-purchased { + background: linear-gradient(135deg, rgba(39,208,125,0.96), rgba(22,150,91,0.98)) !important; +} + +#progress-bar-not-purchased { + background: linear-gradient(135deg, rgba(246,196,83,0.96), rgba(224,164,26,0.98)) !important; +} + +#progress-bar-remaining { + background: rgba(255,255,255,0.08) !important; +} + + +/* v10.2 item row consistency and mobile share fixes */ +.shopping-item-text { + line-height: 1.35; +} + +.shopping-item-text .info-line { + display: inline; + flex-basis: auto; + margin-top: 0; + white-space: normal; +} + +.shopping-item-text .info-line > span { + display: inline; +} + +.shopping-item-text .item-added-by-meta { + color: currentColor; + opacity: .72; + font-size: .92em; +} + +.shopping-item-text .item-added-by-meta b { + font-weight: 600; +} + +.shopping-item-name, +.shopping-item-text .info-line { + overflow-wrap: break-word; + word-break: normal; +} + +.shopping-item-actions { + flex: 0 0 auto; +} + +@media (max-width: 575.98px) { + .shopping-item-head { + flex-wrap: wrap; + align-items: flex-start; + } + + .shopping-item-text { + flex: 1 1 100%; + min-width: 0; + } + + .shopping-item-actions { + width: 100%; + margin-left: 0; + justify-content: flex-end; + } +} + + +/* v10.3 alignment fixes for list/share */ +.hide-purchased-switch--right { + display: inline-flex !important; + align-items: center; + justify-content: flex-end; + gap: .6rem; + width: auto; + margin-left: auto; + padding: 0 !important; + border: 0 !important; + background: transparent !important; + box-shadow: none !important; +} + +.hide-purchased-switch--right::before, +.hide-purchased-switch--right::after { + display: none !important; +} + +.hide-purchased-switch--right .form-check-input { + order: 1; + margin: 0; + float: none; + flex: 0 0 auto; +} + +.hide-purchased-switch--right .form-check-label { + order: 0; + margin: 0 !important; + white-space: nowrap; +} + +.list-action-row { + display: flex; + flex-wrap: nowrap; + align-items: stretch; + gap: .65rem; +} + +.list-action-row__form { + display: flex; + flex: 1 1 50%; +} + +.list-action-row__btn { + width: 100%; +} + +.list-action-row > .list-action-row__btn { + flex: 1 1 50%; +} + +.shopping-item-head { + flex-wrap: nowrap; + align-items: center; +} + +.shopping-item-text { + flex: 1 1 auto; + min-width: 0; +} + +.shopping-item-text .info-line { + flex-basis: auto; +} + +.shopping-item-actions { + align-items: center; + justify-content: flex-end; + flex: 0 0 auto; + white-space: nowrap; +} + +.shopping-item-actions .btn { + flex: 0 0 auto; +} + +@media (max-width: 575.98px) { + .shopping-item-main { + align-items: center; + } + + .shopping-item-head { + flex-wrap: nowrap; + align-items: center; + gap: .4rem; + } + + .shopping-item-text { + flex: 1 1 auto; + min-width: 0; + gap: .25rem; + } + + .shopping-item-actions { + width: auto; + margin-left: auto; + gap: .25rem; + } + + .shopping-item-actions .btn { + min-width: 2rem; + padding: .3rem .42rem; + } + + .hide-purchased-switch--right { + width: auto; + max-width: 100%; + } + + .list-action-row { + gap: .5rem; + } + + .list-action-row > .list-action-row__btn, + .list-action-row__form { + flex: 1 1 50%; + min-width: 0; + } + + .list-action-row__btn { + padding-left: .55rem; + padding-right: .55rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + + +/* layout polish 2026-03-15: toolbar, sorting and item alignment */ +.list-header-toolbar { + width: 100%; +} + +.list-header-toolbar > h2 { + flex: 1 1 auto; + min-width: 0; +} + +.list-header-toolbar .list-toolbar { + flex: 0 0 auto; + width: auto; + margin-left: auto; +} + +.list-toolbar__sort.btn { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + border-width: 1px !important; +} + +.drag-handle { + display: none !important; + cursor: grab; +} + +body.sorting-active .drag-handle { + display: inline-flex !important; +} + +body.sorting-active .shopping-item-row { + cursor: grab; +} + +body.sorting-active .shopping-item-row:active, +body.sorting-active .drag-handle:active { + cursor: grabbing; +} + +body.sorting-active .shopping-item-row .large-checkbox { + pointer-events: none; +} + +.endpoint-list_share .shopping-item-name, +.endpoint-list_share .shopping-item-text .info-line, +.endpoint-shared_list .shopping-item-name, +.endpoint-shared_list .shopping-item-text .info-line { + overflow-wrap: break-word; + word-break: normal; +} + +.endpoint-list_share input[type="checkbox"].large-checkbox::before, +.endpoint-shared_list input[type="checkbox"].large-checkbox::before, +.endpoint-view_list input[type="checkbox"].large-checkbox::before { + font-size: 1.7em; +} + +@media (max-width: 575.98px) { + .endpoint-view_list .list-toolbar { + display: grid !important; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center !important; + gap: .4rem !important; + flex-wrap: nowrap !important; + } + + .endpoint-view_list .list-toolbar__sort.btn { + min-width: 0; + padding: .35rem .55rem; + font-size: .82rem; + } + + .endpoint-view_list .hide-purchased-switch--right { + min-width: 0; + gap: .25rem; + font-size: .82rem; + } + + .endpoint-view_list .hide-purchased-switch--right .form-check-label { + margin-left: .25rem !important; + white-space: nowrap; + } + + .endpoint-view_list .hide-purchased-switch--right .form-check-input { + transform: scale(.92); + transform-origin: center; + } + + .list-header-toolbar { + align-items: flex-start !important; + } + + .list-header-toolbar .list-toolbar { + width: 100%; + justify-content: flex-end !important; + } +} + +/* hotfix 2026-03-15 v3: /share item layout parity with /list */ +.endpoint-list_share .shopping-item-row, +.endpoint-shared_list .shopping-item-row { + overflow: hidden; +} + +.endpoint-list_share .shopping-item-main, +.endpoint-shared_list .shopping-item-main { + align-items: center; +} + +.endpoint-list_share .shopping-item-content, +.endpoint-shared_list .shopping-item-content { + flex: 1 1 auto; + min-width: 0; + width: 100%; +} + +.endpoint-list_share .shopping-item-head, +.endpoint-shared_list .shopping-item-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + column-gap: .65rem; + row-gap: .35rem; +} + +.endpoint-list_share .shopping-item-text, +.endpoint-shared_list .shopping-item-text { + display: flex; + flex-wrap: wrap; + align-items: center; + align-content: center; + min-width: 0; + width: 100%; + gap: .35rem; +} + +.endpoint-list_share .shopping-item-name, +.endpoint-shared_list .shopping-item-name { + display: inline; + min-width: 0; + max-width: 100%; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.endpoint-list_share .shopping-item-text .info-line, +.endpoint-shared_list .shopping-item-text .info-line { + display: block; + flex: 0 0 100%; + width: 100%; + margin-top: .15rem; + white-space: normal; + overflow-wrap: anywhere; + word-break: break-word; +} + +.endpoint-list_share .shopping-item-actions, +.endpoint-shared_list .shopping-item-actions { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + align-self: center; + justify-content: flex-end; + margin-left: 0; + white-space: nowrap; +} + +.endpoint-list_share .shopping-item-actions .btn, +.endpoint-shared_list .shopping-item-actions .btn { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.endpoint-list_share .shopping-item-main > .large-checkbox, +.endpoint-shared_list .shopping-item-main > .large-checkbox, +.endpoint-view_list .shopping-item-main > .large-checkbox { + align-self: center; +} + +@media (max-width: 575.98px) { + .endpoint-list_share .shopping-item-head, + .endpoint-shared_list .shopping-item-head { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + } + + .endpoint-list_share .shopping-item-actions, + .endpoint-shared_list .shopping-item-actions { + align-self: start; + gap: .3rem; + } + + .endpoint-list_share .shopping-item-actions .btn, + .endpoint-shared_list .shopping-item-actions .btn { + min-width: 2.15rem; + padding: .32rem .45rem; + } +} + +/* hotfix 2026-03-15 v4: /share parity with /list */ +.endpoint-list_share .shopping-item-row, +.endpoint-shared_list .shopping-item-row { + padding: .8rem .95rem; +} + +.endpoint-list_share .shopping-item-main, +.endpoint-shared_list .shopping-item-main { + display: flex; + align-items: center; + gap: .75rem; + width: 100%; +} + +.endpoint-list_share .shopping-item-content, +.endpoint-shared_list .shopping-item-content { + flex: 1 1 auto; + min-width: 0; + width: auto; +} + +.endpoint-list_share .shopping-item-head, +.endpoint-shared_list .shopping-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; +} + +.endpoint-list_share .shopping-item-text, +.endpoint-shared_list .shopping-item-text { + flex: 1 1 auto; + min-width: 0; + display: flex; + align-items: center; + align-content: center; + gap: .35rem; + flex-wrap: wrap; +} + +.endpoint-list_share .shopping-item-name, +.endpoint-shared_list .shopping-item-name { + display: inline; + min-width: 0; + max-width: 100%; + white-space: normal; + overflow-wrap: break-word; + word-break: normal; +} + +.endpoint-list_share .shopping-item-text .badge, +.endpoint-shared_list .shopping-item-text .badge, +.endpoint-list_share .shopping-item-text .info-line, +.endpoint-shared_list .shopping-item-text .info-line { + align-self: center; +} + +.endpoint-list_share .shopping-item-text .info-line, +.endpoint-shared_list .shopping-item-text .info-line { + display: block; + flex: 0 0 100%; + width: 100%; + margin-top: .1rem; + white-space: normal; + overflow-wrap: break-word; + word-break: normal; +} + +.endpoint-list_share .shopping-item-actions, +.endpoint-shared_list .shopping-item-actions { + display: inline-flex; + flex: 0 0 auto; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: .35rem; + white-space: nowrap; +} + +.endpoint-list_share .shopping-item-actions .btn, +.endpoint-shared_list .shopping-item-actions .btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.25rem; + padding: .34rem .48rem; +} + +.endpoint-list_share .shopping-item-main > .large-checkbox, +.endpoint-shared_list .shopping-item-main > .large-checkbox { + flex: 0 0 auto; + align-self: center; +} + +.endpoint-list_share input[type="checkbox"].large-checkbox::before, +.endpoint-shared_list input[type="checkbox"].large-checkbox::before { + font-size: 1.75em; +} + +@media (max-width: 575.98px) { + .endpoint-list_share .shopping-item-main, + .endpoint-shared_list .shopping-item-main { + gap: .55rem; + } + + .endpoint-list_share .shopping-item-head, + .endpoint-shared_list .shopping-item-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: .45rem; + } + + .endpoint-list_share .shopping-item-text, + .endpoint-shared_list .shopping-item-text { + flex: 1 1 auto; + min-width: 0; + gap: .25rem; + } + + .endpoint-list_share .shopping-item-actions, + .endpoint-shared_list .shopping-item-actions { + width: auto; + margin-left: auto; + gap: .25rem; + } + + .endpoint-list_share .shopping-item-actions .btn, + .endpoint-shared_list .shopping-item-actions .btn { + min-width: 2rem; + padding: .3rem .42rem; + } +} + +/* mobile menu simplification 2026-03-15 */ +.app-mobile-menu { + display: flex; + align-items: center; +} + +.app-mobile-menu__toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 3rem; + height: 3rem; + border-radius: 14px; + background: rgba(255,255,255,0.04); +} + +.app-mobile-menu__panel { + min-width: 14rem; + padding: .4rem; + border-radius: 16px; + background: linear-gradient(180deg, rgba(14,22,35,0.98), rgba(9,15,26,0.98)) !important; + border: 1px solid rgba(255,255,255,0.1); + box-shadow: 0 18px 36px rgba(0,0,0,0.28); +} + +.app-mobile-menu__item { + display: flex; + align-items: center; + gap: .55rem; + min-height: 2.6rem; + padding: .55rem .75rem; + color: #fff; + border-radius: 12px; +} + +.app-mobile-menu__item:hover, +.app-mobile-menu__item:focus { + background: rgba(255,255,255,0.08); + color: #fff; +} + +@media (min-width: 992px) { + .app-mobile-menu { + display: none !important; + } +} + + +/* v5.1 create-list temp toggle mobile/Desktop fix */ +.create-list-input-group { + display: flex; + flex-wrap: nowrap !important; + align-items: stretch; + gap: 0 !important; +} + +.create-list-input-group > .form-control { + flex: 1 1 auto !important; + width: 1% !important; + min-width: 0 !important; + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.create-list-input-group > .create-list-temp-toggle, +.create-list-input-group > #tempToggle { + flex: 0 0 auto !important; + width: auto !important; + min-width: 10rem; + margin-left: -1px; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-top-right-radius: 14px !important; + border-bottom-right-radius: 14px !important; + background: rgba(255,255,255,0.03) !important; + border-color: var(--app-border) !important; + color: var(--app-text) !important; + transition: background-color .18s ease, border-color .18s ease, color .18s ease, box-shadow .18s ease; +} + +.create-list-input-group > .create-list-temp-toggle.is-active, +.create-list-input-group > #tempToggle.is-active { + background: rgba(41, 209, 125, 0.16) !important; + border-color: rgba(41, 209, 125, 0.72) !important; + color: #9bf0c1 !important; + box-shadow: inset 0 0 0 1px rgba(41, 209, 125, 0.15); +} + +.create-list-input-group > .create-list-temp-toggle:hover, +.create-list-input-group > #tempToggle:hover, +.create-list-input-group > .create-list-temp-toggle:focus, +.create-list-input-group > #tempToggle:focus { + background: rgba(255,255,255,0.06) !important; + color: var(--app-text) !important; +} + +.create-list-input-group > .create-list-temp-toggle.is-active:hover, +.create-list-input-group > #tempToggle.is-active:hover, +.create-list-input-group > .create-list-temp-toggle.is-active:focus, +.create-list-input-group > #tempToggle.is-active:focus { + background: rgba(41, 209, 125, 0.2) !important; + color: #b7f7d2 !important; +} + +@media (max-width: 767.98px) { + .create-list-input-group { + gap: 0 !important; + } + + .create-list-input-group > .form-control { + padding-left: .9rem; + padding-right: .75rem; + } + + .create-list-input-group > .create-list-temp-toggle, + .create-list-input-group > #tempToggle { + min-width: 8.75rem; + padding-left: .85rem; + padding-right: .85rem; + font-size: .92rem; + letter-spacing: 0; + } +} + + +/* Login/auth password field fixes */ +.endpoint-login form .form-control, +.endpoint-system_auth form .form-control { + min-height: 42px; + border-radius: 14px !important; +} + +.endpoint-login .ui-password-group, +.endpoint-system_auth .ui-password-group { + display: flex !important; + flex-wrap: nowrap !important; + align-items: stretch !important; + gap: 0 !important; +} + +.endpoint-login .ui-password-group > .form-control, +.endpoint-system_auth .ui-password-group > .form-control { + width: auto !important; + flex: 1 1 auto !important; + max-width: none !important; + border-radius: 14px 0 0 14px !important; + border-right: 0 !important; +} + +.endpoint-login .ui-password-group > .ui-password-toggle, +.endpoint-system_auth .ui-password-group > .ui-password-toggle { + appearance: none; + -webkit-appearance: none; + display: inline-flex !important; + align-items: center; + justify-content: center; + flex: 0 0 46px !important; + width: 46px !important; + min-width: 46px !important; + padding: 0 !important; + margin: 0 !important; + color: rgba(255,255,255,.78); + background: #1f2738 !important; + border: 1px solid var(--bs-border-color, #495057) !important; + border-left: 0 !important; + border-radius: 0 14px 14px 0 !important; + outline: none !important; + box-shadow: none !important; + line-height: 1; + font-size: 1rem; +} + +.endpoint-login .ui-password-group > .ui-password-toggle:hover, +.endpoint-login .ui-password-group > .ui-password-toggle:focus, +.endpoint-system_auth .ui-password-group > .ui-password-toggle:hover, +.endpoint-system_auth .ui-password-group > .ui-password-toggle:focus { + color: #fff; + background: #253047 !important; + outline: none !important; + box-shadow: none !important; +} + +.endpoint-login .ui-password-group > .ui-password-toggle.is-active, +.endpoint-system_auth .ui-password-group > .ui-password-toggle.is-active { + background: #2a3550 !important; +} + +@media (max-width: 575.98px) { + .endpoint-login .ui-password-group, + .endpoint-system_auth .ui-password-group { + width: 100%; + } + + .endpoint-login .ui-password-group > .form-control, + .endpoint-system_auth .ui-password-group > .form-control { + width: auto !important; + flex: 1 1 auto !important; + } + + .endpoint-login .ui-password-group > .ui-password-toggle, + .endpoint-system_auth .ui-password-group > .ui-password-toggle { + flex: 0 0 44px !important; + width: 44px !important; + min-width: 44px !important; + } +} + +/* final hotfix 2026-03-17: list/share parity, pending spinner, auth inputs */ +.shopping-item-row { + position: relative; +} + +.shopping-item-spinner { + position: absolute; + top: .7rem; + right: .7rem; + z-index: 2; + pointer-events: none; +} + +.shopping-item-row.is-pending .shopping-item-actions { + opacity: .72; +} + +.shopping-item-actions { + display: inline-flex; + flex: 0 0 auto; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: .35rem; + min-height: 2.35rem; +} + +.shopping-action-btn { + display: inline-flex !important; + align-items: center; + justify-content: center; + width: 2.35rem; + height: 2.35rem; + min-width: 2.35rem; + padding: 0 !important; + line-height: 1; + border-radius: .7rem !important; + flex: 0 0 2.35rem; +} + +.shopping-action-btn--wide { + width: auto; + min-width: 5.9rem; + padding: 0 .8rem !important; + flex: 0 0 auto; +} +.shopping-action-btn--countdown { + width: auto !important; + min-width: 3.2rem !important; + padding: 0 .65rem !important; + font-variant-numeric: tabular-nums; + opacity: 1 !important; +} + + +.endpoint-list_share .shopping-item-actions, +.endpoint-shared_list .shopping-item-actions, +.endpoint-list .shopping-item-actions { + min-height: 2.35rem; +} + +.endpoint-list_share .shopping-action-btn, +.endpoint-shared_list .shopping-action-btn, +.endpoint-list .shopping-action-btn { + width: 2.35rem; + height: 2.35rem; + min-width: 2.35rem; + border-radius: .7rem !important; +} + +.endpoint-list_share .shopping-action-btn--wide, +.endpoint-shared_list .shopping-action-btn--wide, +.endpoint-list .shopping-action-btn--wide { + width: auto; + min-width: 5.9rem; +} +.endpoint-list_share .shopping-action-btn--countdown, +.endpoint-shared_list .shopping-action-btn--countdown, +.endpoint-list .shopping-action-btn--countdown { + width: auto; + min-width: 3.2rem; +} + + +@media (max-width: 575.98px) { + .shopping-item-spinner { + top: .55rem; + right: .55rem; + } + + .shopping-action-btn, + .endpoint-list_share .shopping-action-btn, + .endpoint-shared_list .shopping-action-btn, + .endpoint-list .shopping-action-btn { + width: 2.15rem; + height: 2.15rem; + min-width: 2.15rem; + border-radius: .65rem !important; + } + + .shopping-action-btn--wide, + .endpoint-list_share .shopping-action-btn--wide, + .endpoint-shared_list .shopping-action-btn--wide, + .endpoint-list .shopping-action-btn--wide { + width: auto; + min-width: 5.4rem; + padding: 0 .72rem !important; + } +} + .shopping-action-btn--countdown, + .endpoint-list_share .shopping-action-btn--countdown, + .endpoint-shared_list .shopping-action-btn--countdown, + .endpoint-list .shopping-action-btn--countdown { + min-width: 3rem; + padding: 0 .55rem !important; + } +} + +.endpoint-login .card .form-control, +.endpoint-system_auth .card .form-control, +.endpoint-user_management .ui-password-group > .form-control, +.endpoint-user_management .modal .ui-password-group > .form-control { + min-height: 42px; + border-radius: 14px !important; +} + +.endpoint-user_management .ui-password-group, +.endpoint-user_management .modal .ui-password-group { + display: flex !important; + flex-wrap: nowrap !important; + align-items: stretch !important; + gap: 0 !important; +} + +.endpoint-user_management .ui-password-group > .form-control, +.endpoint-user_management .modal .ui-password-group > .form-control { + flex: 1 1 auto !important; + width: auto !important; + max-width: none !important; + border-radius: 14px 0 0 14px !important; + border-right: 0 !important; +} + +.endpoint-user_management .ui-password-group > .ui-password-toggle, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle { + appearance: none; + -webkit-appearance: none; + display: inline-flex !important; + align-items: center; + justify-content: center; + flex: 0 0 46px !important; + width: 46px !important; + min-width: 46px !important; + padding: 0 !important; + margin: 0 !important; + color: rgba(255,255,255,.78); + background: #1f2738 !important; + border: 1px solid var(--bs-border-color, #495057) !important; + border-left: 0 !important; + border-radius: 0 14px 14px 0 !important; + outline: none !important; + box-shadow: none !important; + line-height: 1; + font-size: 1rem; +} + +.endpoint-user_management .ui-password-group > .ui-password-toggle:hover, +.endpoint-user_management .ui-password-group > .ui-password-toggle:focus, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle:hover, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle:focus { + color: #fff; + background: #253047 !important; + box-shadow: none !important; +} + + +/* v14 fixes: share/list action parity + sort handle visibility */ +.endpoint-list_share .shopping-item-actions, +.endpoint-shared_list .shopping-item-actions, +.endpoint-view_list .shopping-item-actions, +.endpoint-list .shopping-item-actions { + gap: .35rem !important; + min-height: 2.35rem !important; +} + +.endpoint-list_share .shopping-action-btn, +.endpoint-shared_list .shopping-action-btn, +.endpoint-view_list .shopping-action-btn, +.endpoint-list .shopping-action-btn { + width: 2.35rem !important; + height: 2.35rem !important; + min-width: 2.35rem !important; + min-height: 2.35rem !important; + padding: 0 !important; + border-radius: .7rem !important; + font-size: 1rem !important; + line-height: 1 !important; +} + +.endpoint-list_share .shopping-action-btn--wide, +.endpoint-shared_list .shopping-action-btn--wide, +.endpoint-view_list .shopping-action-btn--wide, +.endpoint-list .shopping-action-btn--wide { + width: auto !important; + min-width: 5.9rem !important; + padding: 0 .8rem !important; +} +.endpoint-list_share .shopping-action-btn--countdown, +.endpoint-shared_list .shopping-action-btn--countdown, +.endpoint-view_list .shopping-action-btn--countdown, +.endpoint-list .shopping-action-btn--countdown { + width: auto !important; + min-width: 3.2rem !important; + padding: 0 .65rem !important; +} + + +.endpoint-list_share .shopping-action-btn > *, +.endpoint-shared_list .shopping-action-btn > *, +.endpoint-view_list .shopping-action-btn > *, +.endpoint-list .shopping-action-btn > * { + line-height: 1 !important; +} + +@media (max-width: 575.98px) { + .endpoint-list_share .shopping-action-btn, + .endpoint-shared_list .shopping-action-btn, + .endpoint-view_list .shopping-action-btn, + .endpoint-list .shopping-action-btn { + width: 2.15rem !important; + height: 2.15rem !important; + min-width: 2.15rem !important; + min-height: 2.15rem !important; + border-radius: .65rem !important; + } + + .endpoint-list_share .shopping-action-btn--wide, + .endpoint-shared_list .shopping-action-btn--wide, + .endpoint-view_list .shopping-action-btn--wide, + .endpoint-list .shopping-action-btn--wide { + min-width: 5.4rem !important; + padding: 0 .72rem !important; + } +} + .endpoint-list_share .shopping-action-btn--countdown, + .endpoint-shared_list .shopping-action-btn--countdown, + .endpoint-view_list .shopping-action-btn--countdown, + .endpoint-list .shopping-action-btn--countdown { + min-width: 3rem !important; + padding: 0 .55rem !important; + } +} + +body:not(.sorting-active) .drag-handle { + display: none !important; +} + + +/* final hotfix 2026-03-17: consistent password toggle on auth/admin */ +.ui-password-group { + display: flex !important; + flex-wrap: nowrap !important; + align-items: stretch !important; + gap: 0 !important; + width: 100%; +} + +.ui-password-group > .form-control { + flex: 1 1 auto !important; + width: 1% !important; + min-width: 0 !important; + max-width: none !important; + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; + border-right: 0 !important; +} + +.ui-password-group > .ui-password-toggle { + appearance: none; + -webkit-appearance: none; + display: inline-flex !important; + align-items: center; + justify-content: center; + flex: 0 0 46px !important; + width: 46px !important; + min-width: 46px !important; + padding: 0 !important; + margin: 0 !important; + background: var(--dark-700) !important; + color: var(--text-strong) !important; + border: 1px solid var(--dark-300) !important; + border-left: 0 !important; + border-top-right-radius: 14px !important; + border-bottom-right-radius: 14px !important; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + box-shadow: none !important; + outline: none !important; + line-height: 1; + transition: background-color .18s ease, border-color .18s ease, color .18s ease, box-shadow .18s ease; +} + +.ui-password-group > .ui-password-toggle:hover, +.ui-password-group > .ui-password-toggle:focus, +.ui-password-group > .ui-password-toggle:focus-visible { + background: var(--dark-800) !important; + color: #fff !important; + border-color: var(--primary) !important; + box-shadow: 0 0 0 .25rem rgba(24, 64, 118, .18) !important; +} + +.ui-password-group > .ui-password-toggle.is-active { + background: #2a3550 !important; + color: #fff !important; +} + +@media (max-width: 575.98px) { + .ui-password-group > .ui-password-toggle { + flex-basis: 44px !important; + width: 44px !important; + min-width: 44px !important; + } +} + + +/* final hotfix 2026-03-17b: password toggle parity on login/system-auth/admin-users */ +.ui-password-group { + display: flex !important; + flex-wrap: nowrap !important; + align-items: stretch !important; + width: 100% !important; +} + +.ui-password-group > .form-control { + flex: 1 1 auto !important; + width: 1% !important; + min-width: 0 !important; + min-height: 42px !important; + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; + border-right: 0 !important; +} + +.ui-password-group > .ui-password-toggle { + appearance: none !important; + -webkit-appearance: none !important; + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + flex: 0 0 46px !important; + width: 46px !important; + min-width: 46px !important; + min-height: 42px !important; + padding: 0 !important; + margin: 0 !important; + cursor: pointer !important; + background-color: var(--dark-700) !important; + background-image: none !important; + color: var(--text-strong) !important; + border: 1px solid var(--dark-300) !important; + border-left: 0 !important; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; + border-top-right-radius: 14px !important; + border-bottom-right-radius: 14px !important; + box-shadow: none !important; + line-height: 1 !important; +} + +.ui-password-group > .ui-password-toggle:hover, +.ui-password-group > .ui-password-toggle:focus, +.ui-password-group > .ui-password-toggle:focus-visible { + background-color: var(--dark-800) !important; + color: #fff !important; + border-color: var(--primary) !important; + box-shadow: 0 0 0 .25rem rgba(24, 64, 118, .18) !important; + outline: none !important; +} + +.ui-password-group > .ui-password-toggle.is-active { + background-color: var(--dark-800) !important; + color: #fff !important; +} + +.ui-password-group > .ui-password-toggle > * { + pointer-events: none !important; +} + +.endpoint-login .ui-password-group > .ui-password-toggle, +.endpoint-system_auth .ui-password-group > .ui-password-toggle, +.endpoint-user_management .ui-password-group > .ui-password-toggle, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle { + background-color: var(--dark-700) !important; + color: var(--text-strong) !important; + border-color: var(--dark-300) !important; +} + +.endpoint-login .ui-password-group > .ui-password-toggle:hover, +.endpoint-login .ui-password-group > .ui-password-toggle:focus, +.endpoint-login .ui-password-group > .ui-password-toggle:focus-visible, +.endpoint-system_auth .ui-password-group > .ui-password-toggle:hover, +.endpoint-system_auth .ui-password-group > .ui-password-toggle:focus, +.endpoint-system_auth .ui-password-group > .ui-password-toggle:focus-visible, +.endpoint-user_management .ui-password-group > .ui-password-toggle:hover, +.endpoint-user_management .ui-password-group > .ui-password-toggle:focus, +.endpoint-user_management .ui-password-group > .ui-password-toggle:focus-visible, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle:hover, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle:focus, +.endpoint-user_management .modal .ui-password-group > .ui-password-toggle:focus-visible { + background-color: var(--dark-800) !important; + border-color: var(--primary) !important; +} + +@media (max-width: 575.98px) { + .ui-password-group > .ui-password-toggle { + flex-basis: 44px !important; + width: 44px !important; + min-width: 44px !important; + } +} + + +/* wyróżnienie pola dodawania produktu */ +.endpoint-list .shopping-entry-card, +.endpoint-list_share .shopping-entry-card, +.endpoint-shared_list .shopping-entry-card, +.endpoint-view_list .shopping-entry-card { + background: linear-gradient(180deg, rgba(25, 135, 84, 0.16), rgba(13, 17, 23, 0.92)); + border: 1px solid rgba(25, 135, 84, 0.42); + border-radius: 1rem; + padding: .9rem; + box-shadow: 0 .5rem 1.2rem rgba(0, 0, 0, 0.18); +} + +.endpoint-list .shopping-entry-card__label, +.endpoint-list_share .shopping-entry-card__label, +.endpoint-shared_list .shopping-entry-card__label, +.endpoint-view_list .shopping-entry-card__label { + display: inline-flex; + align-items: center; + gap: .4rem; + margin-bottom: .2rem; + font-size: .95rem; + font-weight: 700; + color: #d1f7df; +} + +.endpoint-list .shopping-entry-card__hint, +.endpoint-list_share .shopping-entry-card__hint, +.endpoint-shared_list .shopping-entry-card__hint, +.endpoint-view_list .shopping-entry-card__hint { + margin-bottom: .75rem; + color: rgba(255, 255, 255, 0.72); + font-size: .82rem; + line-height: 1.35; +} + +.endpoint-list .shopping-entry-card .shopping-product-input-group, +.endpoint-list_share .shopping-entry-card .shopping-product-input-group, +.endpoint-shared_list .shopping-entry-card .shopping-product-input-group, +.endpoint-view_list .shopping-entry-card .shopping-product-input-group { + margin-bottom: 0 !important; +} + +.endpoint-list .shopping-entry-card .shopping-product-input-group > .form-control, +.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .form-control, +.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .form-control, +.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .form-control { + border-color: rgba(25, 135, 84, 0.55) !important; + background: rgba(17, 24, 39, 0.95) !important; +} + +.endpoint-list .shopping-entry-card .shopping-product-input-group > .form-control::placeholder, +.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .form-control::placeholder, +.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .form-control::placeholder, +.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .form-control::placeholder { + color: rgba(255, 255, 255, 0.62); +} + +.endpoint-list .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus, +.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus, +.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus, +.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus { + box-shadow: inset 0 0 0 1px rgba(25, 135, 84, 0.25), 0 0 0 .2rem rgba(25, 135, 84, 0.18); +} + +@media (max-width: 767.98px) { + .endpoint-list .shopping-entry-card, + .endpoint-list_share .shopping-entry-card, + .endpoint-shared_list .shopping-entry-card, + .endpoint-view_list .shopping-entry-card { + padding: .8rem; + border-radius: .95rem; + } + + .endpoint-list .shopping-entry-card__label, + .endpoint-list_share .shopping-entry-card__label, + .endpoint-shared_list .shopping-entry-card__label, + .endpoint-view_list .shopping-entry-card__label { + font-size: .92rem; + } + + .endpoint-list .shopping-entry-card__hint, + .endpoint-list_share .shopping-entry-card__hint, + .endpoint-shared_list .shopping-entry-card__hint, + .endpoint-view_list .shopping-entry-card__hint { + font-size: .78rem; + margin-bottom: .65rem; + } +} + +/* ========== /expenses mobile fixes: separate range pickers + better wrapping ========== */ +.endpoint-expenses .expenses-range-toolbar { + width: 100%; +} + +.endpoint-expenses .expenses-range-group { + flex-wrap: wrap; + justify-content: center; + width: 100%; +} + +.endpoint-expenses .expenses-range-group > .btn { + white-space: nowrap; +} + +.endpoint-expenses .expenses-date-range { + align-items: stretch; +} + +@media (max-width: 767.98px) { + .endpoint-expenses .expenses-range-toolbar { + justify-content: stretch !important; + overflow: visible; + padding-bottom: 0; + } + + .endpoint-expenses .expenses-range-group { + display: grid !important; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.55rem; + width: 100%; + min-width: 0; + } + + .endpoint-expenses .expenses-table-toolbar .expenses-range-group { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .endpoint-expenses .expenses-range-group > .btn { + flex: initial !important; + width: 100%; + min-width: 0; + padding-inline: 0.55rem; + font-size: 0.95rem; + } + + .endpoint-expenses .expenses-date-range { + display: grid !important; + grid-template-columns: 52px minmax(0, 1fr); + gap: 0.55rem; + width: 100%; + max-width: 100% !important; + flex-wrap: wrap !important; + } + + .endpoint-expenses .expenses-date-range > .input-group-text, + .endpoint-expenses .expenses-date-range > .form-control, + .endpoint-expenses .expenses-date-range > .btn { + width: 100% !important; + min-width: 0 !important; + flex: initial !important; + border-radius: 0.85rem !important; + } + + .endpoint-expenses .expenses-date-range > .btn { + grid-column: 1 / -1; + } +} + + +/* /share expense entry card aligned with product card */ +.endpoint-list .shopping-entry-card--expense, +.endpoint-list_share .shopping-entry-card--expense, +.endpoint-shared_list .shopping-entry-card--expense, +.endpoint-view_list .shopping-entry-card--expense { + background: linear-gradient(180deg, rgba(13, 110, 253, 0.16), rgba(13, 17, 23, 0.92)); + border-color: rgba(13, 110, 253, 0.42); +} + +.endpoint-list .shopping-entry-card--expense .shopping-entry-card__label, +.endpoint-list_share .shopping-entry-card--expense .shopping-entry-card__label, +.endpoint-shared_list .shopping-entry-card--expense .shopping-entry-card__label, +.endpoint-view_list .shopping-entry-card--expense .shopping-entry-card__label { + color: #d7e9ff; +} + +.endpoint-list .shopping-entry-card--expense .shopping-expense-input-group, +.endpoint-list_share .shopping-entry-card--expense .shopping-expense-input-group, +.endpoint-shared_list .shopping-entry-card--expense .shopping-expense-input-group, +.endpoint-view_list .shopping-entry-card--expense .shopping-expense-input-group { + margin-bottom: 0 !important; +} + +.endpoint-list .shopping-entry-card--expense .shopping-expense-input-group > .form-control, +.endpoint-list_share .shopping-entry-card--expense .shopping-expense-input-group > .form-control, +.endpoint-shared_list .shopping-entry-card--expense .shopping-expense-input-group > .form-control, +.endpoint-view_list .shopping-entry-card--expense .shopping-expense-input-group > .form-control { + border-color: rgba(13, 110, 253, 0.55) !important; + background: rgba(17, 24, 39, 0.95) !important; +} + +.endpoint-list .shopping-entry-card--expense .shopping-expense-input-group > .form-control::placeholder, +.endpoint-list_share .shopping-entry-card--expense .shopping-expense-input-group > .form-control::placeholder, +.endpoint-shared_list .shopping-entry-card--expense .shopping-expense-input-group > .form-control::placeholder, +.endpoint-view_list .shopping-entry-card--expense .shopping-expense-input-group > .form-control::placeholder { + color: rgba(255, 255, 255, 0.62); +} + +.endpoint-list .shopping-entry-card--expense .shopping-expense-input-group > .shopping-expense-amount-input:focus, +.endpoint-list_share .shopping-entry-card--expense .shopping-expense-input-group > .shopping-expense-amount-input:focus, +.endpoint-shared_list .shopping-entry-card--expense .shopping-expense-input-group > .shopping-expense-amount-input:focus, +.endpoint-view_list .shopping-entry-card--expense .shopping-expense-input-group > .shopping-expense-amount-input:focus { + box-shadow: inset 0 0 0 1px rgba(13, 110, 253, 0.25), 0 0 0 .2rem rgba(13, 110, 253, 0.18); +} + +.endpoint-list .share-submit-btn--expense, +.endpoint-list_share .share-submit-btn--expense, +.endpoint-shared_list .share-submit-btn--expense, +.endpoint-view_list .share-submit-btn--expense { + color: #8ec5ff; + border-color: rgba(13, 110, 253, 0.72) !important; + background: rgba(13, 110, 253, 0.12); +} + +.endpoint-list .share-submit-btn--expense:hover, +.endpoint-list_share .share-submit-btn--expense:hover, +.endpoint-shared_list .share-submit-btn--expense:hover, +.endpoint-view_list .share-submit-btn--expense:hover, +.endpoint-list .share-submit-btn--expense:focus, +.endpoint-list_share .share-submit-btn--expense:focus, +.endpoint-shared_list .share-submit-btn--expense:focus, +.endpoint-view_list .share-submit-btn--expense:focus { + color: #fff; + border-color: rgba(13, 110, 253, 0.9) !important; + background: rgba(13, 110, 253, 0.22); + box-shadow: 0 0 0 .2rem rgba(13, 110, 253, 0.16); +} + +/* UX polish 2026-03-19: list quick actions card */ +.list-quick-actions { + display: grid; + gap: .9rem; + padding: 1rem; + border: 1px solid rgba(255,255,255,.08); + border-radius: 1rem; + background: linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02)); + box-shadow: 0 .5rem 1.25rem rgba(0,0,0,.14); +} + +.list-quick-actions__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: .75rem; +} + +.list-quick-actions__eyebrow { + font-size: .72rem; + letter-spacing: .08em; + text-transform: uppercase; + color: rgba(255,255,255,.58); + margin-bottom: .15rem; +} + +.list-quick-actions__title { + font-size: 1rem; + font-weight: 700; + color: #fff; + line-height: 1.2; +} + +.list-quick-actions__hint { + font-size: .82rem; + color: rgba(255,255,255,.62); + text-align: right; + max-width: 18rem; +} + +.list-quick-actions__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: .75rem; +} + +.list-quick-actions__form { + display: block; +} + +.list-quick-actions__action.btn { + width: 100%; + min-height: 78px; + display: flex; + align-items: flex-start; + gap: .75rem; + padding: .9rem 1rem; + border-radius: .95rem; + text-align: left; + white-space: normal; +} + +.list-quick-actions__action--primary.btn { + border-color: rgba(255,255,255,.2); + background: rgba(255,255,255,.03); +} + +.list-quick-actions__action--secondary.btn { + border-color: rgba(13,110,253,.5); + background: rgba(13,110,253,.08); +} + +.list-quick-actions__icon { + flex: 0 0 auto; + font-size: 1.05rem; + line-height: 1; + margin-top: .1rem; +} + +.list-quick-actions__content { + display: grid; + gap: .2rem; + min-width: 0; +} + +.list-quick-actions__label { + font-size: .95rem; + font-weight: 600; + color: #fff; + line-height: 1.25; +} + +.list-quick-actions__desc { + font-size: .81rem; + color: rgba(255,255,255,.66); + line-height: 1.35; +} + +@media (max-width: 767.98px) { + .list-quick-actions { + padding: .9rem; + gap: .75rem; + } + + .list-quick-actions__header { + flex-direction: column; + gap: .35rem; + } + + .list-quick-actions__hint { + max-width: none; + text-align: left; + } + + .list-quick-actions__grid { + grid-template-columns: 1fr; + } + + .list-quick-actions__action.btn { + min-height: 72px; + padding: .85rem .9rem; + } +} + + +/* mobile user chip 2026-03-19 */ +.app-navbar__meta--mobile { + display: none; +} + +.app-user-chip--mobile { + max-width: min(46vw, 15rem); + min-width: 0; + padding-left: .6rem; + padding-right: .4rem; +} + +.app-user-chip--mobile .badge { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (max-width: 991.98px) { + .app-navbar__meta--mobile { + display: flex !important; + width: auto; + justify-content: flex-end; + min-width: 0; + } + + .app-user-chip--mobile { + display: inline-flex; + } +} + +@media (max-width: 575.98px) { + .app-brand__icon { + width: 2.25rem; + height: 2.25rem; + } + + .app-user-chip--mobile { + gap: .35rem; + padding: .34rem .38rem .34rem .5rem; + } + + .app-user-chip--mobile .app-user-chip__label { + font-size: .62rem; + letter-spacing: .05em; + } + + .app-user-chip--mobile .badge { + font-size: .72rem; + max-width: 5.9rem; + } +} + + +/* mobile navbar layout fix 2026-03-19 */ +@media (max-width: 991.98px) { + .app-navbar .container-xxl { + grid-template-columns: minmax(0, 1fr) auto auto; + } + + .app-navbar__meta--mobile { + grid-column: 2; + justify-self: end; + min-width: 0; + max-width: min(42vw, 12rem); + } + + .app-mobile-menu { + grid-column: 3; + justify-self: end; + } +} + +@media (max-width: 430px) { + .app-navbar .container-xxl { + grid-template-columns: minmax(0, 1fr) auto auto; + gap: .45rem; + } + + .app-user-chip--mobile { + max-width: min(38vw, 8.5rem); + } + + .app-user-chip--mobile .app-user-chip__label { + display: none; + } +} + +/* --- Main page list progress consistency --- */ +.endpoint-main_page .list-group-item { + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; +} + +.endpoint-main_page .main-list-progress-wrap { + display: block; + width: 100%; + flex: 0 0 100%; + margin-top: 0.8rem !important; +} + +.endpoint-main_page .list-group-item > .main-list-row + .main-list-progress-wrap { + align-self: stretch; +} + +.endpoint-main_page .main-list-progress { + width: 100%; + height: 16px; + margin-top: 0 !important; + border: 1px solid rgba(255, 255, 255, 0.08); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)), + var(--dark-700) !important; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.05), + 0 4px 10px rgba(0, 0, 0, 0.18); +} + +.endpoint-main_page .main-list-progress .progress-bar.bg-success { + background: linear-gradient(135deg, rgba(40, 199, 111, 0.98), rgba(22, 163, 74, 0.98)) !important; +} + +.endpoint-main_page .main-list-progress .progress-bar.bg-warning { + background: linear-gradient(135deg, rgba(245, 189, 65, 0.98), rgba(217, 119, 6, 0.98)) !important; +} + +.endpoint-main_page .main-list-progress .progress-bar.bg-transparent { + background: rgba(255, 255, 255, 0.08) !important; +} + +.endpoint-main_page .main-list-progress__label { + max-width: calc(100% - 0.85rem); + padding: 0 0.45rem; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); + letter-spacing: 0.01em; +} + +@media (max-width: 575.98px) { + .endpoint-main_page .main-list-progress { + height: 15px; + } + + .endpoint-main_page .main-list-progress__label { + font-size: 0.64rem; + } +} + +/* --- Main page progress summary cards --- */ +.endpoint-main_page #mainStatsCollapse.collapsing, +.endpoint-main_page #mainStatsCollapse.show { + overflow: visible; +} + +.endpoint-main_page .main-summary-card { + height: 100%; + padding: 1rem 1rem 1.05rem; + border-radius: 1rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)), rgba(9, 16, 28, 0.88); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2); +} + +.endpoint-main_page .main-summary-card__eyebrow { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(255, 255, 255, 0.65); + margin-bottom: 0.2rem; +} + +.endpoint-main_page .main-summary-card__title { + font-size: 1.05rem; +} + +.endpoint-main_page .main-summary-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.7rem; +} + +.endpoint-main_page .main-summary-stat { + padding: 0.65rem 0.75rem; + border-radius: 0.85rem; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.endpoint-main_page .main-summary-stat__label { + display: block; + font-size: 0.73rem; + color: rgba(255, 255, 255, 0.66); + margin-bottom: 0.15rem; +} + + +@media (max-width: 575.98px) { + .endpoint-main_page .main-summary-card { + padding: 0.9rem; + } + + .endpoint-main_page .main-summary-stats { + grid-template-columns: 1fr; + } +} diff --git a/static/css/style_old.css b/shopping_app/static/css/style_old.css similarity index 100% rename from static/css/style_old.css rename to shopping_app/static/css/style_old.css diff --git a/static/js/access_users.js b/shopping_app/static/js/access_users.js similarity index 85% rename from static/js/access_users.js rename to shopping_app/static/js/access_users.js index 8a5e905..2daf4d3 100644 --- a/static/js/access_users.js +++ b/shopping_app/static/js/access_users.js @@ -29,7 +29,7 @@ async function postAction(postUrl, nextPath, params) { const form = new FormData(); for (const [k, v] of Object.entries(params)) form.set(k, v); - form.set('next', nextPath); // dla trybu HTML fallback + form.set('next', nextPath); try { const res = await fetch(postUrl, { @@ -61,13 +61,16 @@ const suggestUrl = box.dataset.suggestUrl || ''; const grantAction = box.dataset.grantAction || 'grant'; const revokeField = box.dataset.revokeField || 'revoke_user_id'; + const listId = box.dataset.listId || ''; const tokensBox = $('.tokens', box); const input = $('.access-input', box); const addBtn = $('.access-add', box); - // współdzielony datalist do sugestii - let datalist = $('#userHintsGeneric'); + let datalist = null; + const existingListId = input?.getAttribute('list'); + if (existingListId) datalist = document.getElementById(existingListId); + if (!datalist) datalist = $('#userHintsGeneric'); if (!datalist) { datalist = document.createElement('datalist'); datalist.id = 'userHintsGeneric'; @@ -79,25 +82,32 @@ const parseUserText = (txt) => unique((txt || '').split(/[\s,;]+/g).map(s => s.trim().replace(/^@/, '').toLowerCase()).filter(Boolean)); const debounce = (fn, ms = 200) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }; - // Sugestie (GET JSON) - const renderHints = (users = []) => { datalist.innerHTML = users.slice(0, 20).map(u => ``).join(''); }; + const initialOptions = Array.from(datalist.querySelectorAll('option')).map(o => o.value).filter(Boolean); + const renderHints = (users = []) => { + const merged = unique([...(users || []), ...initialOptions]).slice(0, 20); + datalist.innerHTML = merged.map(u => ``).join(''); + }; + renderHints(initialOptions); + let acCtrl = null; const fetchHints = debounce(async (q) => { if (!suggestUrl) return; try { acCtrl?.abort(); acCtrl = new AbortController(); - const res = await fetch(`${suggestUrl}?q=${encodeURIComponent(q || '')}`, { credentials: 'same-origin', signal: acCtrl.signal }); + const normalized = String(q || '').trim().replace(/^@/, ''); + const res = await fetch(`${suggestUrl}?q=${encodeURIComponent(normalized)}`, { credentials: 'same-origin', signal: acCtrl.signal }); if (!res.ok) return renderHints([]); const data = await res.json().catch(() => ({ users: [] })); renderHints(data.users || []); - } catch { renderHints([]); } + } catch { + renderHints(initialOptions); + } }, 200); input?.addEventListener('focus', () => fetchHints(input.value)); input?.addEventListener('input', () => fetchHints(input.value)); - // Revoke (klik w token) box.addEventListener('click', async (e) => { const btn = e.target.closest('.token'); if (!btn || !box.contains(btn)) return; @@ -107,7 +117,7 @@ if (!userId) return toast('Brak identyfikatora użytkownika.', 'danger'); btn.disabled = true; btn.classList.add('disabled'); - const res = await postAction(postUrl, nextPath, { [revokeField]: userId }); + const res = await postAction(postUrl, nextPath, { action: 'revoke', target_list_id: listId, [revokeField]: userId }); if (res.ok) { btn.remove(); @@ -124,7 +134,6 @@ } }); - // Grant (wiele loginów, bez przeładowania strony) async function addUsers() { const users = parseUserText(input?.value); if (!users?.length) return toast('Podaj co najmniej jednego użytkownika', 'warning'); @@ -136,10 +145,9 @@ let okCount = 0, failCount = 0, appended = 0; for (const u of users) { - const res = await postAction(postUrl, nextPath, { action: grantAction, grant_username: u }); + const res = await postAction(postUrl, nextPath, { action: grantAction, target_list_id: listId, grant_username: u }); if (res.ok) { okCount++; - // jeśli backend odda JSON z userem – dolep token live if (res.data?.user) { appendToken(box, res.data.user); appended++; @@ -156,9 +164,7 @@ if (okCount) toast(`Dodano dostęp: ${okCount} użytkownika`, 'success'); if (failCount) toast(`Błędy przy dodawaniu: ${failCount}`, 'danger'); - // fallback: jeśli nic nie dolepiliśmy (brak JSON), odśwież, by zobaczyć nowe tokeny if (okCount && appended === 0) { - // opóźnij minimalnie, by toast mignął setTimeout(() => location.reload(), 400); } } diff --git a/shopping_app/static/js/admin_settings.js b/shopping_app/static/js/admin_settings.js new file mode 100644 index 0000000..a07bd77 --- /dev/null +++ b/shopping_app/static/js/admin_settings.js @@ -0,0 +1,114 @@ +(function () { + const form = document.getElementById("settings-form"); + const resetAllBtn = document.getElementById("reset-all"); + if (!form) return; + + function getCard(input) { + return input.closest(".settings-category-card"); + } + + function getAutoHex(input) { + const autoHex = (input.dataset.auto || "").trim(); + return autoHex ? autoHex.toUpperCase() : "#000000"; + } + + function setOverrideState(input, enabled) { + const card = getCard(input); + const flag = card?.querySelector('.override-enabled'); + const badge = card?.querySelector('[data-role="override-status"]'); + input.dataset.hasOverride = enabled ? "1" : "0"; + if (flag) flag.value = enabled ? "1" : "0"; + if (badge) { + badge.textContent = enabled ? "Nadpisany" : "Domyślny"; + badge.classList.toggle('text-bg-info', enabled); + badge.classList.toggle('text-bg-secondary', !enabled); + } + } + + function updatePreview(input) { + const card = getCard(input); + if (!card) return; + const hexAutoEl = card.querySelector('.hex-auto'); + const hexEffEl = card.querySelector('.hex-effective'); + const barAuto = card.querySelector('.bar[data-kind="auto"]'); + const barEff = card.querySelector('.bar[data-kind="effective"]'); + const autoHex = getAutoHex(input); + const effectiveHex = ((input.value || autoHex).trim() || autoHex).toUpperCase(); + const hasOverride = input.dataset.hasOverride === '1'; + + if (barAuto) barAuto.style.backgroundColor = autoHex; + if (hexAutoEl) hexAutoEl.textContent = autoHex; + if (barEff) barEff.style.backgroundColor = effectiveHex; + if (hexEffEl) hexEffEl.textContent = effectiveHex; + setOverrideState(input, hasOverride); + } + + function applyDefaultVisual(input, keepOverride) { + input.value = getAutoHex(input); + setOverrideState(input, !!keepOverride); + updatePreview(input); + } + + form.querySelectorAll('.use-default').forEach((btn) => { + btn.addEventListener('click', () => { + const input = form.querySelector(`#${btn.dataset.target}`); + if (!input) return; + applyDefaultVisual(input, true); + }); + }); + + form.querySelectorAll('.reset-one').forEach((btn) => { + btn.addEventListener('click', () => { + const input = form.querySelector(`#${btn.dataset.target}`); + if (!input) return; + applyDefaultVisual(input, false); + }); + }); + + resetAllBtn?.addEventListener('click', () => { + form.querySelectorAll('input[type="color"].category-color').forEach((input) => { + applyDefaultVisual(input, false); + }); + }); + + form.querySelectorAll('input[type="color"].category-color').forEach((input) => { + updatePreview(input); + input.addEventListener('input', () => { + setOverrideState(input, true); + updatePreview(input); + }); + input.addEventListener('change', () => { + setOverrideState(input, true); + updatePreview(input); + }); + }); + + (function () { + const slider = document.getElementById('ocr_sensitivity'); + const badge = document.getElementById('ocr_sens_badge'); + const value = document.getElementById('ocr_sens_value'); + if (!slider || !badge || !value) return; + + function labelFor(v) { + v = Number(v); + if (v <= 3) return 'Niski'; + if (v <= 7) return 'Średni'; + return 'Wysoki'; + } + function clsFor(v) { + v = Number(v); + if (v <= 3) return 'sens-low'; + if (v <= 7) return 'sens-mid'; + return 'sens-high'; + } + function update() { + value.textContent = `(${slider.value})`; + badge.textContent = labelFor(slider.value); + badge.classList.remove('sens-low', 'sens-mid', 'sens-high'); + badge.classList.add(clsFor(slider.value)); + } + slider.addEventListener('input', update); + slider.addEventListener('change', update); + update(); + })(); +})(); diff --git a/shopping_app/static/js/app_ui.js b/shopping_app/static/js/app_ui.js new file mode 100644 index 0000000..db3caea --- /dev/null +++ b/shopping_app/static/js/app_ui.js @@ -0,0 +1,270 @@ +document.addEventListener('DOMContentLoaded', function () { + enhancePasswordFields(); + observePasswordFields(); + enhanceSearchableTables(); + wireCopyButtons(); + wireUnsavedWarnings(); + enhanceMobileTables(); + wireAdminNavToggle(); + initResponsiveCategoryBadges(); +}); + +function initPasswordField(input) { + if (!input || input.dataset.uiPasswordReady === '1') return; + if (input.closest('[data-ui-skip-toggle="true"]')) return; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'ui-password-toggle'; + btn.setAttribute('aria-label', 'Pokaż lub ukryj hasło'); + btn.setAttribute('aria-pressed', 'false'); + btn.title = 'Pokaż hasło'; + btn.innerHTML = ''; + + const syncState = function () { + const visible = input.type === 'text'; + btn.innerHTML = visible ? '' : ''; + btn.classList.toggle('is-active', visible); + btn.setAttribute('aria-pressed', visible ? 'true' : 'false'); + btn.title = visible ? 'Ukryj hasło' : 'Pokaż hasło'; + }; + + btn.addEventListener('click', function () { + const selectionStart = input.selectionStart; + const selectionEnd = input.selectionEnd; + input.type = input.type === 'password' ? 'text' : 'password'; + syncState(); + input.focus({ preventScroll: true }); + if (typeof selectionStart === 'number' && typeof selectionEnd === 'number') { + try { + input.setSelectionRange(selectionStart, selectionEnd); + } catch (err) {} + } + }); + + const parent = input.parentElement; + if (parent && parent.classList.contains('input-group')) { + parent.classList.add('ui-password-group'); + if (!parent.querySelector(':scope > .ui-password-toggle')) { + parent.appendChild(btn); + } + } else { + const wrapper = document.createElement('div'); + wrapper.className = 'input-group ui-password-group'; + input.parentNode.insertBefore(wrapper, input); + wrapper.appendChild(input); + wrapper.appendChild(btn); + } + + input.dataset.uiPasswordReady = '1'; + syncState(); +} + +function enhancePasswordFields(root) { + const scope = root && root.querySelectorAll ? root : document; + if (scope.matches && scope.matches('input[type="password"]')) { + initPasswordField(scope); + } + scope.querySelectorAll('input[type="password"]').forEach(initPasswordField); +} + +function observePasswordFields() { + if (window.__uiPasswordObserverReady) return; + const observer = new MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + mutation.addedNodes.forEach(function (node) { + if (!(node instanceof HTMLElement)) return; + enhancePasswordFields(node); + }); + }); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + window.__uiPasswordObserverReady = true; +} + +function enhanceSearchableTables() { + if (document.getElementById('search-table')) return; + const tables = document.querySelectorAll('table.sortable, table[data-searchable="true"]'); + + tables.forEach(function (table, index) { + if (table.dataset.uiSearchReady === '1') return; + + const tbody = table.tBodies[0]; + if (!tbody) return; + const rows = Array.from(tbody.querySelectorAll('tr')); + if (rows.length < 6) return; + + const toolbar = document.createElement('div'); + toolbar.className = 'table-toolbar'; + toolbar.innerHTML = [ + '', + '
', + ' ', + '
' + ].join(''); + + const input = toolbar.querySelector('input'); + const clearBtn = toolbar.querySelector('button'); + const count = toolbar.querySelector('.table-toolbar__count'); + + function updateTableFilter() { + const query = (input.value || '').trim().toLowerCase(); + let visible = 0; + rows.forEach(function (row) { + const rowText = row.innerText.toLowerCase(); + const match = !query || rowText.includes(query); + row.style.display = match ? '' : 'none'; + if (match) visible += 1; + }); + count.textContent = 'Widoczne: ' + visible + ' / ' + rows.length; + } + + input.addEventListener('input', updateTableFilter); + clearBtn.addEventListener('click', function () { + input.value = ''; + updateTableFilter(); + input.focus(); + }); + + const container = table.closest('.table-responsive') || table; + container.parentNode.insertBefore(toolbar, container); + updateTableFilter(); + table.dataset.uiSearchReady = '1'; + }); +} + +function wireCopyButtons() { + document.querySelectorAll('[data-copy-target]').forEach(function (button) { + if (button.dataset.uiCopyReady === '1') return; + button.dataset.uiCopyReady = '1'; + + button.addEventListener('click', async function () { + const target = document.querySelector(button.dataset.copyTarget); + if (!target) return; + const text = target.value || target.textContent || ''; + try { + await navigator.clipboard.writeText(text.trim()); + const original = button.textContent; + button.textContent = '✅ Skopiowano'; + setTimeout(function () { + button.textContent = original; + }, 1800); + } catch (err) { + console.warn('Copy failed', err); + } + }); + }); +} + +function wireUnsavedWarnings() { + const trackedForms = Array.from(document.querySelectorAll('form[data-unsaved-warning="true"]')); + if (!trackedForms.length) return; + + trackedForms.forEach(function (form) { + if (form.dataset.uiUnsavedReady === '1') return; + form.dataset.uiUnsavedReady = '1'; + form.dataset.uiDirty = '0'; + + const markDirty = function () { + form.dataset.uiDirty = '1'; + form.classList.add('is-dirty'); + }; + + form.addEventListener('input', markDirty); + form.addEventListener('change', markDirty); + form.addEventListener('submit', function () { + form.dataset.uiDirty = '0'; + form.classList.remove('is-dirty'); + }); + }); + + window.addEventListener('beforeunload', function (event) { + const hasDirty = trackedForms.some(function (form) { + return form.dataset.uiDirty === '1'; + }); + if (!hasDirty) return; + event.preventDefault(); + event.returnValue = ''; + }); +} + + +function enhanceMobileTables() { + document.querySelectorAll('table').forEach(function (table) { + if (table.dataset.mobileLabelsReady === '1') return; + const headers = Array.from(table.querySelectorAll('thead th')).map(function (th) { + return (th.innerText || '').trim(); + }); + if (!headers.length) return; + table.querySelectorAll('tbody tr').forEach(function (row) { + Array.from(row.children).forEach(function (cell, index) { + if (!cell.dataset.label && headers[index]) { + cell.dataset.label = headers[index]; + } + }); + }); + table.dataset.mobileLabelsReady = '1'; + }); +} + +function wireAdminNavToggle() { + const toggle = document.querySelector('[data-admin-nav-toggle]'); + const nav = document.querySelector('[data-admin-nav-body]'); + if (!toggle || !nav) return; + + toggle.addEventListener('click', function () { + const expanded = toggle.getAttribute('aria-expanded') === 'true'; + toggle.setAttribute('aria-expanded', expanded ? 'false' : 'true'); + nav.classList.toggle('is-open', !expanded); + }); +} + + +function initResponsiveCategoryBadges() { + const headings = Array.from(document.querySelectorAll('[data-mobile-list-heading]')); + if (!headings.length) return; + + const update = function () { + const isMobile = window.matchMedia('(max-width: 575.98px)').matches; + + headings.forEach(function (heading) { + const title = heading.querySelector('[data-mobile-list-title]'); + const group = heading.querySelector('[data-mobile-category-group]'); + if (!title || !group) return; + + group.classList.remove('is-compact'); + if (!isMobile || !group.children.length) return; + + const headingWidth = Math.ceil(heading.getBoundingClientRect().width); + if (!headingWidth) return; + + const titleRect = title.getBoundingClientRect(); + const groupRect = group.getBoundingClientRect(); + const titleWidth = Math.ceil(titleRect.width); + const groupWidth = Math.ceil(group.scrollWidth); + const wrapped = groupRect.top - titleRect.top > 4; + const needsCompact = wrapped || (titleWidth + groupWidth > headingWidth); + group.classList.toggle('is-compact', needsCompact); + }); + }; + + let resizeTimer = null; + window.addEventListener('resize', function () { + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(update, 60); + }); + + if (typeof ResizeObserver === 'function') { + const observer = new ResizeObserver(update); + headings.forEach(function (heading) { + observer.observe(heading); + }); + } + + update(); +} diff --git a/static/js/categories_autosave.js b/shopping_app/static/js/categories_autosave.js similarity index 100% rename from static/js/categories_autosave.js rename to shopping_app/static/js/categories_autosave.js diff --git a/static/js/categories_select_admin.js b/shopping_app/static/js/categories_select_admin.js similarity index 100% rename from static/js/categories_select_admin.js rename to shopping_app/static/js/categories_select_admin.js diff --git a/static/js/category_modal.js b/shopping_app/static/js/category_modal.js similarity index 100% rename from static/js/category_modal.js rename to shopping_app/static/js/category_modal.js diff --git a/static/js/chart_controls.js b/shopping_app/static/js/chart_controls.js similarity index 95% rename from static/js/chart_controls.js rename to shopping_app/static/js/chart_controls.js index 78e5f36..682efc8 100644 --- a/static/js/chart_controls.js +++ b/shopping_app/static/js/chart_controls.js @@ -76,7 +76,7 @@ document.addEventListener("DOMContentLoaded", function () { } // porzucenie zakresu - document.querySelectorAll("#chartTab .range-btn").forEach(b => b.classList.remove("active")); + document.querySelectorAll("#chartTab .chart-range-btn").forEach(b => b.classList.remove("active")); reloadRespectingSplit(); }); @@ -90,9 +90,9 @@ document.addEventListener("DOMContentLoaded", function () { }); // ——— Predefiniowane zakresy pod wykresem ——— - document.querySelectorAll("#chartTab .range-btn").forEach((btn) => { + document.querySelectorAll("#chartTab .chart-range-btn").forEach((btn) => { btn.addEventListener("click", function () { - document.querySelectorAll("#chartTab .range-btn").forEach((b) => b.classList.remove("active")); + document.querySelectorAll("#chartTab .chart-range-btn").forEach((b) => b.classList.remove("active")); this.classList.add("active"); const r = this.getAttribute("data-range"); // last30days/currentmonth/monthly/quarterly/halfyearly/yearly diff --git a/static/js/clickable_row.js b/shopping_app/static/js/clickable_row.js similarity index 79% rename from static/js/clickable_row.js rename to shopping_app/static/js/clickable_row.js index 55b3f43..bfa97ee 100644 --- a/static/js/clickable_row.js +++ b/shopping_app/static/js/clickable_row.js @@ -25,16 +25,15 @@ document.addEventListener("DOMContentLoaded", () => { } checkbox.disabled = true; - row.classList.add('opacity-50'); + row.classList.add('opacity-50', 'is-pending'); - // Dodaj spinner tylko jeśli nie ma - let existingSpinner = row.querySelector('.spinner-border'); + let existingSpinner = row.querySelector('.shopping-item-spinner'); if (!existingSpinner) { const spinner = document.createElement('span'); - spinner.className = 'spinner-border spinner-border-sm ms-2'; + spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm'; spinner.setAttribute('role', 'status'); spinner.setAttribute('aria-hidden', 'true'); - checkbox.parentElement.appendChild(spinner); + row.appendChild(spinner); } }); }); diff --git a/static/js/confirm_delete.js b/shopping_app/static/js/confirm_delete.js similarity index 100% rename from static/js/confirm_delete.js rename to shopping_app/static/js/confirm_delete.js diff --git a/static/js/download_chart.js b/shopping_app/static/js/download_chart.js similarity index 100% rename from static/js/download_chart.js rename to shopping_app/static/js/download_chart.js diff --git a/static/js/expense_chart.js b/shopping_app/static/js/expense_chart.js similarity index 100% rename from static/js/expense_chart.js rename to shopping_app/static/js/expense_chart.js diff --git a/static/js/expense_tab.js b/shopping_app/static/js/expense_tab.js similarity index 100% rename from static/js/expense_tab.js rename to shopping_app/static/js/expense_tab.js diff --git a/static/js/expense_table.js b/shopping_app/static/js/expense_table.js similarity index 95% rename from static/js/expense_table.js rename to shopping_app/static/js/expense_table.js index 47147d1..694c47f 100644 --- a/static/js/expense_table.js +++ b/shopping_app/static/js/expense_table.js @@ -1,7 +1,7 @@ document.addEventListener('DOMContentLoaded', () => { const checkboxes = document.querySelectorAll('.list-checkbox'); const totalEl = document.getElementById('listsTotal'); - const filterButtons = document.querySelectorAll('.range-btn'); + const filterButtons = document.querySelectorAll('#listsTab .table-range-btn'); const rows = document.querySelectorAll('#listsTableBody tr'); const categoryButtons = document.querySelectorAll('.category-filter'); const applyCustomBtn = document.getElementById('applyCustomRange'); @@ -136,7 +136,7 @@ document.addEventListener('DOMContentLoaded', () => { if (initialLoad) { filterByLast30Days(); } else { - const activeRange = document.querySelector('.range-btn.active'); + const activeRange = document.querySelector('#listsTab .table-range-btn.active'); if (activeRange) filterByRange(activeRange.dataset.range); } applyExpenseFilter(); @@ -158,7 +158,7 @@ document.addEventListener('DOMContentLoaded', () => { return; } initialLoad = false; - document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active')); + document.querySelectorAll('#listsTab .table-range-btn').forEach(b => b.classList.remove('active')); filterByCustomRange(startStr, endStr); applyExpenseFilter(); applyCategoryFilter(); diff --git a/static/js/functions.js b/shopping_app/static/js/functions.js similarity index 61% rename from static/js/functions.js rename to shopping_app/static/js/functions.js index ef89d4b..74b8148 100644 --- a/static/js/functions.js +++ b/shopping_app/static/js/functions.js @@ -4,7 +4,7 @@ function updateItemState(itemId, isChecked) { checkbox.checked = isChecked; checkbox.disabled = false; const li = checkbox.closest('li'); - li.classList.remove('opacity-50', 'bg-light', 'text-dark', 'bg-success', 'text-white'); + li.classList.remove('opacity-50', 'is-pending', 'bg-light', 'text-dark', 'bg-success', 'text-white', 'bg-warning', 'item-not-checked'); if (isChecked) { li.classList.add('bg-success', 'text-white'); @@ -12,8 +12,7 @@ function updateItemState(itemId, isChecked) { li.classList.add('item-not-checked'); } - const sp = li.querySelector('.spinner-border'); - if (sp) sp.remove(); + li.querySelectorAll('.shopping-item-spinner, .spinner-border').forEach(sp => sp.remove()); } updateProgressBar(); applyHidePurchased(); @@ -87,22 +86,51 @@ function deleteItem(id) { } function editItem(id, oldName, oldQuantity) { - const newName = prompt('Podaj nową nazwę (lub zostaw starą):', oldName); - if (newName === null) return; + const finalName = String(oldName ?? '').trim(); + let newQuantity = parseInt(oldQuantity, 10); - const newQuantityStr = prompt('Podaj nową ilość:', oldQuantity); - if (newQuantityStr === null) return; + if (!finalName) { + showToast('Nazwa produktu nie może być pusta.', 'warning'); + return; + } - const finalName = newName.trim() !== '' ? newName.trim() : oldName; - - let newQuantity = parseInt(newQuantityStr); if (isNaN(newQuantity) || newQuantity < 1) { - newQuantity = oldQuantity; + newQuantity = 1; } socket.emit('edit_item', { item_id: id, new_name: finalName, new_quantity: newQuantity }); } +function openEditItemModal(event, id, oldName, oldQuantity) { + if (event && typeof event.stopPropagation === 'function') { + event.stopPropagation(); + } + + const modalEl = document.getElementById('editItemModal'); + const idInput = document.getElementById('editItemId'); + const nameInput = document.getElementById('editItemName'); + const quantityInput = document.getElementById('editItemQuantity'); + + if (!modalEl || !idInput || !nameInput || !quantityInput || typeof bootstrap === 'undefined') { + editItem(id, oldName, oldQuantity); + return; + } + + idInput.value = id; + nameInput.value = String(oldName ?? '').trim(); + + const parsedQuantity = parseInt(oldQuantity, 10); + quantityInput.value = !isNaN(parsedQuantity) && parsedQuantity > 0 ? parsedQuantity : 1; + + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + modal.show(); + + setTimeout(() => { + nameInput.focus(); + nameInput.select(); + }, 150); +} + function submitExpense(listId) { const amountInput = document.getElementById('expenseAmount'); const amount = parseFloat(amountInput.value); @@ -257,6 +285,17 @@ function showToast(message, type = 'primary') { setTimeout(() => { toast.remove(); }, 1750); } + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + + function isListDifferent(oldItems, newItems) { if (oldItems.length !== newItems.length) return true; @@ -271,96 +310,106 @@ function isListDifferent(oldItems, newItems) { return false; } -function renderItem(item, isShare = window.IS_SHARE, showEditOnly = false) { + +function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = false) { + const options = (typeof optionsOrShowEditOnly === 'object' && optionsOrShowEditOnly !== null) + ? optionsOrShowEditOnly + : { showEditOnly: !!optionsOrShowEditOnly }; + + const showEditOnly = !!options.showEditOnly; + const temporaryShareUndo = !!options.temporaryShareUndo; + const countdownSeconds = Math.max(0, parseInt(options.countdownSeconds, 10) || 15); + const li = document.createElement('li'); li.id = `item-${item.id}`; - li.dataset.name = item.name.toLowerCase(); - li.className = `list-group-item d-flex justify-content-between align-items-center flex-wrap clickable-item ${item.purchased ? 'bg-success text-white' + li.dataset.name = String(item.name || '').toLowerCase(); + li.dataset.isShare = isShare ? 'true' : 'false'; + li.className = `list-group-item shopping-item-row clickable-item ${item.purchased ? 'bg-success text-white' : item.not_purchased ? 'bg-warning text-dark' : 'item-not-checked' }`; const isOwner = window.IS_OWNER === true || window.IS_OWNER === 'true'; - const allowEdit = !isShare || showEditOnly || isOwner; + const isArchived = window.IS_ARCHIVED === true || window.IS_ARCHIVED === 'true'; + const safeName = escapeHtml(item.name || ''); + const nameForEdit = JSON.stringify(String(item.name || '')); + const quantity = Number.isInteger(item.quantity) ? item.quantity : parseInt(item.quantity, 10) || 1; + const quantityBadge = quantity > 1 + ? `x${quantity}` + : ''; - let quantityBadge = ''; - if (item.quantity && item.quantity > 1) { - quantityBadge = `x${item.quantity}`; + const canEditListItem = !isShare; + const canShowShareActions = isShare && !showEditOnly && !temporaryShareUndo; + const canMarkNotPurchased = !item.not_purchased && !isArchived; + const checkboxHtml = ``; + + const infoParts = []; + if (item.note) { + infoParts.push(`[ ${escapeHtml(item.note)} ]`); + } + if (item.not_purchased_reason) { + infoParts.push(`[ Powód: ${escapeHtml(item.not_purchased_reason)} ]`); + } + const addedByDisplay = item.added_by_display || (isShare ? item.added_by : ''); + const addedById = item.added_by_id != null ? Number(item.added_by_id) : null; + const ownerId = item.owner_id != null ? Number(item.owner_id) : null; + const shouldShowAddedBy = !!addedByDisplay && (addedById === null || ownerId === null || addedById !== ownerId); + if (shouldShowAddedBy) { + infoParts.push(`· dodał/a: ${escapeHtml(addedByDisplay)}`); + } + const infoHtml = infoParts.length + ? `${infoParts.join(' ')}` + : ''; + + const iconBtn = 'btn btn-outline-light btn-sm shopping-action-btn'; + const wideBtn = 'btn btn-outline-light btn-sm shopping-action-btn shopping-action-btn--wide'; + let actionButtons = ''; + + if (canEditListItem) { + const dragHandleButton = window.isSorting + ? `` + : ''; + + actionButtons += ` + ${dragHandleButton} + + `; } - let checkboxOrIcon = item.not_purchased - ? `🚫` - : ``; - - let noteHTML = item.note - ? `[ ${item.note} ]` : ''; - - let reasonHTML = item.not_purchased_reason - ? `[ Powód: ${item.not_purchased_reason} ]` : ''; - - let dragHandle = window.isSorting ? `` : ''; - - let left = ` -
- ${dragHandle} - ${checkboxOrIcon} - ${item.name} ${quantityBadge} - ${noteHTML} - ${reasonHTML} -
`; - - let rightButtons = ''; - - // ✏️ i 🗑️ — tylko jeśli nie jesteśmy w trybie /share lub jesteśmy w 15s (tymczasowo) lub jesteśmy właścicielem - if (allowEdit) { - rightButtons += ` - - `; - } - - // ✅ Jeśli element jest oznaczony jako niekupiony — pokaż "Przywróć" if (item.not_purchased) { - rightButtons += ` - `; + actionButtons += ` + `; + } else if (!isShare || canShowShareActions || isOwner) { + actionButtons += ` + `; } - // ⚠️ tylko jeśli NIE jest oznaczony jako niekupiony i nie jesteśmy w 15s - if (!item.not_purchased && (isOwner || (isShare && !showEditOnly))) { - - rightButtons += ` - `; + if (temporaryShareUndo) { + actionButtons += ` + + + `; + } else if (canShowShareActions) { + actionButtons += ` + `; } - // 📝 tylko jeśli jesteśmy w /share i nie jesteśmy w 15s - if (isShare && !showEditOnly && !isOwner) { - - rightButtons += ` - `; - } - - li.innerHTML = `${left}
${rightButtons}
`; - - if (item.added_by && item.owner_id && item.added_by_id && item.added_by_id !== item.owner_id) { - const infoEl = document.createElement('small'); - infoEl.className = 'text-info ms-4'; - infoEl.innerHTML = `[Dodał/a: ${item.added_by}]`; - li.querySelector('.d-flex.align-items-center')?.appendChild(infoEl); - } + li.innerHTML = ` +
+ ${checkboxHtml} +
+
+
+ ${safeName} + ${quantityBadge} + ${infoHtml} +
+
+ ${actionButtons} +
+
+
+
`; return li; } diff --git a/shopping_app/static/js/lists_access.js b/shopping_app/static/js/lists_access.js new file mode 100644 index 0000000..bf42bd9 --- /dev/null +++ b/shopping_app/static/js/lists_access.js @@ -0,0 +1,22 @@ +(function () { +const $=(s,r=document)=>r.querySelector(s); const $$=(s,r=document)=>Array.from(r.querySelectorAll(s)); +const filterInput=$('#listFilter'),filterCount=$('#filterCount'),selectAll=$('#selectAll'),bulkTokens=$('#bulkTokens'),bulkInput=$('#bulkUsersInput'),bulkBtn=$('#bulkAddBtn'); +const unique=arr=>Array.from(new Set(arr)); +const parseUserText=txt=>unique((txt||'').split(/[\s,;]+/g).map(s=>s.trim().replace(/^@/,'').toLowerCase()).filter(Boolean)); +const selectedListIds=()=>$$('.row-check:checked').map(ch=>ch.dataset.listId); +const visibleRows=()=>$$('#listsTable tbody tr').filter(r=>r.style.display!=='none'); +function applyFilter(){const q=(filterInput?.value||'').trim().toLowerCase();let shown=0;$$('#listsTable tbody tr').forEach(tr=>{const hay=`${tr.dataset.id||''} ${tr.dataset.title||''} ${tr.dataset.owner||''}`;const ok=!q||hay.includes(q);tr.style.display=ok?'':'none';if(ok) shown++;});if(filterCount) filterCount.textContent=shown?`Widoczne: ${shown}`:'Brak wyników';} +filterInput?.addEventListener('input',applyFilter);applyFilter(); +selectAll?.addEventListener('change',()=>{visibleRows().forEach(tr=>{const cb=tr.querySelector('.row-check'); if(cb) cb.checked=selectAll.checked;});}); +$$('.copy-share').forEach(btn=>btn.addEventListener('click',async()=>{const url=btn.dataset.url;try{await navigator.clipboard.writeText(url);}catch{const ta=Object.assign(document.createElement('textarea'),{value:url});document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();}showToast('Skopiowano link udostępnienia','success');})); +function addGlobalToken(username){if(!username) return;const exists=$(`.user-token[data-user="${username}"]`,bulkTokens);if(exists) return;const token=document.createElement('span');token.className='badge rounded-pill text-bg-secondary user-token';token.dataset.user=username;token.innerHTML=`@${username} `;token.querySelector('button').addEventListener('click',()=>token.remove());bulkTokens.appendChild(token);} +bulkInput?.addEventListener('keydown',e=>{if(e.key==='Enter'){e.preventDefault();parseUserText(bulkInput.value).forEach(addGlobalToken);bulkInput.value='';}}); +bulkInput?.addEventListener('change',()=>{parseUserText(bulkInput.value).forEach(addGlobalToken);bulkInput.value='';}); +let hintCtrl=null; +function renderBulkHints(users){const dl=$('#userHints'); if(!dl) return; dl.innerHTML=(users||[]).slice(0,20).map(u=>``).join('');} +async function fetchBulkHints(q=''){const normalized=String(q||'').trim().replace(/^@/,'');try{hintCtrl?.abort();hintCtrl=new AbortController();const res=await fetch(`/admin/user-suggestions?q=${encodeURIComponent(normalized)}`,{credentials:'same-origin',signal:hintCtrl.signal});if(!res.ok) return renderBulkHints([]);const data=await res.json().catch(()=>({users:[]}));renderBulkHints(data.users||[]);}catch(e){renderBulkHints([]);}} +bulkInput?.addEventListener('focus',()=>fetchBulkHints(bulkInput.value)); +bulkInput?.addEventListener('input',()=>fetchBulkHints(bulkInput.value)); +async function bulkGrant(){const lists=selectedListIds(), users=$$('.user-token',bulkTokens).map(t=>t.dataset.user);if(!lists.length) return showToast('Zaznacz przynajmniej jedną listę','warning');if(!users.length) return showToast('Dodaj przynajmniej jednego użytkownika','warning');bulkBtn.disabled=true;bulkBtn.textContent='Pracuję…';const url=location.pathname+location.search;let ok=0,fail=0;for(const lid of lists){for(const u of users){const form=new FormData();form.set('action','grant');form.set('target_list_id',lid);form.set('grant_username',u);try{const res=await fetch(url,{method:'POST',body:form,credentials:'same-origin',headers:{'Accept':'application/json','X-Requested-With':'fetch'}});if(res.ok) ok++; else fail++;}catch{fail++;}}}bulkBtn.disabled=false;bulkBtn.textContent='➕ Nadaj dostęp';showToast(`Gotowe. Sukcesy: ${ok}${fail?`, błędy: ${fail}`:''}`,fail?'danger':'success');if(ok) location.reload();} +bulkBtn?.addEventListener('click',bulkGrant); +})(); \ No newline at end of file diff --git a/static/js/live.js b/shopping_app/static/js/live.js similarity index 80% rename from static/js/live.js rename to shopping_app/static/js/live.js index 2b43def..4538a12 100644 --- a/static/js/live.js +++ b/shopping_app/static/js/live.js @@ -13,7 +13,7 @@ function toggleEmptyPlaceholder() { const li = document.createElement('li'); li.id = 'empty-placeholder'; li.className = 'list-group-item bg-dark text-secondary text-center w-100'; - li.textContent = 'Brak produktów w tej liście.'; + li.textContent = 'Brak produktów w tej liście.'; list.appendChild(li); } else if (hasRealItems && placeholder) { placeholder.remove(); @@ -88,15 +88,15 @@ function setupList(listId, username) { } e.target.disabled = true; - li.classList.add('opacity-50'); + li.classList.add('opacity-50', 'is-pending'); - let existingSpinner = li.querySelector('.spinner-border'); + let existingSpinner = li.querySelector('.shopping-item-spinner'); if (!existingSpinner) { const spinner = document.createElement('span'); - spinner.className = 'spinner-border spinner-border-sm ms-2'; + spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm'; spinner.setAttribute('role', 'status'); spinner.setAttribute('aria-hidden', 'true'); - e.target.parentElement.appendChild(spinner); + li.appendChild(spinner); } } } @@ -139,45 +139,48 @@ function setupList(listId, username) { note: '' }; - const li = renderItem(item, false, true); // ← tryb 15s + const isOwnFreshShareItem = Boolean( + window.IS_SHARE && + data.added_by && + window.CURRENT_LIST_USERNAME && + String(data.added_by) === String(window.CURRENT_LIST_USERNAME) + ); + + const li = renderItem( + item, + window.IS_SHARE, + isOwnFreshShareItem ? { temporaryShareUndo: true, countdownSeconds: 15 } : false + ); + document.getElementById('items').appendChild(li); toggleEmptyPlaceholder(); updateProgressBar(); - if (window.IS_SHARE) { - const countdownId = `countdown-${data.id}`; - const countdownBtn = document.createElement('button'); - countdownBtn.type = 'button'; - countdownBtn.className = 'btn btn-outline-warning'; - countdownBtn.id = countdownId; - countdownBtn.disabled = true; - countdownBtn.textContent = '15s'; - - const btnGroup = li.querySelector('.btn-group'); - if (btnGroup) { - btnGroup.prepend(countdownBtn); - } - + if (isOwnFreshShareItem) { let seconds = 15; const intervalId = setInterval(() => { - const el = document.getElementById(countdownId); - if (el) { - seconds--; - el.textContent = `${seconds}s`; - if (seconds <= 0) { - el.remove(); - clearInterval(intervalId); - } - } else { + const currentItem = document.getElementById(`item-${data.id}`); + const countdownEl = currentItem?.querySelector(`[data-countdown-for="${data.id}"]`); + + if (!currentItem || !countdownEl) { clearInterval(intervalId); + return; } + + seconds -= 1; + if (seconds <= 0) { + clearInterval(intervalId); + return; + } + + countdownEl.textContent = `${seconds}s`; }, 1000); setTimeout(() => { + clearInterval(intervalId); const existing = document.getElementById(`item-${data.id}`); if (existing) { - const updated = renderItem(item, true); - existing.replaceWith(updated); + existing.replaceWith(renderItem(item, window.IS_SHARE)); } }, 15000); } @@ -203,7 +206,7 @@ function setupList(listId, username) { const progressTitle = document.getElementById('progress-title'); if (progressTitle) { - progressTitle.textContent = `📊 Postęp listy — ${data.purchased_count}/${data.total_count} kupionych (${Math.round(data.percent)}%)`; + progressTitle.textContent = `Postęp listy — ${data.purchased_count}/${data.total_count} kupionych (${Math.round(data.percent)}%)`; } }); @@ -218,7 +221,7 @@ function setupList(listId, username) { window.currentItems[idx].name = data.new_name; window.currentItems[idx].quantity = data.new_quantity; - const newItem = renderItem(window.currentItems[idx], true); + const newItem = renderItem(window.currentItems[idx], window.IS_SHARE); const oldItem = document.getElementById(`item-${data.item_id}`); if (oldItem && newItem) { oldItem.replaceWith(newItem); @@ -234,6 +237,7 @@ function setupList(listId, username) { // --- WAŻNE: zapisz dane do reconnect --- window.LIST_ID = listId; window.usernameForReconnect = username; + window.CURRENT_LIST_USERNAME = username; } diff --git a/static/js/mass_add.js b/shopping_app/static/js/mass_add.js similarity index 100% rename from static/js/mass_add.js rename to shopping_app/static/js/mass_add.js diff --git a/static/js/modal_chart.js b/shopping_app/static/js/modal_chart.js similarity index 100% rename from static/js/modal_chart.js rename to shopping_app/static/js/modal_chart.js diff --git a/static/js/notes.js b/shopping_app/static/js/notes.js similarity index 100% rename from static/js/notes.js rename to shopping_app/static/js/notes.js diff --git a/static/js/preview_list_modal.js b/shopping_app/static/js/preview_list_modal.js similarity index 100% rename from static/js/preview_list_modal.js rename to shopping_app/static/js/preview_list_modal.js diff --git a/static/js/product_suggestion.js b/shopping_app/static/js/product_suggestion.js similarity index 100% rename from static/js/product_suggestion.js rename to shopping_app/static/js/product_suggestion.js diff --git a/static/js/receipt_analysis.js b/shopping_app/static/js/receipt_analysis.js similarity index 100% rename from static/js/receipt_analysis.js rename to shopping_app/static/js/receipt_analysis.js diff --git a/static/js/receipt_crop.js b/shopping_app/static/js/receipt_crop.js similarity index 100% rename from static/js/receipt_crop.js rename to shopping_app/static/js/receipt_crop.js diff --git a/static/js/receipt_crop_logic.js b/shopping_app/static/js/receipt_crop_logic.js similarity index 100% rename from static/js/receipt_crop_logic.js rename to shopping_app/static/js/receipt_crop_logic.js diff --git a/static/js/receipt_section.js b/shopping_app/static/js/receipt_section.js similarity index 100% rename from static/js/receipt_section.js rename to shopping_app/static/js/receipt_section.js diff --git a/static/js/receipt_upload.js b/shopping_app/static/js/receipt_upload.js similarity index 100% rename from static/js/receipt_upload.js rename to shopping_app/static/js/receipt_upload.js diff --git a/static/js/select.js b/shopping_app/static/js/select.js similarity index 100% rename from static/js/select.js rename to shopping_app/static/js/select.js diff --git a/static/js/select_all_table.js b/shopping_app/static/js/select_all_table.js similarity index 100% rename from static/js/select_all_table.js rename to shopping_app/static/js/select_all_table.js diff --git a/static/js/select_month.js b/shopping_app/static/js/select_month.js similarity index 100% rename from static/js/select_month.js rename to shopping_app/static/js/select_month.js diff --git a/static/js/show_all_expense.js b/shopping_app/static/js/show_all_expense.js similarity index 100% rename from static/js/show_all_expense.js rename to shopping_app/static/js/show_all_expense.js diff --git a/static/js/sockets.js b/shopping_app/static/js/sockets.js similarity index 97% rename from static/js/sockets.js rename to shopping_app/static/js/sockets.js index e9ef140..4ef5862 100644 --- a/static/js/sockets.js +++ b/shopping_app/static/js/sockets.js @@ -126,6 +126,9 @@ socket.on('full_list', function (data) { window.currentItems = data.items; updateListSmoothly(data.items); + if (typeof window.syncSortModeUI === 'function') { + window.syncSortModeUI(); + } toggleEmptyPlaceholder(); if (didReceiveFirstFullList && isDifferent) { diff --git a/static/js/sort_mode.js b/shopping_app/static/js/sort_mode.js similarity index 51% rename from static/js/sort_mode.js rename to shopping_app/static/js/sort_mode.js index efa90c4..dcad781 100644 --- a/static/js/sort_mode.js +++ b/shopping_app/static/js/sort_mode.js @@ -1,32 +1,62 @@ let sortable = null; -let isSorting = false; +window.isSorting = false; + +function syncSortModeUI() { + const active = !!window.isSorting; + const btn = document.getElementById('sort-toggle-btn'); + const itemsContainer = document.getElementById('items'); + + document.body.classList.toggle('sorting-active', active); + + if (btn) { + if (active) { + btn.textContent = '✔️ Zakończ sortowanie'; + btn.classList.remove('btn-outline-warning'); + btn.classList.add('btn-outline-success'); + } else { + btn.textContent = '✳️ Zmień kolejność'; + btn.classList.remove('btn-outline-success'); + btn.classList.add('btn-outline-warning'); + } + } + + if (itemsContainer && window.currentItems) { + updateListSmoothly(window.currentItems); + } + + document.querySelectorAll('.drag-handle').forEach(handle => { + handle.hidden = !active; + handle.setAttribute('aria-hidden', active ? 'false' : 'true'); + }); +} function enableSortMode() { - if (isSorting) return; - isSorting = true; - window.isSorting = true; - localStorage.setItem('sortModeEnabled', 'true'); + if (window.isSorting) return; const itemsContainer = document.getElementById('items'); const listId = window.LIST_ID; if (!itemsContainer || !listId) return; - if (window.currentItems) { - updateListSmoothly(window.currentItems); - } + window.isSorting = true; + syncSortModeUI(); setTimeout(() => { - if (sortable) sortable.destroy(); + if (!window.isSorting) return; + + if (sortable) { + sortable.destroy(); + sortable = null; + } sortable = Sortable.create(itemsContainer, { animation: 150, handle: '.drag-handle', ghostClass: 'drag-ghost', - filter: 'input, button', + filter: 'input, button:not(.drag-handle)', preventOnFilter: false, onEnd: () => { const order = Array.from(itemsContainer.children) - .map(li => parseInt(li.id.replace('item-', ''))) + .map(li => parseInt(li.id.replace('item-', ''), 10)) .filter(id => !isNaN(id)); fetch('/reorder_items', { @@ -37,16 +67,14 @@ function enableSortMode() { showToast('Zapisano nową kolejność', 'success'); if (window.currentItems) { - window.currentItems = order.map(id => - window.currentItems.find(item => item.id === id) - ); + window.currentItems = order + .map(id => window.currentItems.find(item => item.id === id)) + .filter(Boolean); updateListSmoothly(window.currentItems); } }); } }); - - updateSortButtonUI(true); }, 50); } @@ -56,39 +84,22 @@ function disableSortMode() { sortable = null; } - isSorting = false; - localStorage.removeItem('sortModeEnabled'); window.isSorting = false; - if (window.currentItems) { - updateListSmoothly(window.currentItems); - } - - updateSortButtonUI(false); - + syncSortModeUI(); } function toggleSortMode() { - isSorting ? disableSortMode() : enableSortMode(); -} - -function updateSortButtonUI(active) { - const btn = document.getElementById('sort-toggle-btn'); - if (!btn) return; - - if (active) { - btn.textContent = '✔️ Zakończ sortowanie'; - btn.classList.remove('btn-outline-warning'); - btn.classList.add('btn-outline-success'); + if (window.isSorting) { + disableSortMode(); } else { - btn.textContent = '✳️ Zmień kolejność'; - btn.classList.remove('btn-outline-success'); - btn.classList.add('btn-outline-warning'); - } -} - -document.addEventListener('DOMContentLoaded', () => { - const wasSorting = localStorage.getItem('sortModeEnabled') === 'true'; - if (wasSorting) { enableSortMode(); } +} + +window.toggleSortMode = toggleSortMode; +window.syncSortModeUI = syncSortModeUI; + +document.addEventListener('DOMContentLoaded', () => { + window.isSorting = false; + syncSortModeUI(); }); diff --git a/static/js/table_search.js b/shopping_app/static/js/table_search.js similarity index 100% rename from static/js/table_search.js rename to shopping_app/static/js/table_search.js diff --git a/static/js/toasts.js b/shopping_app/static/js/toasts.js similarity index 100% rename from static/js/toasts.js rename to shopping_app/static/js/toasts.js diff --git a/shopping_app/static/js/toggle_button.js b/shopping_app/static/js/toggle_button.js new file mode 100644 index 0000000..4c68a28 --- /dev/null +++ b/shopping_app/static/js/toggle_button.js @@ -0,0 +1,30 @@ +document.addEventListener("DOMContentLoaded", function () { + const toggleBtn = document.getElementById("tempToggle"); + const hiddenInput = document.getElementById("temporaryHidden"); + if (!toggleBtn || !hiddenInput) return; + + if (typeof bootstrap !== "undefined") { + new bootstrap.Tooltip(toggleBtn); + } + + function updateToggle(isActive) { + toggleBtn.classList.toggle("is-active", isActive); + toggleBtn.textContent = isActive ? "Tymczasowa ✔" : "Tymczasowa"; + toggleBtn.setAttribute("aria-pressed", isActive ? "true" : "false"); + toggleBtn.setAttribute("title", isActive + ? "Lista tymczasowa będzie ważna przez 7 dni" + : "Po zaznaczeniu lista będzie ważna tylko 7 dni"); + } + + let active = toggleBtn.getAttribute("data-active") === "1"; + hiddenInput.value = active ? "1" : "0"; + updateToggle(active); + + toggleBtn.addEventListener("click", function (event) { + event.preventDefault(); + active = !active; + toggleBtn.setAttribute("data-active", active ? "1" : "0"); + hiddenInput.value = active ? "1" : "0"; + updateToggle(active); + }); +}); diff --git a/static/js/user_management.js b/shopping_app/static/js/user_management.js similarity index 100% rename from static/js/user_management.js rename to shopping_app/static/js/user_management.js diff --git a/static/lib/css/bootstrap.min.css b/shopping_app/static/lib/css/bootstrap.min.css similarity index 100% rename from static/lib/css/bootstrap.min.css rename to shopping_app/static/lib/css/bootstrap.min.css diff --git a/static/lib/css/cropper.min.css b/shopping_app/static/lib/css/cropper.min.css similarity index 100% rename from static/lib/css/cropper.min.css rename to shopping_app/static/lib/css/cropper.min.css diff --git a/static/lib/css/glightbox.min.css b/shopping_app/static/lib/css/glightbox.min.css similarity index 100% rename from static/lib/css/glightbox.min.css rename to shopping_app/static/lib/css/glightbox.min.css diff --git a/static/lib/css/sort_table.min.css b/shopping_app/static/lib/css/sort_table.min.css similarity index 100% rename from static/lib/css/sort_table.min.css rename to shopping_app/static/lib/css/sort_table.min.css diff --git a/static/lib/css/tom-select.bootstrap5.min.css b/shopping_app/static/lib/css/tom-select.bootstrap5.min.css similarity index 100% rename from static/lib/css/tom-select.bootstrap5.min.css rename to shopping_app/static/lib/css/tom-select.bootstrap5.min.css diff --git a/static/lib/images/close.png b/shopping_app/static/lib/images/close.png similarity index 100% rename from static/lib/images/close.png rename to shopping_app/static/lib/images/close.png diff --git a/static/lib/images/loading.gif b/shopping_app/static/lib/images/loading.gif similarity index 100% rename from static/lib/images/loading.gif rename to shopping_app/static/lib/images/loading.gif diff --git a/static/lib/images/next.png b/shopping_app/static/lib/images/next.png similarity index 100% rename from static/lib/images/next.png rename to shopping_app/static/lib/images/next.png diff --git a/static/lib/images/prev.png b/shopping_app/static/lib/images/prev.png similarity index 100% rename from static/lib/images/prev.png rename to shopping_app/static/lib/images/prev.png diff --git a/static/lib/js/Sortable.min.js b/shopping_app/static/lib/js/Sortable.min.js similarity index 100% rename from static/lib/js/Sortable.min.js rename to shopping_app/static/lib/js/Sortable.min.js diff --git a/static/lib/js/bootstrap.bundle.min.js b/shopping_app/static/lib/js/bootstrap.bundle.min.js similarity index 100% rename from static/lib/js/bootstrap.bundle.min.js rename to shopping_app/static/lib/js/bootstrap.bundle.min.js diff --git a/static/lib/js/chart.js b/shopping_app/static/lib/js/chart.js similarity index 100% rename from static/lib/js/chart.js rename to shopping_app/static/lib/js/chart.js diff --git a/static/lib/js/cropper.min.js b/shopping_app/static/lib/js/cropper.min.js similarity index 100% rename from static/lib/js/cropper.min.js rename to shopping_app/static/lib/js/cropper.min.js diff --git a/static/lib/js/glightbox.min.js b/shopping_app/static/lib/js/glightbox.min.js similarity index 100% rename from static/lib/js/glightbox.min.js rename to shopping_app/static/lib/js/glightbox.min.js diff --git a/static/lib/js/socket.io.min.js b/shopping_app/static/lib/js/socket.io.min.js similarity index 100% rename from static/lib/js/socket.io.min.js rename to shopping_app/static/lib/js/socket.io.min.js diff --git a/static/lib/js/sort_table.min.js b/shopping_app/static/lib/js/sort_table.min.js similarity index 100% rename from static/lib/js/sort_table.min.js rename to shopping_app/static/lib/js/sort_table.min.js diff --git a/static/lib/js/tom-select.complete.min.js b/shopping_app/static/lib/js/tom-select.complete.min.js similarity index 100% rename from static/lib/js/tom-select.complete.min.js rename to shopping_app/static/lib/js/tom-select.complete.min.js diff --git a/shopping_app/templates/_list_progress.html b/shopping_app/templates/_list_progress.html new file mode 100644 index 0000000..04e3cae --- /dev/null +++ b/shopping_app/templates/_list_progress.html @@ -0,0 +1,31 @@ +{% set total_count = total_count or 0 %} +{% set purchased_count = purchased_count or 0 %} +{% set not_purchased_count = not_purchased_count or 0 %} +{% set accounted_count = purchased_count + not_purchased_count %} +{% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %} +{% set purchased_percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %} +{% set not_purchased_percent = (not_purchased_count / total_count * 100) if total_count > 0 else 0 %} +{% set remaining_count = (total_count - accounted_count) if total_count > accounted_count else 0 %} +{% set remaining_percent = (remaining_count / total_count * 100) if total_count > 0 else 100 %} + +
+
+
+ +
+ +
+ + + Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0) }}%) + {% if total_expense > 0 %} — 💸 {{ '%.2f'|format(total_expense) }} PLN{% endif %} + +
+
diff --git a/shopping_app/templates/admin/_nav.html b/shopping_app/templates/admin/_nav.html new file mode 100644 index 0000000..67cb8da --- /dev/null +++ b/shopping_app/templates/admin/_nav.html @@ -0,0 +1,18 @@ + diff --git a/templates/admin/admin_panel.html b/shopping_app/templates/admin/admin_panel.html similarity index 90% rename from templates/admin/admin_panel.html rename to shopping_app/templates/admin/admin_panel.html index 9dbca18..441b36b 100644 --- a/templates/admin/admin_panel.html +++ b/shopping_app/templates/admin/admin_panel.html @@ -2,23 +2,16 @@ {% block title %}Panel administratora{% endblock %} {% block content %} -
-

⚙️ Panel administratora

+
+
+

⚙️ Panel administratora

+

Wgląd w użytkowników, listy, paragony, wydatki i ustawienia aplikacji.

+
← Powrót do strony głównej
- +{% include 'admin/_nav.html' %} +
@@ -158,7 +151,7 @@
-
+ {% if expiring_lists %}
⏰ Listy tymczasowe wygasające w ciągu 24h
    {% for l in expiring_lists %}
  • #{{ l.id }} {{ l.title }} — {{ l.owner.username if l.owner else '—' }} — {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}
  • {% endfor %}
{% endif %}
{# panel wyboru miesiąca zawsze widoczny #} @@ -243,10 +236,10 @@ {% for e in enriched_lists %} {% set l = e.list %} - + {{ l.id }} - {{ l.title }} + {{ l.title }}{% if l.is_temporary and l.expires_at %}
wygasa: {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %} {% if l.categories %} @@ -295,13 +288,9 @@ {% endif %} -
- ✏️ - +
+ ✏️ +
@@ -352,7 +341,7 @@ checkboxes.forEach(cb => cb.checked = this.checked); }); - + {% endblock %} {% endblock %} \ No newline at end of file diff --git a/shopping_app/templates/admin/api_tokens.html b/shopping_app/templates/admin/api_tokens.html new file mode 100644 index 0000000..6ce103c --- /dev/null +++ b/shopping_app/templates/admin/api_tokens.html @@ -0,0 +1,161 @@ +{% extends 'base.html' %} +{% block title %}Tokeny API{% endblock %} + +{% block content %} +
+
+

🔑 Tokeny API

+

Administrator może utworzyć wiele tokenów, ograniczyć ich zakres i endpointy oraz w każdej chwili je wyłączyć albo usunąć.

+
+ +
+ +{% include 'admin/_nav.html' %} + +{% if latest_plain_token %} + +{% endif %} + +
+
+
➕ Utwórz token
+
+ +
+
+ + +
Nazwij token tak, aby było wiadomo do czego służy.
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+ + +
+
+ +
+
+
+
+
+ +
+
+
+
📘 Dokumentacja API
+ Otwórz TXT +
+
Autoryzacja: Authorization: Bearer TWOJ_TOKEN lub X-API-Token. Endpoint i zakres muszą być jednocześnie dozwolone na tokenie. Parametr limit jest przycinany do wartości ustawionej w tokenie.
+
+ + + + + + {% for row in api_examples %} + + + + + + + {% endfor %} + +
MetodaEndpointWymagany zakresOpis
{{ row.method }}{{ row.path }}{{ row.scope }}{{ row.description }}
+
+
+
+ +
+
+
+
📋 Aktywne i historyczne tokeny
+ {{ api_tokens|length }} szt. +
+
+ + + + + + + + + + + + + + + + {% for token in api_tokens %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
NazwaPrefixStatusZakresEndpointyMax limitUtworzonoOstatnie użycieAkcje
+
{{ token.name }}
+
Autor: {{ token.creator.username if token.creator else '—' }}
+
{{ token.token_prefix }}…{% if token.is_active %}Aktywny{% else %}Wyłączony{% endif %}{{ token.scopes or '—' }}{{ token.allowed_endpoints or '—' }}{{ token.max_limit or '—' }}{{ token.created_at.strftime('%Y-%m-%d %H:%M') if token.created_at else '—' }}{{ token.last_used_at.strftime('%Y-%m-%d %H:%M') if token.last_used_at else 'Jeszcze nie użyto' }} +
+ {% if token.is_active %} +
+ + + +
+ {% else %} +
+ + + +
+ {% endif %} +
+ + + +
+
+
Brak tokenów API.
+
+
+
+{% endblock %} diff --git a/templates/admin/edit_categories.html b/shopping_app/templates/admin/edit_categories.html similarity index 92% rename from templates/admin/edit_categories.html rename to shopping_app/templates/admin/edit_categories.html index 365ae02..5466d26 100644 --- a/templates/admin/edit_categories.html +++ b/shopping_app/templates/admin/edit_categories.html @@ -9,6 +9,8 @@
+{% include 'admin/_nav.html' %} +
-
+
-
+
@@ -88,8 +90,7 @@ - {# Fallback – ukryty przez JS #} - +
@@ -145,7 +146,6 @@ {% endblock %} {% block scripts %} - - - + + {% endblock %} \ No newline at end of file diff --git a/templates/admin/edit_list.html b/shopping_app/templates/admin/edit_list.html similarity index 96% rename from templates/admin/edit_list.html rename to shopping_app/templates/admin/edit_list.html index ca18c13..ff8b1e8 100644 --- a/templates/admin/edit_list.html +++ b/shopping_app/templates/admin/edit_list.html @@ -7,10 +7,12 @@ ← Powrót do panelu +{% include 'admin/_nav.html' %} +

📄 Podstawowe informacje

-
+ @@ -43,20 +45,20 @@
-
-
+
+
-
+
-
+
@@ -301,5 +303,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} \ No newline at end of file diff --git a/templates/admin/list_products.html b/shopping_app/templates/admin/list_products.html similarity index 94% rename from templates/admin/list_products.html rename to shopping_app/templates/admin/list_products.html index 0be9c13..89f2328 100644 --- a/templates/admin/list_products.html +++ b/shopping_app/templates/admin/list_products.html @@ -7,6 +7,8 @@ ← Powrót do panelu
+{% include 'admin/_nav.html' %} +
@@ -40,7 +42,7 @@ {{ total_items }} produktów
-
+
@@ -99,7 +101,7 @@
{% set item_names = items | map(attribute='name') | map('lower') | list %} -
ID
+
@@ -168,8 +170,8 @@ {% block scripts %} - - + + {% endblock %} {% endblock %} \ No newline at end of file diff --git a/templates/admin/lists_access.html b/shopping_app/templates/admin/lists_access.html similarity index 72% rename from templates/admin/lists_access.html rename to shopping_app/templates/admin/lists_access.html index 29eaeb8..c10326e 100644 --- a/templates/admin/lists_access.html +++ b/shopping_app/templates/admin/lists_access.html @@ -3,8 +3,7 @@ {% block content %}
-

🔐{% if list_id %} Zarządzanie dostępem listy #{{ list_id }}{% else %} Zarządzanie dostępem do list - {% endif %}

+

🔐{% if list_id %} Dostęp do listy #{{ list_id }}{% else %} Zarządzanie dostępem do list{% endif %}

{% if list_id %} Powrót do wszystkich list @@ -13,12 +12,14 @@
+{% include 'admin/_nav.html' %} +
-
- +
+
@@ -36,7 +37,7 @@
+ placeholder="Podaj użytkowników (po przecinku lub enterach)" list="userHints" autocomplete="off">
@@ -47,15 +48,14 @@ - + + {% for username in all_usernames %}{% endfor %} +
- - - -
-
ID
+
+
@@ -63,9 +63,8 @@ - - + @@ -73,8 +72,7 @@ @@ -92,29 +90,10 @@ - - - @@ -158,17 +137,13 @@ {% endfor %} {% if lists|length == 0 %} - + {% endif %}
Nazwa listy Właściciel UtworzonoStatusy UdostępnianieUprawnieniaUprawnienia
- - + {{ l.id }} {{ l.created_at.strftime('%Y-%m-%d %H:%M') if l.created_at else '-' }} -
- - -
-
- - -
-
- - -
-
{% if l.share_token %} {% set share_url = url_for('shared_list', token=l.share_token, _external=True) %} -
+
{{ share_url }}
@@ -123,12 +102,12 @@ {% if l.is_public %}Lista widoczna publicznie{% else %}Dostęp przez link / uprawnienia{% endif %}
{% else %} -
Brak tokenu
+
Brak tokenu
{% endif %}
-
+
{% for u in permitted_by_list.get(l.id, []) %} @@ -146,11 +125,11 @@
- +
-
Kliknij token, aby odebrać dostęp.
+
Kliknij token, aby odebrać dostęp. Zmiana zapisuje się od razu.
Brak list do wyświetleniaBrak list do wyświetlenia
-
- -
-
@@ -206,6 +181,7 @@ {% endblock %} {% block scripts %} - + + {% endblock %} \ No newline at end of file diff --git a/templates/admin/receipts.html b/shopping_app/templates/admin/receipts.html similarity index 97% rename from templates/admin/receipts.html rename to shopping_app/templates/admin/receipts.html index 3c7f2b8..a5ccd0a 100644 --- a/templates/admin/receipts.html +++ b/shopping_app/templates/admin/receipts.html @@ -46,6 +46,8 @@
+{% include 'admin/_nav.html' %} +
@@ -222,8 +224,8 @@ endpoint: "/admin/crop_receipt" }; - - + + {% endblock %} {% endblock %} \ No newline at end of file diff --git a/templates/admin/settings.html b/shopping_app/templates/admin/settings.html similarity index 67% rename from templates/admin/settings.html rename to shopping_app/templates/admin/settings.html index eed9f43..b9c7cba 100644 --- a/templates/admin/settings.html +++ b/shopping_app/templates/admin/settings.html @@ -7,7 +7,9 @@ ← Powrót do panelu
-
+{% include 'admin/_nav.html' %} + +
🔎 OCR — słowa kluczowe i czułość @@ -64,34 +66,44 @@ {% set hex_auto = auto_colors[c.id] %} {% set hex_effective = effective_colors[c.id] %}
- +
+
+ + + {{ 'Nadpisany' if hex_override else 'Domyślny' }} + +
-
- + -
- - -
-
+
+ -
+
+ + +
+
+ +
Efektywny @@ -102,6 +114,7 @@ {{ hex_auto|upper }}
+
{% endfor %} @@ -140,6 +153,6 @@ {% endblock %} {% block scripts %} - - + + {% endblock %} diff --git a/shopping_app/templates/admin/templates.html b/shopping_app/templates/admin/templates.html new file mode 100644 index 0000000..87b7669 --- /dev/null +++ b/shopping_app/templates/admin/templates.html @@ -0,0 +1,64 @@ +{% extends 'base.html' %} +{% block title %}Szablony list{% endblock %} +{% block content %} +
+
+

🧩 Szablony list

+

Szablony są niezależne od zwykłych list i mogą służyć do szybkiego tworzenia nowych list.

+
+
+{% include 'admin/_nav.html' %} +
+
+
+
➕ Nowy szablon ręcznie
+ + +
+
+
Każdy produkt w osobnej linii. Ilość opcjonalnie po „x”.
+ + +
+
+
📋 Utwórz z istniejącej listy
+
+ +
+
+
+ +
+
+
+
+
+
Wszystkie szablony użytkowników
{{ templates|length }} szt.
+
+ + + + {% for template in templates %} + + + + + + + + {% else %} + + {% endfor %} + +
NazwaProduktyStatusAutorAkcje
{{ template.name }}
{{ template.description or 'Bez opisu' }}
{{ template.items|length }}{% if template.is_active %}Aktywny{% else %}Wyłączony{% endif %}{{ template.creator.username if template.creator else '—' }} +
+
+
+
+
+
Brak szablonów użytkowników.
+
+
+
+
+{% endblock %} diff --git a/templates/admin/user_management.html b/shopping_app/templates/admin/user_management.html similarity index 86% rename from templates/admin/user_management.html rename to shopping_app/templates/admin/user_management.html index f4c63b3..8dc1265 100644 --- a/templates/admin/user_management.html +++ b/shopping_app/templates/admin/user_management.html @@ -7,6 +7,8 @@ ← Powrót do panelu
+{% include 'admin/_nav.html' %} +
@@ -20,8 +22,10 @@
- +
+ +
@@ -34,7 +38,7 @@
- +
@@ -103,8 +107,10 @@ {% block scripts %} - + {% endblock %} diff --git a/shopping_app/templates/base.html b/shopping_app/templates/base.html new file mode 100644 index 0000000..9585856 --- /dev/null +++ b/shopping_app/templates/base.html @@ -0,0 +1,197 @@ + + + + + + + + + {% block title %}Live Lista Zakupów{% endblock %} + + + + + + {% set exclude_paths = ['/system-auth'] %} + {% if (exclude_paths | select("in", request.path) | list | length == 0) + and has_authorized_cookie + and not is_blocked %} + + + {% endif %} + + {% set substrings_cropper = ['/admin/receipts', '/edit_my_list'] %} + {% if substrings_cropper | select("in", request.path) | list | length > 0 %} + + {% endif %} + + {% set substrings_tomselect = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %} + {% if substrings_tomselect | select("in", request.path) | list | length > 0 %} + + {% endif %} + + + +
+ +
+ +
+ +
+
+ {% block before_content %}{% endblock %} +
+ {% block content %}{% endblock %} +
+
+
+ +
+ + + + + + {% if not is_blocked %} + + + {% if request.endpoint != 'system_auth' %} + + + + + + + {% endif %} + + + + + + {% set substrings = ['/admin/receipts', '/edit_my_list'] %} + {% if substrings | select("in", request.path) | list | length > 0 %} + + {% endif %} + + {% set substrings = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %} + {% if substrings | select("in", request.path) | list | length > 0 %} + + {% endif %} + {% endif %} + + {% block scripts %}{% endblock %} + + diff --git a/templates/edit_my_list.html b/shopping_app/templates/edit_my_list.html similarity index 87% rename from templates/edit_my_list.html rename to shopping_app/templates/edit_my_list.html index 3bbba0f..f54e766 100644 --- a/templates/edit_my_list.html +++ b/shopping_app/templates/edit_my_list.html @@ -1,14 +1,17 @@ {% extends 'base.html' %} {% block content %} -
-

Edytuj listę: {{ list.title }}

+
+
+

Edytuj listę: {{ list.title }}

+

Zmień ustawienia, kategorię, ważność i udostępnianie listy.

+
← Powrót
-
+
@@ -20,20 +23,20 @@
-
-
+
+
-
+
-
+
@@ -95,6 +98,10 @@
+ + {% for username in all_usernames %}{% endfor %} + +
@@ -118,10 +125,10 @@
- + placeholder="Dodaj użytkownika (wiele: przecinki/enter)" list="userHintsOwner" autocomplete="off" aria-label="Dodaj użytkowników"> +
-
Kliknij token, aby odebrać dostęp.
+
Kliknij token, aby odebrać dostęp. Możesz wpisać kilka loginów oddzielonych przecinkiem.
@@ -253,9 +260,9 @@ endpoint: "/user_crop_receipt" }; - - - - - + + + + + {% endblock %} \ No newline at end of file diff --git a/templates/errors.html b/shopping_app/templates/errors.html similarity index 70% rename from templates/errors.html rename to shopping_app/templates/errors.html index 367abfe..fbc9513 100644 --- a/templates/errors.html +++ b/shopping_app/templates/errors.html @@ -2,9 +2,10 @@ {% block title %}Błąd {{ code }}{% endblock %} {% block content %} -
+

{{ code }} — {{ title }}

-
+

Nie udało się wyświetlić żądanej strony.

+
diff --git a/templates/expenses.html b/shopping_app/templates/expenses.html similarity index 73% rename from templates/expenses.html rename to shopping_app/templates/expenses.html index 119dfac..ddadd12 100644 --- a/templates/expenses.html +++ b/shopping_app/templates/expenses.html @@ -2,15 +2,18 @@ {% block title %}Wydatki z Twoich list{% endblock %} {% block content %} -
-

Statystyki wydatków

+
+
+

Statystyki wydatków

+

Analiza kosztów list w czasie, z podziałem na zakresy i kategorie.

+
← Powrót
-
+
ID
+ + + {% for log in activity_logs %} + + + + + + + {% endfor %} + +
KiedyKtoAkcjaProdukt / szczegóły
{{ log.created_at.strftime('%Y-%m-%d %H:%M') }}{{ log.actor_name }}{{ action_label(log.action) }}{{ log.item_name or log.details or '—' }}
+
+
+
+
+{% endif %} + +{% set receipt_pattern = 'list_' ~ list.id %} + +
+
+
+
+
📄 Paragony dodane do tej listy
+

+ Tutaj możesz wygodnie przejrzeć wszystkie paragony przypisane do tej listy. +

+
+
+ {{ receipts|length }} plik{% if receipts|length != 1 %}i{% endif %} + Tylko podgląd +
+
+ +
+
+
📸 Galeria paragonów
+ {% if receipts %} + Kliknij miniaturę, aby otworzyć podgląd + {% endif %} +
+ +
+ {% if receipts %} + {% for r in receipts %} + + {% endfor %} + {% else %} +
+ +
+ {% endif %} +
+
+
+
+ + + + + + + {% for username in all_usernames %}{% endfor %} + + + + + + + + +{% block scripts %} + + + + + + + + +{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/shopping_app/templates/list_share.html b/shopping_app/templates/list_share.html new file mode 100644 index 0000000..1d7f650 --- /dev/null +++ b/shopping_app/templates/list_share.html @@ -0,0 +1,335 @@ +{% extends 'base.html' %} +{% block title %}Lista: {{ list.title }}{% endblock %} +{% block content %} + +
+

+ 🛍️ {{ list.title }} + + {% if list.is_archived %} + (Archiwalna) + {% endif %} + + {% if total_expense > 0 %} + + 💸 {{ '%.2f'|format(total_expense) }} PLN + + {% else %} + + {% endif %} + + {% if list.category_badges %} + {% for cat in list.category_badges %} + + {{ cat.name }} + + {% endfor %} + {% endif %} +

+ + +
+ +
    + {% for item in items %} + +
  • + +
    + +
    +
    +
    + {{ item.name }} + {% if item.quantity and item.quantity > 1 %} + x{{ item.quantity }} + {% endif %} + {% set info_parts = [] %} + {% if item.note %}{% set _ = info_parts.append('[ ' ~ item.note ~ ' ]') %}{% endif %} + {% if item.not_purchased_reason %}{% set _ = info_parts.append('[ Powód: ' ~ item.not_purchased_reason ~ ' ]') %}{% endif %} + {% if item.added_by_display %}{% set _ = info_parts.append('· dodał/a: ' ~ item.added_by_display ~ '') %}{% endif %} + {% if info_parts %} + {{ info_parts | join(' ') | safe }} + {% endif %} +
    +
    + {% if item.not_purchased %} + + {% else %} + + {% endif %} + + +
    +
    +
    +
    +
  • + {% else %} +
  • + Brak produktów w tej liście. +
  • + {% endfor %} +
+ +{% if not list.is_archived %} +
+
➕ Dodaj produkt
+
Wpisz nazwę produktu i ilość, potem kliknij Dodaj.
+
+ + + +
+
+{% endif %} + +{% if not list.is_archived %} +
+
+ 💰 Dodaj wydatek + + + 💸 Łączna suma: {{ '%.2f'|format(total_expense) }} PLN + +
+ +
Wpisz kwotę wydatku i kliknij Zapisz.
+ +
+ + + +
+
+{% endif %} + + + + + +
+ {% set receipt_pattern = 'list_' ~ list.id %} + +
+
+
+
+
+
📄 Paragony
+

+ Przeglądaj dodane paragony, wrzucaj nowe i rozliczaj je przez OCR. +

+
+
+ {{ receipts|length }} plik{% if receipts|length != 1 %}i{% endif %} + {% if list.is_archived %} + Lista archiwalna + {% elif current_user.is_authenticated %} + Możesz dodawać + {% else %} + Tylko podgląd + {% endif %} +
+
+ +
+
+
+
+
📸 Dodane paragony
+ {% if receipts %} + Kliknij miniaturę, aby otworzyć podgląd + {% endif %} +
+ +
+ {% if receipts %} + {% for r in receipts %} + + {% endfor %} + {% else %} +
+ +
+ {% endif %} +
+
+
+ +
+
+
+
+
+
🔍 Analiza paragonów (OCR)
+

+ System spróbuje automatycznie rozpoznać kwoty. Sprawdź wynik i kliknij „Dodaj”, aby dopisać wydatek. +

+
+
+ + {% if current_user.is_authenticated %} + + {% else %} +
+ ⚠️ Tylko zalogowani użytkownicy mogą zlecać analizę OCR. +
+ {% endif %} +
+
+ + {% if not list.is_archived and current_user.is_authenticated %} +
+
📤 Dodaj nowy paragon
+

Możesz dodać zdjęcie z aparatu, z galerii albo plik PDF.

+ +
+ +
+ + + + + + + + +
+ + + +
+
+
+ {% elif list.is_archived %} +
+
📤 Dodawanie zablokowane
+

Ta lista jest archiwalna, więc nie można już dodawać nowych paragonów.

+
+ {% elif not current_user.is_authenticated %} +
+
🔐 Dodawanie wymaga logowania
+

Zaloguj się, aby dodawać paragony i uruchamiać analizę OCR.

+
+ {% endif %} +
+
+
+
+
+
+
+ + + + +{% block scripts %} + + + + + + + + +{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/shopping_app/templates/login.html similarity index 58% rename from templates/login.html rename to shopping_app/templates/login.html index 5623470..bd00a9e 100644 --- a/templates/login.html +++ b/shopping_app/templates/login.html @@ -1,9 +1,10 @@ {% extends 'base.html' %} {% block title %}Logowanie{% endblock %} {% block content %} -
+

🔒 Logowanie

-
+

Zaloguj się, aby tworzyć, edytować i współdzielić listy zakupów.

+
@@ -13,8 +14,10 @@ class="form-control bg-dark text-white border-secondary rounded" required>
- +
+ +
diff --git a/shopping_app/templates/main.html b/shopping_app/templates/main.html new file mode 100644 index 0000000..97879b1 --- /dev/null +++ b/shopping_app/templates/main.html @@ -0,0 +1,326 @@ +{% extends 'base.html' %} +{% block title %}Twoje listy zakupów{% endblock %} +{% block content %} + +{% if not current_user.is_authenticated %} + +{% endif %} + +{% if current_user.is_authenticated %} +{% if expiring_lists %} + +{% endif %} +
+
+
+
+
+

Twoje centrum list zakupowych

+

Stwórz nową liste

+
+
+
+
+ + + +
+ +
+
+
+
+ +{% endif %} + +{% set month_names = ["styczeń","luty","marzec","kwiecień","maj","czerwiec","lipiec","sierpień","wrzesień","październik","listopad","grudzień"] %} + +
+ + +
+ +
+ +
+ +{% macro render_summary_panel(title, summary, accent='success') -%} +
+
+
+
+
Postęp
+

{{ title }}

+
+ Listy: {{ summary.list_count }} +
+ + {% with total_count=summary.total_products, purchased_count=summary.purchased_products, not_purchased_count=summary.not_purchased_products, total_expense=summary.total_expense %} + {% include '_list_progress.html' %} + {% endwith %} + +
+
+ Kupione + {{ summary.purchased_products }} +
+
+ Niekupione + {{ summary.not_purchased_products }} +
+
+ Nieoznaczone + {{ summary.remaining_products }} +
+
+ Wydatki + {{ '%.2f'|format(summary.total_expense) }} PLN +
+
+
+
+{%- endmacro %} + +{% if current_user.is_authenticated %} +
+ + +
+
+
+ {{ render_summary_panel('Twoje listy', user_lists_summary) }} + {{ render_summary_panel('Udostępnione i publiczne', accessible_lists_summary, 'info') }} +
+
+ +

+ Twoje listy +

+ +{% if user_lists %} + +{% else %} +

Nie utworzono żadnej listy

+{% endif %} +{% else %} +
+ +
+
+
+
+ {{ render_summary_panel('Publiczne listy innych użytkowników', accessible_lists_summary, 'info') }} +
+
+
+{% endif %} + +

+ {% if current_user.is_authenticated %} + Udostępnione i publiczne listy innych użytkowników + {% else %} + Publiczne listy innych użytkowników + {% endif %} +

+ +{% set lists_to_show = accessible_lists %} +{% if lists_to_show %} +
    + {% for l in lists_to_show %} + {% set purchased_count = l.purchased_count %} + {% set total_count = l.total_count %} + {% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %} + +
  • +
    +
    +
    + {% if l.is_temporary and l.expires_at %}⏰ {{ l.expires_at.strftime('%Y-%m-%d %H:%M') }}{% endif %} + + {{ l.title }} + (Autor: {{ l.owner.username if l.owner else '—' }}) + + {% if l.category_badges %} + + {% for cat in l.category_badges %} + + {{ cat.name }} + + + {% endfor %} + + {% endif %} +
    +
    + + +
    + + {% with total_count=total_count, purchased_count=purchased_count, not_purchased_count=l.not_purchased_count, total_expense=l.total_expense %} + {% include '_list_progress.html' %} + {% endwith %} +
  • + {% endfor %} +
+{% else %} +

Brak list do wyświetlenia

+{% endif %} + + + + + +{% block scripts %} + + +{% endblock %} + +{% endblock %} \ No newline at end of file diff --git a/shopping_app/templates/my_templates.html b/shopping_app/templates/my_templates.html new file mode 100644 index 0000000..2294cf9 --- /dev/null +++ b/shopping_app/templates/my_templates.html @@ -0,0 +1,58 @@ +{% extends 'base.html' %} +{% block title %}Moje szablony{% endblock %} +{% block content %} +
+

🧩 Moje szablony

Każdy użytkownik zarządza własnymi szablonami niezależnie od panelu admina.

+ {% if current_user.is_admin %}Panel admina{% endif %} +
+
+
+
+
➕ Nowy szablon ręcznie
+
+ +
+
+
Każda linia to osobny produkt. Ilość opcjonalnie przez xN.
+ +
+
+
+
📋 Utwórz z istniejącej listy
+
+
+
+
+ +
+
+
+
+
+
Aktywne szablony
{{ templates|length }} szt.
+
+ + + + {% for template in templates %} + + + + + + + {% else %} + + {% endfor %} + +
NazwaOpisProduktyAkcje
{{ template.name }}
{{ template.created_at.strftime('%Y-%m-%d %H:%M') if template.created_at else '' }}
{{ template.description or '—' }}{{ template.items|length }}
+
+
+
Brak własnych szablonów.
+
+
+
+
+{% endblock %} diff --git a/templates/system_auth.html b/shopping_app/templates/system_auth.html similarity index 59% rename from templates/system_auth.html rename to shopping_app/templates/system_auth.html index 4d55b03..b8e6930 100644 --- a/templates/system_auth.html +++ b/shopping_app/templates/system_auth.html @@ -2,16 +2,19 @@ {% block title %}Wymagane hasło główne{% endblock %} {% block content %} -
+

🔑 Podaj hasło główne

-
+

Dostęp do aplikacji jest chroniony dodatkowym hasłem wejściowym.

+
- +
+ +
diff --git a/shopping_app/uploads b/shopping_app/uploads new file mode 120000 index 0000000..1cca7b2 --- /dev/null +++ b/shopping_app/uploads @@ -0,0 +1 @@ +../uploads \ No newline at end of file diff --git a/shopping_app/web.py b/shopping_app/web.py new file mode 100644 index 0000000..11a06c4 --- /dev/null +++ b/shopping_app/web.py @@ -0,0 +1,238 @@ +from .deps import * +from .app_setup import * +from .models import * +from .helpers import * + +@login_manager.user_loader +def load_user(user_id): + return db.session.get(User, int(user_id)) + + +@app.context_processor +def inject_version(): + def static_asset_url(endpoint, filename): + directory_map = { + "static_bp.serve_js": "static/js", + "static_bp.serve_css": "static/css", + "static_bp.serve_js_lib": "static/lib/js", + "static_bp.serve_css_lib": "static/lib/css", + } + relative_dir = directory_map.get(endpoint) + version = app.config["APP_VERSION"] + if relative_dir: + file_path = os.path.join(app.root_path, relative_dir, filename) + version = get_file_md5(file_path) + return url_for(endpoint, filename=filename, v=version) + + return { + "APP_VERSION": app.config["APP_VERSION"], + "static_asset_url": static_asset_url, + } + + +@app.context_processor +def inject_time(): + return dict(time=time) + + +@app.context_processor +def inject_has_authorized_cookie(): + return {"has_authorized_cookie": "authorized" in request.cookies} + + +@app.context_processor +def inject_is_blocked(): + ip = request.access_route[0] + return {"is_blocked": is_ip_blocked(ip)} + + +@app.before_request +def require_system_password(): + endpoint = request.endpoint + + if endpoint is None: + return + + if endpoint in ( + "static_bp.serve_js", + "static_bp.serve_css", + "static_bp.serve_js_lib", + "static_bp.serve_css_lib", + "favicon", + "favicon_ico", + "uploaded_file", + ): + return + + if endpoint in ("system_auth", "healthcheck", "robots_txt") or endpoint.startswith("api_"): + return + + ip = request.access_route[0] + if is_ip_blocked(ip): + abort(403) + + if "authorized" not in request.cookies and not endpoint.startswith("login"): + if request.path == "/": + return redirect(url_for("system_auth")) + + parsed = urlparse(request.url) + fixed_url = urlunparse(parsed._replace(netloc=request.host)) + return redirect(url_for("system_auth", next=fixed_url)) + + +@app.after_request +def apply_headers(response): + # Specjalny endpoint wykresów/API – zawsze no-cache + if request.path == "/expenses_data": + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + # --- statyczne pliki (nagłówki z .env) --- + if request.path.startswith(("/static/", "/uploads/")): + response.headers.pop('Vary', None) # fix bug with backslash + response.headers['Vary'] = 'Accept-Encoding' + return response + + # --- healthcheck --- + if request.path == '/healthcheck': + response.headers['Cache-Control'] = 'no-store, no-cache' + response.headers.pop('ETag', None) + response.headers.pop('Vary', None) + return response + + # --- redirecty --- + if response.status_code in (301, 302, 303, 307, 308): + response.headers["Cache-Control"] = "no-store" + response.headers.pop("Vary", None) + return response + + # --- błędy 4xx --- + if 400 <= response.status_code < 500: + response.headers["Cache-Control"] = "no-store" + ct = (response.headers.get("Content-Type") or "").lower() + if "application/json" not in ct: + response.headers["Content-Type"] = "text/html; charset=utf-8" + response.headers.pop("Vary", None) + + # --- błędy 5xx --- + elif 500 <= response.status_code < 600: + response.headers["Cache-Control"] = "no-store" + ct = (response.headers.get("Content-Type") or "").lower() + if "application/json" not in ct: + response.headers["Content-Type"] = "text/html; charset=utf-8" + response.headers["Retry-After"] = "120" + response.headers.pop("Vary", None) + + # --- strony dynamiczne (domyślnie) --- + # Wszystko, co nie jest /static/ ani /uploads/ ma być no-store/no-cache + response.headers.setdefault("Cache-Control", "no-cache, no-store") + + return response + + +@app.before_request +def start_timer(): + g.start_time = time.time() + + +@app.after_request +def log_request(response): + if request.path == "/healthcheck": + return response + + ip = get_client_ip() + method = request.method + path = request.path + status = response.status_code + length = response.content_length or "-" + start = getattr(g, "start_time", None) + duration = round((time.time() - start) * 1000, 2) if start else "-" + agent = request.headers.get("User-Agent", "-") + + if status == 304: + app.logger.info( + f'REVALIDATED: {ip} - "{method} {path}" {status} {length} {duration}ms "{agent}"' + ) + else: + app.logger.info( + f'{ip} - "{method} {path}" {status} {length} {duration}ms "{agent}"' + ) + + app.logger.debug(f"Request headers: {dict(request.headers)}") + app.logger.debug(f"Response headers: {dict(response.headers)}") + return response + + +@app.template_filter("filemtime") +def file_mtime_filter(path): + try: + t = os.path.getmtime(path) + return datetime.fromtimestamp(t) + except Exception: + # return datetime.utcnow() + return datetime.now(timezone.utc) + + +@app.template_filter("todatetime") +def to_datetime_filter(s): + return datetime.strptime(s, "%Y-%m-%d") + + +@app.template_filter("filesizeformat") +def filesizeformat_filter(path): + try: + size = os.path.getsize(path) + for unit in ["B", "KB", "MB", "GB"]: + if size < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} TB" + except Exception: + return "N/A" + + +@app.errorhandler(404) +def page_not_found(e): + return ( + render_template( + "errors.html", + code=404, + title="Strona nie znaleziona", + message="Ups! Podana strona nie istnieje lub została przeniesiona.", + ), + 404, + ) + + +@app.errorhandler(403) +def forbidden(e): + return ( + render_template( + "errors.html", + code=403, + title="Brak dostępu", + message=( + e.description + if e.description + else "Nie masz uprawnień do wyświetlenia tej strony." + ), + ), + 403, + ) + + +@app.route("/favicon.ico") +def favicon_ico(): + return redirect(url_for("static", filename="favicon.svg")) + + +@app.route("/favicon.svg") +def favicon(): + svg = """ + + 🛒 + + """ + return svg, 200, {"Content-Type": "image/svg+xml"} diff --git a/static/css/style.css b/static/css/style.css deleted file mode 100644 index d53fad6..0000000 --- a/static/css/style.css +++ /dev/null @@ -1,1036 +0,0 @@ -/* ========================================================= - Variables (single source of truth) -========================================================= */ -:root { - /* brand / info */ - --primary: #184076; - --primary-border: #153866; - --primary-text: #e6f0ff; - - --info: var(--primary); - --info-border: var(--primary-border); - --info-text: var(--primary-text); - - /* success */ - --success: #1c6930; - --success-border: #165024; - --success-text: #eaffea; - - /* warning */ - --warning: #665c1e; - --warning-border: #4d4415; - --warning-text: #fffbe5; - - /* danger */ - --danger: #6e1a1e; - --danger-border: #531417; - --danger-text: #ffeaea; - - /* neutrals / dark */ - --dark-900: #181a1b; - --dark-800: #1c1f22; - --dark-750: #1f2225; - --dark-700: #212529; - --dark-650: #23272a; - --dark-600: #2a2d31; - --dark-550: #2b2f33; - --dark-500: #2c2f33; - --dark-480: #2c3034; - --dark-470: #2a2d31; - --dark-450: #3a3f44; - --dark-400: #343a40; - --dark-350: #3d4248; - --dark-300: #495057; - - --text-strong: #f8f9fa; - --text: #e2e3e5; - --text-dim: #e1e1e1; - --muted: #6c757d; - - /* defaults */ - --progress-default: #3d7bd6; -} - -/* ========================================================= - Utilities & Sizes -========================================================= */ -.large-checkbox { - width: 1.5em; - height: 1.5em; -} - -.clickable-item { - cursor: pointer; -} - -.progress-thin { - height: 12px; -} - -.item-not-checked { - background-color: var(--dark-500) !important; - color: #fff !important; -} - -#empty-placeholder { - font-style: italic; - pointer-events: none; -} - -.fade-out { - opacity: 0; - transition: opacity 0.5s ease; -} - -@media (pointer: fine) { - .only-mobile { - display: none !important; - } -} - -/* Bootstrap bg overrides via variables */ -.bg-success { - background-color: var(--success) !important; -} - -.bg-warning { - background-color: var(--warning) !important; -} - -/* ========================================================= - Buttons -========================================================= */ -/* Primary */ -.btn-primary { - background-color: var(--primary) !important; - border-color: var(--primary-border) !important; -} - -.btn-primary:hover, -.btn-primary:focus, -.btn-primary:active { - background-color: #13315f !important; - border-color: #10284f !important; -} - -/* Success */ -.btn-success { - background-color: var(--success) !important; - border-color: var(--success-border) !important; - color: #fff !important; -} - -.btn-success:hover, -.btn-success:focus, -.btn-success:active { - background-color: #155627 !important; - border-color: #124521 !important; - color: #fff !important; -} - -/* Warning */ -.btn-warning { - background-color: var(--warning) !important; - border-color: var(--warning-border) !important; - color: var(--warning-text) !important; -} - -.btn-warning:hover, -.btn-warning:focus, -.btn-warning:active { - background-color: #5c4c17 !important; - border-color: #3e3610 !important; - color: var(--warning-text) !important; -} - -/* Outline */ -.btn-outline-success { - color: var(--success) !important; - border-color: var(--success) !important; -} - -.btn-outline-success:hover, -.btn-outline-success:focus, -.btn-outline-success:active { - background-color: var(--success) !important; - border-color: var(--success-border) !important; - color: #fff !important; -} - -.btn-outline-warning { - color: #d9c97a !important; - border-color: var(--warning) !important; -} - -.btn-outline-warning:hover, -.btn-outline-warning:focus, -.btn-outline-warning:active { - background-color: var(--warning) !important; - border-color: var(--warning-border) !important; - color: var(--warning-text) !important; -} - -/* File input button */ -input[type="file"]::file-selector-button { - background-color: #1b4a29; - color: #f0f0f0; - border: none; - padding: .5em 1em; - border-radius: 4px; - font-weight: bold; - cursor: pointer; - transition: background .2s; -} - -/* ========================================================= - Forms (inputs, selects, switches, placeholders) -========================================================= */ -.form-select, -.form-control, -textarea.form-control { - background-color: var(--dark-700) !important; - color: var(--text-strong) !important; - border: 1px solid var(--dark-300) !important; -} - -.form-select:focus, -.form-control:focus, -textarea.form-control:focus { - background-color: var(--dark-800) !important; - border-color: var(--primary) !important; - color: #fff !important; - box-shadow: 0 0 0 .25rem rgba(24, 64, 118, .35) !important; -} - -.form-control:disabled, -textarea.form-control:disabled { - background-color: var(--dark-550) !important; - color: var(--muted) !important; - cursor: not-allowed; -} - -/* Switch */ -.form-switch .form-check-input { - background-color: var(--dark-400) !important; - border-color: var(--dark-300) !important; -} - -.form-switch .form-check-input:checked { - background-color: var(--primary) !important; - border-color: var(--primary-border) !important; -} - -/* Placeholders */ -.form-control::placeholder, -.bg-dark .form-control::placeholder { - color: #aaa !important; - opacity: 1 !important; -} - -/* Paired corners (utility) */ -#tempToggle { - border-top-left-radius: 0; - border-bottom-left-radius: 0; -} - -input.form-control { - border-top-right-radius: 0; - border-bottom-right-radius: 0; -} - -/* XXL custom checkbox */ -input[type="checkbox"].large-checkbox { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - width: 1.5em; - height: 1.5em; - margin: 0; - padding: 0; - outline: none; - background: none; - cursor: pointer; - position: relative; - vertical-align: middle; -} - -input[type="checkbox"].large-checkbox::before { - content: '✗'; - color: #dc3545; - font-size: 1.5em; - font-weight: bold; - position: absolute; - left: 0; - top: 50%; - transform: translateY(-50%); - line-height: 1; - transition: color .2s; -} - -input[type="checkbox"].large-checkbox:checked::before { - content: '✓'; - color: #fff; -} - -input[type="checkbox"].large-checkbox:disabled::before { - opacity: .5; - cursor: not-allowed; -} - -input[type="checkbox"].large-checkbox:disabled { - cursor: not-allowed; -} - -/* Tom-Select / TS */ -.tom-dark .ts-control { - background-color: var(--dark-700) !important; - color: #fff !important; - border: 1px solid var(--dark-300) !important; - border-radius: .375rem; - min-height: 38px; - padding: .25rem .5rem; - box-sizing: border-box; -} - -.tom-dark .ts-control .item { - background-color: var(--dark-400) !important; - color: #fff !important; - border-radius: .25rem; - padding: 2px 8px; - margin-right: 4px; -} - -.ts-dropdown { - background-color: var(--dark-700) !important; - color: #fff !important; - border: 1px solid var(--dark-300); - border-radius: .375rem; - z-index: 9999 !important; - max-height: 300px; - overflow-y: auto; -} - -.ts-dropdown .active { - background-color: var(--dark-300) !important; - color: #fff !important; -} - -td select.tom-dark { - width: 100%; - max-width: 100%; - box-sizing: border-box; -} - -/* ========================================================= - Alerts, Badges, Background helpers -========================================================= */ -/* Alerts */ -.alert-success { - background-color: #225d36 !important; - color: var(--success-text) !important; - border-color: #174428 !important; -} - -.alert-danger { - background-color: #7a1f23 !important; - color: var(--danger-text) !important; - border-color: #531417 !important; -} - -.alert-info { - background-color: var(--primary) !important; - color: var(--primary-text) !important; - border-color: var(--primary-border) !important; -} - -.alert-warning { - background-color: var(--warning) !important; - color: var(--warning-text) !important; - border-color: var(--warning-border) !important; -} - -.alert-light { - background-color: #3a3f44 !important; - color: var(--text-strong) !important; - border-color: var(--dark-480) !important; -} - -/* Badges */ -.badge.bg-success, -.badge.text-bg-success { - background-color: #225d36 !important; - color: var(--success-text) !important; -} - -.badge.bg-danger, -.badge.text-bg-danger { - background-color: #7a1f23 !important; - color: var(--danger-text) !important; -} - -.badge.bg-info, -.badge.text-bg-info { - background-color: #1d3a4d !important; - color: #eaf6ff !important; -} - -.badge.bg-warning, -.badge.text-bg-warning { - background-color: var(--warning) !important; - color: var(--warning-text) !important; -} - -.badge.bg-secondary, -.badge.text-bg-secondary { - background-color: var(--dark-400) !important; - color: #e2e3e5 !important; -} - -.badge.bg-primary, -.badge.text-bg-primary { - background-color: var(--primary) !important; - color: var(--primary-text) !important; -} - -.badge.bg-light, -.badge.text-bg-light { - background-color: var(--dark-350) !important; - color: #f1f3f5 !important; -} - -.badge.bg-dark, -.badge.text-bg-dark { - background-color: var(--dark-900) !important; - color: var(--text-strong) !important; -} - -/* ========================================================= - Progress -========================================================= */ -.progress-dark { - background-color: var(--dark-700) !important; - border-radius: 20px !important; - overflow: hidden; -} - -.progress { - background-color: #2a2d31 !important; - border-radius: 20px !important; -} - -.progress-bar { - border-radius: 0 !important; - transition: width .4s ease, background-color .4s ease; - background-color: var(--progress-default) !important; -} - -.progress-bar:first-child { - border-top-left-radius: 20px !important; - border-bottom-left-radius: 20px !important; -} - -.progress-bar:last-child { - border-top-right-radius: 20px !important; - border-bottom-right-radius: 20px !important; -} - -.progress-bar.bg-success { - background-color: var(--success) !important; -} - -.progress-bar.bg-danger { - background-color: var(--danger) !important; -} - -.progress-bar.bg-warning { - background-color: var(--warning) !important; - color: #fff !important; -} - -.progress-bar.bg-info { - background-color: #16425a !important; -} - -/* Label (parent must be position-relative) */ -.progress-label { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - pointer-events: none; - white-space: nowrap; -} - -/* ========================================================= - Cards & Tables -========================================================= */ -.card { - background-color: var(--dark-500) !important; - border: 1px solid var(--dark-450) !important; - color: var(--text) !important; -} - -.card-header, -.card-footer { - background-color: var(--dark-650) !important; - border-color: var(--dark-450) !important; - color: #f1f3f5 !important; -} - -.card .table { - border-radius: 0 !important; - overflow: hidden; - margin-bottom: 0; -} - -.table-responsive { - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} - -.table-responsive table { - min-width: 1000px; -} - -.table-dark.table-striped tbody tr:nth-of-type(odd) { - background-color: rgba(255, 255, 255, .025); -} - -.table-dark tbody tr:hover { - background-color: rgba(255, 255, 255, .04); -} - -.table-dark thead th { - background-color: var(--dark-800); - color: var(--text-dim); - font-weight: 500; - border-bottom: 1px solid var(--dark-450); -} - -.table-dark td, -.table-dark th { - padding: .6rem .75rem; - vertical-align: middle; - border-top: 1px solid var(--dark-450); -} - -/* ========================================================= - Navs & Pagination -========================================================= */ -.nav-tabs .nav-link.active, -.nav-tabs .nav-item.show .nav-link { - background-color: var(--dark-500) !important; - color: var(--text-strong) !important; - border-color: var(--dark-450) var(--dark-450) var(--dark-500) !important; -} - -.page-link { - color: #e0e0e0 !important; - background-color: var(--dark-750) !important; - border: 1px solid var(--dark-450) !important; -} - -.page-link:hover, -.page-link:focus { - color: #fff !important; - background-color: var(--dark-400) !important; - border-color: var(--dark-300) !important; -} - -.page-item.active .page-link { - color: #fff !important; - background-color: var(--primary) !important; - border-color: var(--primary-border) !important; -} - -.page-item.disabled .page-link { - color: var(--muted) !important; - background-color: var(--dark-550) !important; - border-color: var(--dark-450) !important; -} - -/* ========================================================= - Lists & Misc UI -========================================================= */ -.list-group-item { - display: flex; - align-items: center; - justify-content: space-between; -} - -.list-group-item:first-child, -.list-group-item:last-child { - border-radius: 0 !important; -} - -#items li.hide-purchased { - display: none !important; -} - -#mass-add-list li { - transition: background .2s; -} - -#mass-add-list li.active { - background: #198754 !important; - color: #fff !important; - border: 1px solid #000 !important; -} - -.quantity-input { - width: 60px; - background: var(--dark-400); - color: #fff; - border: 1px solid var(--dark-300); - border-radius: 4px; - text-align: center; -} - -.add-btn { - margin-left: 10px; -} - -.quantity-controls { - min-width: 120px; - display: flex; - align-items: center; - justify-content: flex-end; - gap: 4px; -} - -/* ========================================================= - Toasts & Info Bar -========================================================= */ -.toast { - animation: fadeInUp .5s ease; -} - -@keyframes fadeInUp { - from { - opacity: 0; - transform: translateY(20px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Base toast when not using text-bg-* */ -.toast:not([class*="text-bg-"]) { - background-color: var(--dark-500) !important; - color: #f1f1f1 !important; - border: 1px solid var(--dark-450) !important; - animation: fadeInUp .5s ease; -} - -.toast .toast-body { - color: inherit !important; -} - -.toast .btn-close { - filter: invert(1) grayscale(100%) brightness(200%); -} - -/* Typed toasts (override Bootstrap text-bg-*) */ -.toast.text-bg-primary { - background-color: var(--info) !important; - color: var(--info-text) !important; - border-color: var(--info-border) !important; -} - -.toast.text-bg-info { - background-color: var(--info) !important; - color: var(--info-text) !important; - border-color: var(--info-border) !important; -} - -.toast.text-bg-success { - background-color: var(--success) !important; - color: var(--success-text) !important; - border-color: var(--success-border) !important; -} - -.toast.text-bg-warning { - background-color: var(--warning) !important; - color: var(--warning-text) !important; - border-color: var(--warning-border) !important; -} - -.toast.text-bg-danger { - background-color: var(--danger) !important; - color: var(--danger-text) !important; - border-color: var(--danger-border) !important; -} - -.toast-body { - color: #fff !important; - font-weight: 500 !important; -} - -.info-bar-fixed { - width: 100%; - color: var(--text-strong); - background-color: var(--dark-700); - border-radius: 12px 12px 0 0; - text-align: center; - padding: 10px 10px; - font-size: .95rem; - box-sizing: border-box; - margin-top: 2rem; - box-shadow: 0 -1px 4px rgba(0, 0, 0, .25); -} - -@media (max-width: 768px) { - .info-bar-fixed { - position: static; - font-size: .85rem; - padding: 8px 4px; - border-radius: 0; - } -} - -/* ========================================================= - Modals (incl. fullscreen chart modal) -========================================================= */ -.modal-content { - background-color: var(--dark-470) !important; - color: #f1f1f1 !important; - border: 1px solid var(--dark-450) !important; -} - -.modal-header, -.modal-footer { - background-color: var(--dark-650) !important; - border-color: var(--dark-450) !important; -} - -/* Fullscreen chart modal */ -#chartFullscreenModal .modal-dialog { - max-width: 100vw; - width: 100vw; - margin: 0; -} - -#chartFullscreenModal .modal-content { - height: 100vh; - border-radius: 0; -} - -#chartFullscreenModal .modal-body { - display: flex; - flex: 1 1 auto; - padding: 0; - overflow: hidden; -} - -#chartFullscreenCanvas { - display: block; - width: 100%; - height: 100%; -} - -/* ========================================================= - Dropdown (TS already above) — active -========================================================= */ -.ts-dropdown .active { - background-color: var(--dark-300) !important; -} - -.list-group-item.bg-success { - background-color: var(--success) !important; - border-color: var(--success-border) !important; - color: var(--success-text) !important; - --bs-bg-opacity: 1 !important; -} - -.list-group-item.bg-warning { - background-color: var(--warning) !important; - border-color: var(--warning-border) !important; - color: var(--warning-text) !important; - --bs-bg-opacity: 1 !important; -} - -.btn-outline-light { - color: #f8f9fa !important; - border-color: #f8f9fa !important; - background-color: transparent !important; - /* brak białego tła domyślnie */ -} - -.btn-outline-light:hover, -.btn-outline-light:focus { - background-color: #6c757d !important; - /* szare, jak wcześniej */ - color: #fff !important; - border-color: #6c757d !important; -} - -.btn-outline-light:active, -.btn-outline-light.active, -.show>.btn-outline-light.dropdown-toggle { - background-color: #5a6268 !important; - /* ciemniejsze szare na active */ - color: #fff !important; - border-color: #545b62 !important; -} - -.btn-outline-info { - color: var(--info) !important; - border-color: var(--info) !important; - background-color: transparent !important; -} - -.btn-outline-info:hover, -.btn-outline-info:focus { - background-color: #1d4d8c !important; - border-color: #1d4d8c !important; - color: var(--info-text) !important; -} - -.btn-outline-info:active, -.btn-outline-info.active, -.show>.btn-outline-info.dropdown-toggle { - background-color: var(--info) !important; - border-color: var(--info-border) !important; - color: var(--info-text) !important; -} - -/* Tekstowe kolory */ -.text-success { - color: var(--success) !important; -} - -.text-warning { - color: var(--warning) !important; -} - -.text-info { - color: var(--info) !important; -} - -.text-danger { - color: var(--danger) !important; -} - -/* ========== Kolorowe wskaźniki pod pickerem ========== */ -.color-indicators .indicator { - display: grid; - grid-template-columns: auto 1fr auto; - align-items: center; - gap: .5rem; -} - -.color-indicators .bar { - height: 10px; - border-radius: 6px; - border: 1px solid rgba(255,255,255,.25); - box-shadow: inset 0 0 0 1px rgba(0,0,0,.25); -} - -/* ========== Swatch + zapisy heksowe ========== */ -.swatch { - width: 16px; - height: 16px; - border-radius: 50%; - display: inline-block; - border: 1px solid rgba(0,0,0,.15); -} - -.hex, -.hex-label { - font-variant-numeric: lining-nums; - letter-spacing: .2px; -} - -/* ========== OCR textarea ========== */ -.settings-ocr-textarea { - font: inherit; - line-height: 1.45; -} - -/* ========== Odznaka poziomu czułości ========== */ -.sens-badge { font-weight: 600; } -.sens-low { background: rgba(108,117,125,.25); color: #ced4da; } /* szary */ -.sens-mid { background: rgba(13,110,253,.25); color: #9ec5fe; } /* niebieski */ -.sens-high { background: rgba(220,53,69,.25); color: #f1aeb5; } /* czerwony */ - -/* ========================================================= - COMPACT: przyciski akcji na listach - - Desktop: standard Bootstrap - - <=576px: kompakt -========================================================= */ - -/* <=420px: tylko emoji */ -@media (max-width: 420px) { - .btn-group-compact .btn-text { - display: none !important; - } - - .btn-group-compact .btn { - padding: 0.22rem 0.45rem; - min-width: auto; - font-size: 0.9rem; - line-height: 1.1; - } -} - -/* 421–576px: lekko ciaśniej, ale tekst zostaje */ -@media (min-width: 421px) and (max-width: 576px) { - .btn-group-compact .btn { - padding: 0.25rem 0.5rem; - font-size: 0.82rem; - line-height: 1.1; - } - - .btn-group-compact .btn-text { - font-size: 0.75rem; - } -} - -/* Medium-narrow screens */ -@media (min-width: 421px) and (max-width: 576px) { - .btn-group-compact .btn { - padding: 0.24rem 0.45rem; /* ciaśniej */ - font-size: 0.82rem; - line-height: 1.1; - } - - .btn-group-compact .btn-text { - font-size: 0.75rem; - } -} - - -/* ================================================ - RESPONSIVE NAVBAR - ================================================ */ - -/* Wąskie ekrany (np. iPhone 11) */ -@media (max-width: 420px) { - - .navbar .container-fluid { - gap: 4px; - } - - .navbar-brand-compact { - font-size: 0.9rem !important; - margin-right: 0.25rem; - white-space: nowrap; - } - .navbar-brand-compact .navbar-brand-text { - font-size: 0.95em; - } - - .user-info-compact { - font-size: 0.72rem !important; - line-height: 0.9; - white-space: nowrap; - } - .user-info-compact .badge { - font-size: 0.68rem; - padding: 0.2rem 0.45rem; - } - - .nav-buttons-compact .nav-btn-text { - display: none !important; - } - .nav-buttons-compact { - gap: 0.35rem !important; - flex-wrap: nowrap; - } - .nav-buttons-compact .btn { - padding: 0.22rem 0.45rem; - min-width: auto; - line-height: 1.1; - } -} - - -/* Małe ekrany (np. 421-576px) */ -@media (min-width: 421px) and (max-width: 576px) { - .navbar .container-fluid { - gap: 8px; - } - - .navbar-brand-compact { - font-size: 1.25rem !important; - white-space: nowrap; - } - - .user-info-compact { - font-size: 0.8rem !important; - white-space: nowrap; - } - .user-info-compact .badge { - font-size: 0.75rem; - } - - .nav-buttons-compact { - flex-wrap: nowrap; - } - .nav-buttons-compact .btn { - font-size: 0.8rem; - padding: 0.25rem 0.45rem; - } - .nav-buttons-compact .nav-btn-text { - font-size: 0.75rem; - } -} - -@media (max-width: 420px) { - .user-label-desktop { display: none !important; } - .user-label-mobile { display: inline !important; } -} - -@media (min-width: 421px) { - .user-label-desktop { display: inline !important; } - .user-label-mobile { display: none !important; } -} - -.category-dot-pure { - display: inline-block !important; - width: 14px !important; - height: 14px !important; - border-radius: 50% !important; - border: 2px solid rgba(255, 255, 255, 0.8) !important; - background-clip: content-box, border-box !important; - vertical-align: middle !important; - margin-right: 3px !important; - opacity: 1 !important; - padding: 0 !important; - line-height: 1 !important; - font-size: 0 !important; - text-indent: -9999px !important; - overflow: hidden !important; - box-shadow: 0 1px 3px rgba(0,0,0,0.4) !important; -} - -.category-dot-pure::before, -.category-dot-pure::after { - content: none !important; -} - - -/* Hover efekt */ -.category-dot:hover { - transform: scale(1.3) !important; - box-shadow: 0 2px 6px rgba(0,0,0,0.4) !important; -} - - -.list-title { - white-space: nowrap !important; - overflow: hidden !important; - text-overflow: ellipsis !important; - max-width: 70% !important; - display: inline-block !important; -} - -/* Bardzo małe ekrany */ -@media (max-width: 420px) { - .list-title { - max-width: 60% !important; - } -} diff --git a/static/js/admin_settings.js b/static/js/admin_settings.js deleted file mode 100644 index 5066d0c..0000000 --- a/static/js/admin_settings.js +++ /dev/null @@ -1,130 +0,0 @@ -(function () { - const form = document.getElementById("settings-form"); - const resetAllBtn = document.getElementById("reset-all"); - - function ensureHiddenClear(input) { - let hidden = input.parentElement.querySelector(`input[type="hidden"][name="${input.name}"]`); - if (!hidden) { - hidden = document.createElement("input"); - hidden.type = "hidden"; - hidden.name = input.name; - hidden.value = ""; - input.parentElement.appendChild(hidden); - } - } - function removeHiddenClear(input) { - const hidden = input.parentElement.querySelector(`input[type="hidden"][name="${input.name}"]`); - if (hidden) hidden.remove(); - } - - function updatePreview(input) { - const card = input.closest(".col-12, .col-md-6, .col-lg-4"); - const hexAutoEl = card.querySelector(".hex-auto"); - const hexEffEl = card.querySelector(".hex-effective"); - const barAuto = card.querySelector('.bar[data-kind="auto"]'); - const barEff = card.querySelector('.bar[data-kind="effective"]'); - - const raw = (input.value || "").trim(); - const autoHex = hexAutoEl.textContent.trim(); - const effHex = (raw || autoHex).toUpperCase(); - - if (barEff) barEff.style.backgroundColor = effHex; - if (hexEffEl) hexEffEl.textContent = effHex; - - if (!raw) { - ensureHiddenClear(input); - input.disabled = true; - } else { - removeHiddenClear(input); - input.disabled = false; - } - } - - form.querySelectorAll(".use-default").forEach(btn => { - btn.addEventListener("click", () => { - const name = btn.getAttribute("data-target"); - const input = form.querySelector(`input[name="${name}"]`); - if (!input) return; - input.value = ""; - updatePreview(input); - }); - }); - - form.querySelectorAll(".reset-one").forEach(btn => { - btn.addEventListener("click", () => { - const name = btn.getAttribute("data-target"); - const input = form.querySelector(`input[name="${name}"]`); - if (!input) return; - input.value = ""; - updatePreview(input); - }); - }); - - resetAllBtn?.addEventListener("click", () => { - form.querySelectorAll('input[type="color"].category-color').forEach(input => { - input.value = ""; - updatePreview(input); - }); - }); - - form.querySelectorAll('input[type="color"].category-color').forEach(input => { - updatePreview(input); - input.addEventListener("input", () => updatePreview(input)); - input.addEventListener("change", () => updatePreview(input)); - }); - - form.addEventListener("submit", () => { - form.querySelectorAll('input[type="color"].category-color').forEach(updatePreview); - }); - - form.querySelectorAll(".use-default").forEach(btn => { - btn.addEventListener("click", () => { - const name = btn.getAttribute("data-target"); - const input = form.querySelector(`input[name="${name}"]`); - if (!input) return; - - const card = input.closest(".col-12, .col-md-6, .col-lg-4") || input.closest(".col-12"); - let autoHex = (input.dataset.auto || "").trim(); - if (!autoHex && card) { - autoHex = (card.querySelector(".hex-auto")?.textContent || "").trim(); - } - if (autoHex && !autoHex.startsWith("#")) autoHex = `#${autoHex}`; - - if (autoHex) { - input.disabled = false; - removeHiddenClear(input); - input.value = autoHex; - updatePreview(input); - } - }); - }); - - (function () { - const slider = document.getElementById("ocr_sensitivity"); - const badge = document.getElementById("ocr_sens_badge"); - const value = document.getElementById("ocr_sens_value"); - if (!slider || !badge || !value) return; - - function labelFor(v) { - v = Number(v); - if (v <= 3) return "Niski"; - if (v <= 7) return "Średni"; - return "Wysoki"; - } - function clsFor(v) { - v = Number(v); - if (v <= 3) return "sens-low"; - if (v <= 7) return "sens-mid"; - return "sens-high"; - } - function update() { - value.textContent = `(${slider.value})`; - badge.textContent = labelFor(slider.value); - badge.classList.remove("sens-low","sens-mid","sens-high"); - badge.classList.add(clsFor(slider.value)); - } - slider.addEventListener("input", update); - slider.addEventListener("change", update); - update(); - })(); -})(); diff --git a/static/js/lists_access.js b/static/js/lists_access.js deleted file mode 100644 index 0ce3376..0000000 --- a/static/js/lists_access.js +++ /dev/null @@ -1,254 +0,0 @@ -(function () { - const $ = (s, root = document) => root.querySelector(s); - const $$ = (s, root = document) => Array.from(root.querySelectorAll(s)); - - const filterInput = $('#listFilter'); - const filterCount = $('#filterCount'); - const selectAll = $('#selectAll'); - const bulkTokens = $('#bulkTokens'); - const bulkInput = $('#bulkUsersInput'); - const bulkBtn = $('#bulkAddBtn'); - const datalist = $('#userHints'); - - const unique = (arr) => Array.from(new Set(arr)); - const parseUserText = (txt) => unique((txt || '') - .split(/[\s,;]+/g) - .map(s => s.trim().replace(/^@/, '').toLowerCase()) - .filter(Boolean) - ); - - const selectedListIds = () => - $$('.row-check:checked').map(ch => ch.dataset.listId); - - const visibleRows = () => - $$('#listsTable tbody tr').filter(r => r.style.display !== 'none'); - - // ===== Podpowiedzi (datalist) z DOM-u ===== - (function buildHints() { - const names = new Set(); - $$('.owner-username').forEach(el => names.add(el.dataset.username)); - $$('.permitted-username').forEach(el => names.add(el.dataset.username)); - // również tokeny już wyrenderowane - $$('.token[data-username]').forEach(el => names.add(el.dataset.username)); - datalist.innerHTML = Array.from(names) - .sort((a, b) => a.localeCompare(b)) - .map(u => ``) - .join(''); - })(); - - // ===== Live filter ===== - function applyFilter() { - const q = (filterInput?.value || '').trim().toLowerCase(); - let shown = 0; - $$('#listsTable tbody tr').forEach(tr => { - const hay = `${tr.dataset.id || ''} ${tr.dataset.title || ''} ${tr.dataset.owner || ''}`; - const ok = !q || hay.includes(q); - tr.style.display = ok ? '' : 'none'; - if (ok) shown++; - }); - if (filterCount) filterCount.textContent = shown ? `Widoczne: ${shown}` : 'Brak wyników'; - } - filterInput?.addEventListener('input', applyFilter); - applyFilter(); - - // ===== Select all ===== - selectAll?.addEventListener('change', () => { - visibleRows().forEach(tr => { - const cb = tr.querySelector('.row-check'); - if (cb) cb.checked = selectAll.checked; - }); - }); - - // ===== Copy share URL ===== - $$('.copy-share').forEach(btn => { - btn.addEventListener('click', async () => { - const url = btn.dataset.url; - try { - await navigator.clipboard.writeText(url); - showToast('Skopiowano link udostępnienia', 'success'); - } catch { - const ta = Object.assign(document.createElement('textarea'), { value: url }); - document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); - showToast('Skopiowano link udostępnienia', 'success'); - } - }); - }); - - // ===== Tokenized users field (global – belka) ===== - function addGlobalToken(username) { - if (!username) return; - const exists = $(`.user-token[data-user="${username}"]`, bulkTokens); - if (exists) return; - const token = document.createElement('span'); - token.className = 'badge rounded-pill text-bg-secondary user-token'; - token.dataset.user = username; - token.innerHTML = `@${username} `; - token.querySelector('button').addEventListener('click', () => token.remove()); - bulkTokens.appendChild(token); - } - bulkInput?.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - parseUserText(bulkInput.value).forEach(addGlobalToken); - bulkInput.value = ''; - } - }); - bulkInput?.addEventListener('change', () => { - parseUserText(bulkInput.value).forEach(addGlobalToken); - bulkInput.value = ''; - }); - - // ===== Bulk grant (z belki) ===== - async function bulkGrant() { - const lists = selectedListIds(); - const users = $$('.user-token', bulkTokens).map(t => t.dataset.user); - - if (!lists.length) { showToast('Zaznacz przynajmniej jedną listę', 'warning'); return; } - if (!users.length) { showToast('Dodaj przynajmniej jednego użytkownika', 'warning'); return; } - - bulkBtn.disabled = true; - bulkBtn.textContent = 'Pracuję…'; - - const url = location.pathname + location.search; - let ok = 0, fail = 0; - - for (const lid of lists) { - for (const u of users) { - const form = new FormData(); - form.set('action', 'grant'); - form.set('target_list_id', lid); - form.set('grant_username', u); - - try { - const res = await fetch(url, { method: 'POST', body: form, credentials: 'same-origin' }); - if (res.ok) ok++; else fail++; - } catch { fail++; } - } - } - - bulkBtn.disabled = false; - bulkBtn.textContent = '➕ Nadaj dostęp'; - - showToast(`Gotowe. Sukcesy: ${ok}${fail ? `, błędy: ${fail}` : ''}`, fail ? 'danger' : 'success'); - location.reload(); - } - bulkBtn?.addEventListener('click', bulkGrant); - - // ===== Per-row "Access editor" (tokeny + dodawanie) ===== - async function postAction(params) { - const url = location.pathname + location.search; - const form = new FormData(); - for (const [k, v] of Object.entries(params)) form.set(k, v); - const res = await fetch(url, { method: 'POST', body: form, credentials: 'same-origin' }); - return res.ok; - } - - // Delegacja zdarzeń: kliknięcie tokenu = revoke - document.addEventListener('click', async (e) => { - const btn = e.target.closest('.access-editor .token'); - if (!btn) return; - - const wrapper = btn.closest('.access-editor'); - const listId = wrapper?.dataset.listId; - const userId = btn.dataset.userId; - const username = btn.dataset.username; - - if (!listId || !userId) return; - - btn.disabled = true; - btn.classList.add('disabled'); - - const ok = await postAction({ - action: 'revoke', - target_list_id: listId, - revoke_user_id: userId - }); - - if (ok) { - btn.remove(); - const tokens = $$('.token', wrapper); - if (!tokens.length) { - // pokaż info „brak uprawnień” - let empty = $('.no-perms', wrapper); - if (!empty) { - empty = document.createElement('span'); - empty.className = 'text-warning small no-perms'; - empty.textContent = 'Brak dodanych uprawnień.'; - $('.tokens', wrapper).appendChild(empty); - } - } - showToast(`Odebrano dostęp: @${username}`, 'success'); - } else { - btn.disabled = false; - btn.classList.remove('disabled'); - showToast(`Nie udało się odebrać dostępu @${username}`, 'danger'); - } - }); - - // Dodawanie wielu użytkowników per-row - document.addEventListener('click', async (e) => { - const addBtn = e.target.closest('.access-editor .access-add'); - if (!addBtn) return; - - const wrapper = addBtn.closest('.access-editor'); - const listId = wrapper?.dataset.listId; - const input = $('.access-input', wrapper); - if (!listId || !input) return; - - const users = parseUserText(input.value); - if (!users.length) { showToast('Podaj co najmniej jednego użytkownika', 'warning'); return; } - - addBtn.disabled = true; - addBtn.textContent = 'Dodaję…'; - - let okCount = 0, failCount = 0; - - for (const u of users) { - const ok = await postAction({ - action: 'grant', - target_list_id: listId, - grant_username: u - }); - if (ok) { - okCount++; - // usuń info „brak uprawnień” - $('.no-perms', wrapper)?.remove(); - // dodaj token jeśli nie ma - const exists = $(`.token[data-username="${u}"]`, wrapper); - if (!exists) { - const token = document.createElement('button'); - token.type = 'button'; - token.className = 'btn btn-sm btn-outline-secondary rounded-pill token'; - token.dataset.username = u; - token.dataset.userId = ''; // nie znamy ID — token nadal klikany, ale bez revoke po ID - token.title = '@' + u; - token.innerHTML = `@${u} `; - $('.tokens', wrapper).appendChild(token); - } - } else { - failCount++; - } - } - - addBtn.disabled = false; - addBtn.textContent = '➕ Dodaj'; - input.value = ''; - - if (okCount) showToast(`Dodano dostęp: ${okCount} użytk.`, 'success'); - if (failCount) showToast(`Błędy przy dodawaniu: ${failCount}`, 'danger'); - - // Odśwież, by mieć poprawne user_id w tokenach (backend wie lepiej) - if (okCount) location.reload(); - }); - - // Enter w polu per-row = zadziałaj jak przycisk - document.addEventListener('keydown', (e) => { - const inp = e.target.closest('.access-editor .access-input'); - if (inp && e.key === 'Enter') { - e.preventDefault(); - const btn = inp.closest('.access-editor')?.querySelector('.access-add'); - btn?.click(); - } - }); - -})(); diff --git a/static/js/toggle_button.js b/static/js/toggle_button.js deleted file mode 100644 index 57b6386..0000000 --- a/static/js/toggle_button.js +++ /dev/null @@ -1,27 +0,0 @@ -document.addEventListener("DOMContentLoaded", function () { - const toggleBtn = document.getElementById("tempToggle"); - const hiddenInput = document.getElementById("temporaryHidden"); - const tooltip = new bootstrap.Tooltip(toggleBtn); - - function updateToggle(isActive) { - if (isActive) { - toggleBtn.classList.remove("btn-outline-secondary"); - toggleBtn.classList.add("btn-success"); - toggleBtn.textContent = "Tymczasowa ✔️"; - } else { - toggleBtn.classList.remove("btn-success"); - toggleBtn.classList.add("btn-outline-secondary"); - toggleBtn.textContent = "Tymczasowa"; - } - } - - let active = toggleBtn.getAttribute("data-active") === "1"; - updateToggle(active); - - toggleBtn.addEventListener("click", function () { - active = !active; - toggleBtn.setAttribute("data-active", active ? "1" : "0"); - hiddenInput.value = active ? "1" : "0"; - updateToggle(active); - }); -}); diff --git a/templates/base.html b/templates/base.html deleted file mode 100644 index 0b8f0b2..0000000 --- a/templates/base.html +++ /dev/null @@ -1,193 +0,0 @@ - - - - - - {% block title %}Live Lista Zakupów{% endblock %} - - - {# --- Bootstrap i główny css zawsze --- #} - - - - {# --- Style CSS ładowane tylko dla niezablokowanych --- #} - {% set exclude_paths = ['/system-auth'] %} - {% if (exclude_paths | select("in", request.path) | list | length == 0) - and has_authorized_cookie - and not is_blocked %} - - - {% endif %} - - {# --- Cropper CSS tylko dla wybranych podstron --- #} - {% set substrings_cropper = ['/admin/receipts', '/edit_my_list'] %} - {% if substrings_cropper | select("in", request.path) | list | length > 0 %} - - {% endif %} - - {# --- Tom Select CSS tylko dla wybranych podstron --- #} - {% set substrings_tomselect = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %} - {% if substrings_tomselect | select("in", request.path) | list | length > 0 %} - - {% endif %} - - - - - - -
- {% block content %}{% endblock %} -
- -
- - - - - - {% if not is_blocked %} - - - {% if request.endpoint != 'system_auth' %} - - - - - - - {% endif %} - - - - - {% set substrings = ['/admin/receipts', '/edit_my_list'] %} - {% if substrings | select("in", request.path) | list | length > 0 %} - - {% endif %} - - {% set substrings = ['/edit_my_list', '/admin/edit_list', '/admin/edit_categories'] %} - {% if substrings | select("in", request.path) | list | length > 0 %} - - {% endif %} - - {% endif %} - - {% block scripts %}{% endblock %} - - diff --git a/templates/list.html b/templates/list.html deleted file mode 100644 index f721867..0000000 --- a/templates/list.html +++ /dev/null @@ -1,346 +0,0 @@ -{% extends 'base.html' %} -{% block title %}Lista: {{ list.title }}{% endblock %} -{% block content %} - -
-

- Lista: {{ list.title }} - {% if list.is_archived %} - (Archiwalna) - {% endif %} - - {% if list.category_badges %} - {% for cat in list.category_badges %} - - {{ cat.name }} - - {% endfor %} - - - {% else %} - - - {% endif %} -

-
- - - ✅ Otwórz tryb zakupowy / odznaczania produktów - - -
-
-
- - {% if list.is_public %}🔗 Udostępnij link (lista publiczna){% else %}🔗 Udostępnij link (widoczna przez link / - uprawnienia){% endif %} - - - {{ request.url_root }}share/{{ list.share_token }} - -
-
- - - - - - -
-
-
- - -
- 📊 Postęp listy — - {{ purchased_count }}/ - {{ total_count }} kupionych - ({{ percent|int }}%) -
- -
-
-
-
- -
- -{% if total_expense > 0 %} -
- 💸 Łącznie wydano: {{ '%.2f'|format(total_expense) }} PLN -
-{% else %} -
- 💸 Łącznie wydano: 0.00 PLN -
-{% endif %} - -
- -
- - -
-
- -
    - {% for item in items %} -
  • - -
    - - - {{ item.name }} - {% if item.quantity and item.quantity > 1 %} - x{{ item.quantity }} - {% endif %} - - -
    - {% set info_parts = [] %} - {% if item.note %}{% set _ = info_parts.append('[ ' ~ item.note ~ ' ]') - %}{% endif %} - {% if item.not_purchased_reason %}{% set _ = info_parts.append('[ Powód: ' ~ - item.not_purchased_reason ~ ' ]') %}{% endif %} - {% if item.added_by_display %}{% set _ = info_parts.append('[ Dodał/a: ' ~ - item.added_by_display ~ ' ]') %}{% endif %} - {% if info_parts %} -
    - {{ info_parts | join(' ') | safe }} -
    - {% endif %} -
    -
    - -
    - {% if not is_share %} - - - {% endif %} - - {% if item.not_purchased %} - - {% elif not item.not_purchased %} - - {% endif %} -
    -
  • - {% else %} -
  • Brak produktów w tej - liście.
  • - {% endfor %} -
- -{% if not list.is_archived %} -
-
- -
-
-
- - - -
-
-
-{% endif %} - -{% set receipt_pattern = 'list_' ~ list.id %} -
-
📸 Paragony dodane do tej listy
- -
- {% if receipts %} - {% for r in receipts %} -
- - - -
- {% endfor %} - {% else %} - - {% endif %} -
- - - - - - - - - - - -{% block scripts %} - - - - - - - - -{% endblock %} - -{% endblock %} \ No newline at end of file diff --git a/templates/list_share.html b/templates/list_share.html deleted file mode 100644 index e1734e9..0000000 --- a/templates/list_share.html +++ /dev/null @@ -1,260 +0,0 @@ -{% extends 'base.html' %} -{% block title %}Lista: {{ list.title }}{% endblock %} -{% block content %} - -

- 🛍️ {{ list.title }} - - {% if list.is_archived %} - (Archiwalna) - {% endif %} - - {% if total_expense > 0 %} - - 💸 {{ '%.2f'|format(total_expense) }} PLN - - {% else %} - - {% endif %} - - {% if list.category_badges %} - {% for cat in list.category_badges %} - - {{ cat.name }} - - {% endfor %} - {% endif %} - -

- - -
- - -
- -
    - {% for item in items %} - -
  • - -
    - - - - - {{ item.name }} - {% if item.quantity and item.quantity > 1 %} - x{{ item.quantity }} - {% endif %} - - -
    - {% set info_parts = [] %} - {% if item.note %} - {% set _ = info_parts.append('[ ' ~ item.note ~ ' ]') %} - {% endif %} - {% if item.not_purchased_reason %} - {% set _ = info_parts.append('[ Powód: ' ~ item.not_purchased_reason ~ ' - ]') %} - {% endif %} - {% if item.added_by_display %} - {% set _ = info_parts.append('[ Dodał/a: ' ~ item.added_by_display ~ ' ]') - %} - {% endif %} - - {% if info_parts %} -
    - {{ info_parts | join(' ') | safe }} -
    - {% endif %} -
    -
    - -
    - {% if item.not_purchased %} - - {% else %} - - {% endif %} - - -
    - -
  • - {% else %} -
  • - Brak produktów w tej liście. -
  • - {% endfor %} -
- -{% if not list.is_archived %} -
- - - -
-{% endif %} - -{% if not list.is_archived %} -
-
💰 Dodaj wydatek
-
- - -
{% endif %} -

💸 Łącznie wydano: {{ '%.2f'|format(total_expense) }} PLN

- - - -
- {% set receipt_pattern = 'list_' ~ list.id %} - -
- -
🔍 Analiza paragonów (OCR)
-

System spróbuje automatycznie rozpoznać kwoty z dodanych paragonów.
- Dokonaj korekty jeśli źle rozpozna kwote i kliknij w "Dodaj" aby dodać wydatek. -

- - {% if current_user.is_authenticated %} - - {% else %} -
- ⚠️ Tylko zalogowani użytkownicy mogą zlecać analizę OCR. -
- {% endif %} -
-
- -
📸 Paragony dodane do tej listy
-
- {% if receipts %} - {% for r in receipts %} -
- - - -
- {% endfor %} - {% else %} - - {% endif %} -
- - {% if not list.is_archived and current_user.is_authenticated %} -
-
📤 Dodaj zdjęcie paragonu
-
- - - - - - - - - - - - - - -
-
- {% endif %} -
- - - - -{% block scripts %} - - - - - - - - -{% endblock %} - -{% endblock %} \ No newline at end of file diff --git a/templates/main.html b/templates/main.html deleted file mode 100644 index a5261e2..0000000 --- a/templates/main.html +++ /dev/null @@ -1,321 +0,0 @@ -{% extends 'base.html' %} -{% block title %}Twoje listy zakupów{% endblock %} -{% block content %} - -{% if not current_user.is_authenticated %} - -{% endif %} - -{% if current_user.is_authenticated %} -
-

Stwórz nową listę

-
- -
-
-
-
- - - -
- -
-
-
-{% endif %} - -{% set month_names = ["styczeń","luty","marzec","kwiecień","maj","czerwiec","lipiec","sierpień","wrzesień","październik","listopad","grudzień"] %} - -
- - -
- -
- -
- -{% if current_user.is_authenticated %} -

- Twoje listy - -

- -{% if user_lists %} - -{% else %} -

Nie utworzono żadnej listy

-{% endif %} -{% endif %} - -

- {% if current_user.is_authenticated %} - Udostępnione i publiczne listy innych użytkowników - {% else %} - Publiczne listy innych użytkowników - {% endif %} -

- -{% set lists_to_show = accessible_lists %} -{% if lists_to_show %} -
    - {% for l in lists_to_show %} - {% set purchased_count = l.purchased_count %} - {% set total_count = l.total_count %} - {% set percent = (purchased_count / total_count * 100) if total_count > 0 else 0 %} - -
  • -
    - - - - {{ l.title }} (Autor: {{ l.owner.username if l.owner else '—' }}) - - - - - {{ l.title }} - - - {% for cat in l.category_badges %} - - - {{ cat.name }} - - - - - {% endfor %} - - - - - -
    - -
    -
    - - {% set not_purchased_count = l.not_purchased_count if l.total_count else 0 %} -
    - -
    - - - Produkty: {{ purchased_count }}/{{ total_count }} ({{ percent|round(0) }}%) - {% if l.total_expense > 0 %} — 💸 {{ '%.2f'|format(l.total_expense) }} PLN{% endif %} - -
    -
  • - {% endfor %} -
-{% else %} -

Brak list do wyświetlenia

-{% endif %} - - - - - -{% block scripts %} - - -{% endblock %} - -{% endblock %} \ No newline at end of file