From 3a57f2f1d7cf2010f4c019551f0b14014e158f88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 14 Mar 2026 23:17:05 +0100 Subject: [PATCH] refactor next push --- API_OPIS.txt | 33 + CLI_OPIS.txt | 30 + README.md | 8 +- REFACTOR_NOTES.md | 30 - shopping_app/app_setup.py | 6 + shopping_app/helpers.py | 377 ++++ shopping_app/models.py | 61 + shopping_app/routes_admin.py | 233 +- shopping_app/routes_main.py | 119 +- shopping_app/routes_secondary.py | 245 ++- shopping_app/sockets.py | 107 +- shopping_app/static/css/style.css | 1917 ++++++++++++++++- shopping_app/static/js/access_users.js | 34 +- shopping_app/static/js/app_ui.js | 227 ++ shopping_app/static/js/lists_access.js | 274 +-- shopping_app/static/js/toggle_button.js | 10 +- shopping_app/templates/admin/_nav.html | 18 + shopping_app/templates/admin/admin_panel.html | 32 +- shopping_app/templates/admin/api_tokens.html | 161 ++ .../templates/admin/edit_categories.html | 10 +- shopping_app/templates/admin/edit_list.html | 12 +- .../templates/admin/list_products.html | 6 +- .../templates/admin/lists_access.html | 66 +- shopping_app/templates/admin/receipts.html | 2 + shopping_app/templates/admin/settings.html | 4 +- shopping_app/templates/admin/templates.html | 64 + .../templates/admin/user_management.html | 4 +- shopping_app/templates/base.html | 30 +- shopping_app/templates/edit_my_list.html | 20 +- shopping_app/templates/expenses.html | 2 +- shopping_app/templates/list.html | 117 +- shopping_app/templates/list_share.html | 86 +- shopping_app/templates/main.html | 188 +- shopping_app/templates/my_templates.html | 58 + shopping_app/uploads | 1 - shopping_app/web.py | 8 +- tests/test_refactor.py | 70 + 37 files changed, 4012 insertions(+), 658 deletions(-) create mode 100644 API_OPIS.txt create mode 100644 CLI_OPIS.txt delete mode 100644 REFACTOR_NOTES.md create mode 100644 shopping_app/static/js/app_ui.js create mode 100644 shopping_app/templates/admin/_nav.html create mode 100644 shopping_app/templates/admin/api_tokens.html create mode 100644 shopping_app/templates/admin/templates.html create mode 100644 shopping_app/templates/my_templates.html delete mode 120000 shopping_app/uploads create mode 100644 tests/test_refactor.py 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/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/REFACTOR_NOTES.md b/REFACTOR_NOTES.md deleted file mode 100644 index 9435149..0000000 --- a/REFACTOR_NOTES.md +++ /dev/null @@ -1,30 +0,0 @@ -# Refactor / UX refresh - -## Co zostało zrobione - -### Backend Python -- `app.py` został sprowadzony do lekkiego entrypointu. -- Backend został rozbity na moduły w katalogu `shopping_app/`: - - `app_setup.py` — inicjalizacja Flask / SQLAlchemy / SocketIO / Session / config - - `models.py` — modele bazy danych - - `helpers.py` — funkcje pomocnicze, uploady, OCR, uprawnienia, filtry pomocnicze - - `web.py` — context processory, filtry, błędy, favicon, hooki - - `routes_main.py` — główne trasy użytkownika - - `routes_secondary.py` — wydatki, udostępnianie, paragony usera - - `routes_admin.py` — panel admina i trasy administracyjne - - `sockets.py` — Socket.IO i debug socketów - - `deps.py` — wspólne importy -- Endpointy i nazwy widoków zostały zachowane. -- Docker / compose / deploy / varnish nie były ruszane. - -### Frontend / UX / wygląd -- Przebudowany globalny shell aplikacji w `templates/base.html`. -- Odświeżony, spójny dark UI z mocniejszym mobile-first feel. -- Zachowane istniejące pliki JS i ich selektory. -- Główne zmiany wizualne są w `static/css/style.css` jako nowa warstwa override na końcu pliku. -- Drobnie dopracowane teksty i nagłówki w kluczowych widokach. - -## Ważne -- Rozbicie backendu było celowo wykonane bez zmiany zachowania logiki biznesowej. -- Statyczne assety, Socket.IO i routing powinny działać po staremu, ale kod jest łatwiejszy do dalszej pracy. -- Przy lokalnym starcie bez Dockera pamiętaj o istnieniu katalogów `db/` i `uploads/`. diff --git a/shopping_app/app_setup.py b/shopping_app/app_setup.py index bda174c..b141970 100644 --- a/shopping_app/app_setup.py +++ b/shopping_app/app_setup.py @@ -67,6 +67,12 @@ app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MIN 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 diff --git a/shopping_app/helpers.py b/shopping_app/helpers.py index 2bde3e3..fd90bb9 100644 --- a/shopping_app/helpers.py +++ b/shopping_app/helpers.py @@ -128,6 +128,383 @@ def check_password(stored_hash, password_input): 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) diff --git a/shopping_app/models.py b/shopping_app/models.py index 3d84f8d..04ea605 100644 --- a/shopping_app/models.py +++ b/shopping_app/models.py @@ -145,6 +145,67 @@ class AppSetting(db.Model): 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( diff --git a/shopping_app/routes_admin.py b/shopping_app/routes_admin.py index eaa08b9..421a8f2 100644 --- a/shopping_app/routes_admin.py +++ b/shopping_app/routes_admin.py @@ -173,6 +173,7 @@ def admin_panel(): ) 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) @@ -1033,6 +1034,18 @@ def add_suggestion(): 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 @@ -1065,21 +1078,32 @@ def admin_lists_access(list_id=None): 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) + 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() + 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) @@ -1088,36 +1112,29 @@ def admin_lists_access(list_id=None): .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: + 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() + 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) - 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( @@ -1135,6 +1152,7 @@ def admin_lists_access(list_id=None): 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( @@ -1146,6 +1164,7 @@ def admin_lists_access(list_id=None): total_pages=pagination.pages if pagination else 1, query_string=query_string, list_id=list_id, + all_usernames=all_usernames, ) @@ -1170,6 +1189,100 @@ def healthcheck(): 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 @@ -1245,3 +1358,79 @@ def robots_txt(): 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 index 8b4fda4..4f9fb88 100644 --- a/shopping_app/routes_main.py +++ b/shopping_app/routes_main.py @@ -163,6 +163,9 @@ def main_page(): l.total_expense = 0 l.category_badges = [] + 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, @@ -173,6 +176,8 @@ def main_page(): timedelta=timedelta, month_options=month_options, selected_month=month_str, + expiring_lists=expiring_lists, + templates=templates, ) @@ -377,6 +382,14 @@ def edit_my_list(list_id): .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, @@ -384,6 +397,7 @@ def edit_my_list(list_id): categories=categories, selected_categories=selected_categories_ids, permitted_users=permitted_users, + all_usernames=all_usernames, ) @@ -412,16 +426,18 @@ def edit_my_list_suggestions(list_id: int): .subquery() ) - query = db.session.query( - User.username, subq.c.grant_count, subq.c.last_grant_id - ).join(subq, subq.c.uid == User.id) + 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( - subq.c.grant_count.desc(), - subq.c.last_grant_id.desc(), + func.coalesce(subq.c.grant_count, 0).desc(), + func.coalesce(subq.c.last_grant_id, 0).desc(), func.lower(User.username).asc(), ) .limit(20) @@ -523,6 +539,8 @@ def create_list(): ) 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)) @@ -595,6 +613,21 @@ def view_list(list_id): .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, @@ -611,6 +644,9 @@ def view_list(list_id): 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, ) @@ -745,3 +781,76 @@ def list_settings(list_id): 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 index 0b7cbb7..0b52be2 100644 --- a/shopping_app/routes_secondary.py +++ b/shopping_app/routes_secondary.py @@ -163,6 +163,214 @@ def expenses_data(): 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/") @@ -172,21 +380,19 @@ def shared_list(token=None, list_id=None): 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 shopping_list.expires_at - and shopping_list.expires_at <= now - ): + if shopping_list.is_temporary and expires_at and 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 + # 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( @@ -202,6 +408,29 @@ def shared_list(token=None, list_id=None): 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) diff --git a/shopping_app/sockets.py b/shopping_app/sockets.py index c5cbf05..95468a7 100644 --- a/shopping_app/sockets.py +++ b/shopping_app/sockets.py @@ -1,3 +1,4 @@ +import click from .deps import * from .app_setup import * from .models import * @@ -167,6 +168,7 @@ def handle_delete_item(data): 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)) @@ -306,6 +308,7 @@ def handle_add_item(data): 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( @@ -342,7 +345,7 @@ def handle_check_item(data): if item: item.purchased = True item.purchased_at = datetime.now(UTC) - + 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) @@ -366,6 +369,7 @@ def handle_uncheck_item(data): 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) @@ -447,6 +451,7 @@ def handle_add_expense(data): ) 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 = ( @@ -465,6 +470,7 @@ def handle_mark_not_purchased(data): if item: item.not_purchased = True item.not_purchased_reason = reason + 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", @@ -482,6 +488,7 @@ def handle_unmark_not_purchased(data): 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)) @@ -511,3 +518,101 @@ def create_db(): 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/static/css/style.css b/shopping_app/static/css/style.css index 04bc1cd..918ee55 100644 --- a/shopping_app/static/css/style.css +++ b/shopping_app/static/css/style.css @@ -990,21 +990,22 @@ td select.tom-dark { } .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; + 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, @@ -1825,3 +1826,1889 @@ input[type="checkbox"].form-check-input { padding-bottom: 0.8rem; } } + +/* === 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: scale(.92); + transform-origin: 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: flex-start; + 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; + flex-wrap: wrap; + gap: 0.15rem; + min-width: 0; +} + +.endpoint-main_page .list-main-actions { + flex: 0 0 auto; + align-self: flex-start; +} + +@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; + flex-wrap: wrap; + gap: .15rem; +} + +.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: flex-start; + } + + .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: flex-start; + 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; + word-break: break-word; +} + +.shopping-item-text .info-line { + flex-basis: 100%; + margin-top: .1rem; +} + +.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; +} + +@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: 1 1 auto; + min-width: 0; + } + + .shopping-product-input-group > .shopping-qty-input { + flex: 0 0 3.8rem; + max-width: 3.8rem; + } + + .shopping-expense-input-group > .shopping-compact-submit, + .shopping-product-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; + } +} diff --git a/shopping_app/static/js/access_users.js b/shopping_app/static/js/access_users.js index 8a5e905..2daf4d3 100644 --- a/shopping_app/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/app_ui.js b/shopping_app/static/js/app_ui.js new file mode 100644 index 0000000..61ad68a --- /dev/null +++ b/shopping_app/static/js/app_ui.js @@ -0,0 +1,227 @@ +document.addEventListener('DOMContentLoaded', function () { + enhancePasswordFields(); + enhanceSearchableTables(); + wireCopyButtons(); + wireUnsavedWarnings(); + enhanceMobileTables(); + wireAdminNavToggle(); + initResponsiveCategoryBadges(); +}); + +function enhancePasswordFields() { + document.querySelectorAll('input[type="password"]').forEach(function (input) { + if (input.dataset.uiPasswordReady === '1') return; + if (input.closest('[data-ui-skip-toggle="true"]')) return; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-outline-light ui-password-toggle'; + btn.setAttribute('aria-label', 'Pokaż lub ukryj hasło'); + btn.textContent = '👁'; + + btn.addEventListener('click', function () { + const visible = input.type === 'text'; + input.type = visible ? 'password' : 'text'; + btn.textContent = visible ? '👁' : '🙈'; + btn.classList.toggle('is-active', !visible); + }); + + if (input.parentElement && input.parentElement.classList.contains('input-group')) { + input.parentElement.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'; + }); +} + +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/shopping_app/static/js/lists_access.js b/shopping_app/static/js/lists_access.js index 0ce3376..bf42bd9 100644 --- a/shopping_app/static/js/lists_access.js +++ b/shopping_app/static/js/lists_access.js @@ -1,254 +1,22 @@ (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(); - } - }); - -})(); +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/shopping_app/static/js/toggle_button.js b/shopping_app/static/js/toggle_button.js index 57b6386..8d528a1 100644 --- a/shopping_app/static/js/toggle_button.js +++ b/shopping_app/static/js/toggle_button.js @@ -1,7 +1,11 @@ document.addEventListener("DOMContentLoaded", function () { const toggleBtn = document.getElementById("tempToggle"); const hiddenInput = document.getElementById("temporaryHidden"); - const tooltip = new bootstrap.Tooltip(toggleBtn); + if (!toggleBtn || !hiddenInput) return; + + if (typeof bootstrap !== "undefined") { + new bootstrap.Tooltip(toggleBtn); + } function updateToggle(isActive) { if (isActive) { @@ -13,12 +17,14 @@ document.addEventListener("DOMContentLoaded", function () { toggleBtn.classList.add("btn-outline-secondary"); toggleBtn.textContent = "Tymczasowa"; } + toggleBtn.setAttribute("aria-pressed", isActive ? "true" : "false"); } let active = toggleBtn.getAttribute("data-active") === "1"; updateToggle(active); - toggleBtn.addEventListener("click", function () { + toggleBtn.addEventListener("click", function (event) { + event.preventDefault(); active = !active; toggleBtn.setAttribute("data-active", active ? "1" : "0"); hiddenInput.value = active ? "1" : "0"; 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/shopping_app/templates/admin/admin_panel.html b/shopping_app/templates/admin/admin_panel.html index 7df7c49..f735f5f 100644 --- a/shopping_app/templates/admin/admin_panel.html +++ b/shopping_app/templates/admin/admin_panel.html @@ -2,7 +2,7 @@ {% block title %}Panel administratora{% endblock %} {% block content %} -
+

⚙️ Panel administratora

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

@@ -10,18 +10,8 @@ ← Powrót do strony głównej
- +{% include 'admin/_nav.html' %} +
@@ -161,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 #} @@ -246,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 %} @@ -298,13 +288,9 @@ {% endif %} -
- ✏️ - +
+ ✏️ +
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/shopping_app/templates/admin/edit_categories.html b/shopping_app/templates/admin/edit_categories.html index 365ae02..c9dfad7 100644 --- a/shopping_app/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 #} - +
@@ -147,5 +148,4 @@ {% block scripts %} - {% endblock %} \ No newline at end of file diff --git a/shopping_app/templates/admin/edit_list.html b/shopping_app/templates/admin/edit_list.html index ca18c13..7845906 100644 --- a/shopping_app/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 @@
-
-
+
+
-
+
-
+
diff --git a/shopping_app/templates/admin/list_products.html b/shopping_app/templates/admin/list_products.html index 0be9c13..b669026 100644 --- a/shopping_app/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
+
diff --git a/shopping_app/templates/admin/lists_access.html b/shopping_app/templates/admin/lists_access.html index 29eaeb8..3dd2198 100644 --- a/shopping_app/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/shopping_app/templates/admin/receipts.html b/shopping_app/templates/admin/receipts.html index 3c7f2b8..1b4f5e2 100644 --- a/shopping_app/templates/admin/receipts.html +++ b/shopping_app/templates/admin/receipts.html @@ -46,6 +46,8 @@
+{% include 'admin/_nav.html' %} +
diff --git a/shopping_app/templates/admin/settings.html b/shopping_app/templates/admin/settings.html index eed9f43..1e84adf 100644 --- a/shopping_app/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ść 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/shopping_app/templates/admin/user_management.html b/shopping_app/templates/admin/user_management.html index f4c63b3..fd72b8a 100644 --- a/shopping_app/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' %} +
@@ -34,7 +36,7 @@
- +
diff --git a/shopping_app/templates/base.html b/shopping_app/templates/base.html index 08b6cac..4669a3f 100644 --- a/shopping_app/templates/base.html +++ b/shopping_app/templates/base.html @@ -28,7 +28,7 @@ {% endif %} - +
@@ -58,18 +58,25 @@ {% endif %} -
- {% if not is_blocked and request.endpoint and request.endpoint != 'system_auth' %} - {% if current_user.is_authenticated %} - {% if current_user.is_admin %} - ⚙️ Panel + + +
@@ -133,6 +140,7 @@ {% endif %} +
ID