119 Commits
Author SHA1 Message Date
Mateusz Gruszczyński 2be058f3e5 wip 1 2026-04-01 06:39:18 +02:00
Mateusz Gruszczyński 2b9a305db7 clean 2026-03-31 18:18:04 +02:00
Mateusz Gruszczyński bd83211f38 fix 2026-03-31 18:16:49 +02:00
Mateusz Gruszczyński 796b73fa47 new sharehub 2026-03-31 15:42:37 +02:00
gru e8031858cd Merge pull request 'inputs_fix' (#13) from inputs_fix into master
Reviewed-on: #13
2026-03-31 15:21:23 +02:00
Mateusz Gruszczyński 80ed950aed fix logiczny 2026-03-31 15:16:27 +02:00
Mateusz Gruszczyński 77e0c2b5bb fix inputs 2026-03-31 15:02:20 +02:00
Mateusz Gruszczyński ad75e2f958 fix inputs 2026-03-31 15:02:12 +02:00
Mateusz Gruszczyński b9b37daf01 zmiany ux 2026-03-31 14:23:22 +02:00
Mateusz Gruszczyński 40ffbb7de7 fix with .env/password 2026-03-31 13:23:56 +02:00
Mateusz Gruszczyński edd0a3767f sekcja paragonów przebudowana 2026-03-31 12:14:27 +02:00
Mateusz Gruszczyński 2b4a1f551a zmian yux i komendy cli nowe 2026-03-31 11:19:06 +02:00
Mateusz Gruszczyński edabd2ff80 zmian yux i komendy cli nowe 2026-03-31 11:18:56 +02:00
Mateusz Gruszczyński 3d4444bde4 zmian yux i komendy cli nowe 2026-03-31 11:07:38 +02:00
Mateusz Gruszczyński 115933284f new funtions 2026-03-31 10:34:57 +02:00
Mateusz Gruszczyński 222be68db2 new funtions 2026-03-31 10:34:29 +02:00
Mateusz Gruszczyński 9ca2f8f7ea fix in prev. 2026-03-30 14:49:41 +02:00
Mateusz Gruszczyński 36a1378429 hotfixes 2026-03-30 10:12:29 +02:00
Mateusz Gruszczyński 84b4a5b482 fix w css list 2026-03-29 13:40:13 +02:00
Mateusz Gruszczyński a4d3da1d64 fix text position 2026-03-27 11:50:13 +01:00
Mateusz Gruszczyński e14ea5445e fix text position 2026-03-27 11:50:01 +01:00
gru 4c3786bb7b Merge pull request 'rewrite_code' (#12) from rewrite_code into master
Reviewed-on: #12
2026-03-26 12:00:46 +01:00
Mateusz Gruszczyński 61a3121b25 uporzadkowanie style.css 2026-03-25 22:39:05 +01:00
Mateusz Gruszczyński 4533318b29 uporzadkowanie style.css 2026-03-25 22:36:01 +01:00
Mateusz Gruszczyński 4341351923 fix safari feal 2026-03-25 16:00:14 +01:00
Mateusz Gruszczyński 41b0b72532 przywrocenie funkcji z informacja jak ktos dodał kto nie jest wlascicielm produkt na liste 2026-03-25 15:34:20 +01:00
Mateusz Gruszczyński cda3ad2203 progress bar and stats 2026-03-20 12:01:18 +01:00
Mateusz Gruszczyński fd43032b55 poprawki cd 2026-03-19 15:14:21 +01:00
Mateusz Gruszczyński 4ddb48aef0 cleanup in docker 2026-03-19 09:54:03 +01:00
Mateusz Gruszczyński 616fcacb60 cleanup in docker 2026-03-19 09:36:31 +01:00
Mateusz Gruszczyński 59ec73c8b7 improvements 2026-03-18 10:26:34 +01:00
Mateusz Gruszczyński 986518b2e4 improvements 2026-03-18 10:26:25 +01:00
Mateusz Gruszczyński f02d3b8085 fixes more 2026-03-17 13:06:31 +01:00
Mateusz Gruszczyński 3347df1911 fixes 2026-03-17 12:55:59 +01:00
Mateusz Gruszczyński a299783a6c more changes 2026-03-17 11:49:36 +01:00
Mateusz Gruszczyński 14a544c9c4 some fixes 2026-03-15 22:10:33 +01:00
Mateusz Gruszczyński ad5dbcc24b small fixes in css 2026-03-15 14:01:54 +01:00
Mateusz Gruszczyński 3a57f2f1d7 refactor next push 2026-03-14 23:17:05 +01:00
Mateusz Gruszczyński a16798553e refactor part 1 2026-03-13 23:55:17 +01:00
gru e22c7e7dd2 Update deploy/varnish/default.vcl.template 2026-02-25 23:03:23 +01:00
gru 3cbeab37fb Update deploy/varnish/default.vcl.template 2026-02-25 23:00:40 +01:00
gru 3f26f5452f Update deploy/varnish/default.vcl.template 2026-02-25 22:58:34 +01:00
gru 98a52f3c25 Update deploy/varnish/default.vcl.template 2026-02-25 15:14:42 +01:00
gru 1705320ada Update deploy/varnish/default.vcl.template 2026-02-25 15:13:59 +01:00
gru 9fab8046f6 Update deploy/varnish/default.vcl.template 2026-02-25 15:10:48 +01:00
gru eec49e2bd5 Update deploy/varnish/default.vcl.template 2026-02-25 15:07:36 +01:00
gru cfc644e612 Update deploy/varnish/default.vcl.template 2026-02-25 15:02:50 +01:00
gru ec67dacbbc Update app.py 2026-02-25 14:55:23 +01:00
gru af9cef7b5b Update .env.example 2026-02-25 14:48:09 +01:00
gru 3a6ad5fd73 Update app.py 2026-02-25 14:21:47 +01:00
gru bb3c9680a8 Update app.py 2026-02-25 14:19:57 +01:00
gru 1be0c7b9fc Update app.py 2026-02-25 14:16:31 +01:00
gru 8b5c843371 Update app.py 2026-02-25 14:10:26 +01:00
gru b0a57b72e0 Update app.py 2026-02-25 14:06:06 +01:00
gru 8a462f6610 Update app.py 2026-02-25 14:03:00 +01:00
gru f042653b86 Update app.py 2026-02-25 13:55:38 +01:00
gru 3cb08ad968 Update deploy/varnish/default.vcl.template 2026-02-25 13:54:33 +01:00
gru c8b8d70c81 Update requirements.txt 2026-02-25 13:54:06 +01:00
gru 1d6bec5b8b Update docker-compose.yml 2026-02-25 00:29:08 +01:00
gru 1c623d49e3 Update docker-compose.yml 2026-02-25 00:23:21 +01:00
gru 8f08bf740a Update docker-compose.yml 2026-02-25 00:19:15 +01:00
gru e8c6119def Update deploy/varnish/default.vcl.template 2026-02-25 00:11:05 +01:00
Mateusz Gruszczyński 4d5242a479 fix flask session socktio after flask-session upgrade 2026-02-20 23:57:37 +01:00
gru 4e1b200ab3 Update deploy/varnish/default.vcl.template 2026-02-20 23:48:59 +01:00
gru 859feba09e Update deploy/varnish/default.vcl.template 2026-02-20 23:44:19 +01:00
gru 8f0caf6c98 Update deploy/varnish/default.vcl.template 2026-02-20 23:42:04 +01:00
gru 95e3af4f76 Update deploy/varnish/default.vcl.template 2026-02-19 16:24:52 +01:00
gru cf28a311ed Update deploy/varnish/default.vcl.template 2026-02-19 16:22:31 +01:00
gru bbe8c559eb Update deploy/varnish/default.vcl.template 2026-02-19 16:19:27 +01:00
gru 28afbb4279 Update deploy/varnish/default.vcl.template 2026-02-19 16:16:00 +01:00
gru fd7ca2fe6e Update README.md 2026-02-02 09:23:14 +01:00
gru 99ccd937a4 Update templates/base.html 2026-02-02 09:21:59 +01:00
Mateusz Gruszczyński d5a2d1b309 kropka kategorii na malych ekranach 2026-01-21 11:15:04 +01:00
Mateusz Gruszczyński 34cfde795a kropka kategorii na malych ekranach 2026-01-21 11:11:22 +01:00
Mateusz Gruszczyński 43b5312e35 kropka kategorii na malych ekranach 2026-01-21 11:00:45 +01:00
Mateusz Gruszczyński af40974018 kropka kategorii na malych ekranach 2026-01-21 10:58:01 +01:00
Mateusz Gruszczyński a4d17492d2 kropka kategorii na malych ekranach 2026-01-21 10:55:50 +01:00
Mateusz Gruszczyński a4403a0d33 poprawka dla malych ekranow 2026-01-13 11:25:55 +01:00
Mateusz Gruszczyński 218191a718 poprawka dla malych ekranow 2026-01-13 10:24:16 +01:00
Mateusz Gruszczyński 721387c994 poprawka dla malych ekranow 2026-01-13 09:23:39 +01:00
Mateusz Gruszczyński 3901cc152e poprawka dla malych ekranow 2026-01-13 09:03:05 +01:00
Mateusz Gruszczyński 177fde9e4b poprawka dla malych ekranow 2026-01-13 08:51:52 +01:00
Mateusz Gruszczyński dc2ece32a0 poprawka dla malych ekranow 2026-01-13 08:34:57 +01:00
Mateusz Gruszczyński 71233ebb75 poprawka dla malych ekranow 2026-01-13 08:26:51 +01:00
Mateusz Gruszczyński b92127070b poprawka dla malych ekranow 2026-01-13 08:18:49 +01:00
Mateusz Gruszczyński c22a59c70c poprawka dla malych ekranow 2026-01-13 08:13:59 +01:00
Mateusz Gruszczyński 9e3842fc7b poprawka dla malych ekranow 2026-01-13 07:57:43 +01:00
Mateusz Gruszczyński 3ba1de00e0 fix healthcheck 2026-01-12 12:31:18 +01:00
Mateusz Gruszczyński d0d37f08b9 fix healt in compose 2025-12-24 22:48:38 +01:00
Mateusz Gruszczyński 9537eef58d cahce on /healthcheck 2025-12-24 22:38:15 +01:00
gru bc6dcc5bb7 Update README.md 2025-11-29 09:56:22 +01:00
Mateusz Gruszczyński 6da7860b59 oci support 2025-11-24 14:17:20 +01:00
gru 7202459284 Update deploy/app/Dockerfile 2025-11-23 22:32:51 +01:00
gru 6cc430d422 Update deploy/app/Dockerfile 2025-11-23 22:26:45 +01:00
Mateusz Gruszczyński 4128d617a7 zakladka ustawien 2025-10-21 12:08:05 +02:00
Mateusz Gruszczyński a51e44847e zakladka ustawien 2025-10-21 12:03:45 +02:00
Mateusz Gruszczyński 45a6ab7249 zakladka ustawien 2025-10-21 12:02:29 +02:00
Mateusz Gruszczyński a363fb9ef8 zakladka ustawien 2025-10-21 11:57:53 +02:00
Mateusz Gruszczyński 2c246ac40a zakladka ustawien 2025-10-21 11:44:21 +02:00
Mateusz Gruszczyński 43b7b93ffa zakladka ustawien 2025-10-21 11:32:04 +02:00
Mateusz Gruszczyński cabc2c6a4a zakladka ustawien 2025-10-21 11:30:34 +02:00
Mateusz Gruszczyński 226b10b5a1 barwy kategorii 2025-10-18 00:22:51 +02:00
Mateusz Gruszczyński b24748a7b6 barwy kategorii 2025-10-18 00:21:50 +02:00
Mateusz Gruszczyński 11065cd007 barwy kategorii 2025-10-18 00:19:15 +02:00
Mateusz Gruszczyński 05d364bcd4 barwy kategorii 2025-10-18 00:15:06 +02:00
Mateusz Gruszczyński 57a553037b barwy kategorii 2025-10-18 00:01:23 +02:00
Mateusz Gruszczyński 5ed356a61c barwy kategorii 2025-10-17 23:58:56 +02:00
Mateusz Gruszczyński 5da660b4c3 barwy kategorii 2025-10-17 23:57:10 +02:00
Mateusz Gruszczyński d439002241 barwy kategorii 2025-10-17 23:56:01 +02:00
Mateusz Gruszczyński 4246cde484 poprawki 2025-10-17 23:50:15 +02:00
Mateusz Gruszczyński a902205960 fix compose 2025-10-08 12:20:48 +02:00
Mateusz Gruszczyński 355b73775f fix w compose 2025-10-07 21:24:52 +02:00
Mateusz Gruszczyński 81744b5c5e kolory kategorii i jedniklikowy wybor kategorii w modalu 2025-10-07 09:10:29 +02:00
Mateusz Gruszczyński 735fc69562 nowa kategoria domyślna 2025-10-07 08:04:53 +02:00
Mateusz Gruszczyński 17a5fd2086 nowa kategoria domyślna 2025-10-07 08:02:20 +02:00
Mateusz Gruszczyński 9986716e9e zmiany uxowe w panelu 2025-10-01 21:27:19 +02:00
Mateusz Gruszczyński 759c78ce87 zmiany uxowe w panelu 2025-10-01 21:21:59 +02:00
Mateusz Gruszczyński 365791cd35 zmiany uxowe w panelu 2025-10-01 21:16:45 +02:00
Mateusz Gruszczyński 08b680f030 minimalizacja js 2025-10-01 20:44:01 +02:00
140 changed files with 22376 additions and 7979 deletions

No files matched your search

+54 -27
View File
@@ -1,3 +1,8 @@
# UWAGA:
# Po zmianie pliku .env samo `docker compose restart` może nie wystarczyć.
# Aby nowe wartości zostały na pewno wczytane do kontenerów, użyj:
# docker compose up -d --force-recreate
# APP_PORT:
# Domyślny port, na którym uruchamiana jest aplikacja Flask
# Domyślnie: 8000
@@ -6,27 +11,30 @@ APP_PORT=8000
# SECRET_KEY:
# Klucz używany przez Flask do zabezpieczenia sesji, tokenów i formularzy
# Powinien być długi i trudny do odgadnięcia
SECRET_KEY=supersekretnyklucz123
# Może zawierać znaki specjalne
SECRET_KEY="supersekretnyklucz123"
# SYSTEM_PASSWORD:
# Hasło główne administratora systemowego, używane np. przy inicjalizacji
# Domyślnie: admin
SYSTEM_PASSWORD=admin
# Może zawierać znaki specjalne
SYSTEM_PASSWORD="admin"
# DEFAULT_ADMIN_USERNAME:
# Domyślna nazwa użytkownika administratora (tworzona przy starcie)
# Domyślnie: admin
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_USERNAME="admin"
# DEFAULT_ADMIN_PASSWORD:
# Domyślne hasło administratora
# Domyślnie: admin123
DEFAULT_ADMIN_PASSWORD=admin123
# Może zawierać znaki specjalne
DEFAULT_ADMIN_PASSWORD="admin123"
# UPLOAD_FOLDER:
# Ścieżka (względna) do katalogu, gdzie zapisywane są wgrywane pliki
# Domyślnie: uploads
UPLOAD_FOLDER=uploads
UPLOAD_FOLDER="uploads"
# SESSION_TIMEOUT_MINUTES:
# Czas bezczynności użytkownika (w minutach), po którym sesja wygasa
@@ -41,8 +49,12 @@ AUTH_COOKIE_MAX_AGE=86400
# AUTHORIZED_COOKIE_VALUE:
# Wartość ciasteczka uprawniającego do dostępu (np. do zasobów zabezpieczonych)
# Powinna być trudna do przewidzenia
# Chodzi to o zabezpieczenie strony "hasłęm głównym czyli endpointem /system-auth"
AUTHORIZED_COOKIE_VALUE=twoj_wlasny_hash
# Chodzi o zabezpieczenie strony "hasłem głównym", czyli endpointem /system-auth
# Może zawierać znaki specjalne
# UWAGA: zmiana SYSTEM_PASSWORD nie unieważnia automatycznie wcześniej wydanych ciasteczek.
# Aby wymusić ponowną autoryzację, zmień także AUTHORIZED_COOKIE_VALUE
# lub wyczyść ciasteczka w przeglądarce.
AUTHORIZED_COOKIE_VALUE="twoj_wlasny_hash"
# SESSION_COOKIE_SECURE:
# Określa, czy ciasteczko sesyjne (Flask session) ma mieć ustawiony atrybut "Secure".
@@ -54,39 +66,54 @@ SESSION_COOKIE_SECURE=0
# BCRYPT_PEPPER:
# Dodatkowy „sekretny klucz” (pepper) dodawany do hasła przed zahashowaniem
# Zwiększa bezpieczeństwo przechowywanych haseł
BCRYPT_PEPPER=sekretnyKluczbcrypt
# Może zawierać znaki specjalne
BCRYPT_PEPPER="sekretnyKluczbcrypt"
# HEALTHCHECK_TOKEN:
# Token wykorzystywany do sprawdzania stanu aplikacji (np. w Docker Compose)
# Domyślnie: alamapsaikota123
HEALTHCHECK_TOKEN=alamapsaikota123
# Może zawierać znaki specjalne
HEALTHCHECK_TOKEN="alamapsaikota123"
# Rodzaj bazy: sqlite, pgsql, mysql
# Mozliwe wartosci: sqlite / pgsql / mysql
DB_ENGINE=sqlite
# Możliwe wartości: sqlite / pgsql / mysql
DB_ENGINE="sqlite"
# --- Konfiguracja dla sqlite ---
# Plik bazy bedzie utworzony automatycznie w katalogu ./instance
# Pozostale zmienne sa ignorowane przy DB_ENGINE=sqlite
# Plik bazy będzie utworzony automatycznie w katalogu ./instance
# Pozostałe zmienne są ignorowane przy DB_ENGINE=sqlite
# --- Konfiguracja dla pgsql ---
# Ustaw DB_ENGINE=pgsql
# Domyslny port PostgreSQL to 5432
# Wymaga dzialajacego serwera PostgreSQL (np. kontener `postgres`)
# Domyślny port PostgreSQL to 5432
# Wymaga działającego serwera PostgreSQL (np. kontener `postgres`)
# --- Konfiguracja dla mysql ---
# Ustaw DB_ENGINE=mysql
# Domyslny port MySQL to 3306
# Wymaga kontenera z MySQL i uzytkownika z dostepem do bazy
# Domyślny port MySQL to 3306
# Wymaga kontenera z MySQL i użytkownika z dostępem do bazy
# Wspolne zmienne (dla pgsql, mysql)
# Wspólne zmienne (dla pgsql, mysql)
# DB_HOST = pgsql lub mysql zgodnie z deployem (profil w docker-compose.yml)
DB_HOST=pgsql
DB_HOST="pgsql"
DB_PORT=5432
DB_NAME=myapp
DB_USER=user
DB_PASSWORD=pass
# DB_NAME:
# Nazwa bazy danych
# Może zawierać znaki specjalne, ale zalecane są proste nazwy
DB_NAME="myapp"
# DB_USER:
# Użytkownik bazy danych
# Może zawierać znaki specjalne
DB_USER="user"
# DB_PASSWORD:
# Hasło do bazy danych
# Może zawierać znaki specjalne
# Zalecane jest używanie wartości w cudzysłowach
DB_PASSWORD="pass"
# ========================
# Nagłówki bezpieczeństwa
@@ -153,19 +180,19 @@ LIB_JS_CACHE_CONTROL="max-age=86400"
# LIB_CSS_CACHE_CONTROL:
# Nagłówki Cache-Control dla bibliotek CSS (/static/lib/css/)
# Domyślnie: "max-age=86400"
LIB_CSS_CACHE_CONTROL="max-age=86400"
LIB_CSS_CACHE_CONTROL="max-age=3600"
# UPLOADS_CACHE_CONTROL:
# Nagłówki Cache-Control dla wgrywanych plików (/uploads/)
# Domyślnie: "max-age=2592000, immutable"
UPLOADS_CACHE_CONTROL="max-age=2592000, immutable"
UPLOADS_CACHE_CONTROL="max-age=3600, immutable"
# DEFAULT_CATEGORIES:
# Lista domyślnych kategorii tworzonych automatycznie przy starcie aplikacji,
# jeśli nie istnieją w bazie danych.
# Podaj w formacie CSV (oddzielone przecinkami) – kolejność zostanie zachowana.
# Możesz dodać własne kategorie
# UWAGA: Wielkość liter w nazwach jest zachowywana, ale porównywanie odbywa się
# Możesz dodać własne kategorie.
# UWAGA: wielkość liter w nazwach jest zachowywana, ale porównywanie odbywa się
# bez rozróżniania wielkości liter (case-insensitive).
# Domyślnie: poniższa lista
DEFAULT_CATEGORIES="Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo"
DEFAULT_CATEGORIES="Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo"
+33
View File
@@ -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/<id>/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
+61
View File
@@ -0,0 +1,61 @@
Komendy CLI
===========
Admini
-------
flask admins list
flask admins create <username> <password> [--admin/--user]
flask admins promote <username|id>
flask admins demote <username|id>
flask admins set-password <username|id> <password>
Opis:
- list: pokazuje wszystkich uzytkownikow wraz z ID i rola
- create: tworzy konto admina lub zwyklego uzytkownika
- promote: nadaje uprawnienia administratora
- demote: odbiera uprawnienia administratora
- set-password: ustawia nowe haslo dla wskazanego konta
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"
flask lists move --list-id 12 --when "2026-03-21 09:00"
flask lists move --list-id 12 --when "2026-03-21 09:00" --keep-item-times --keep-expiry
flask lists archive --list-id 12
flask lists unarchive --list-id 12
flask lists assign-owner --list-id 12 --owner admin
flask lists create-from-template --template-id 5 --owner admin --when "2026-03-22 08:00"
flask lists create-from-template --template-id 5 --owner admin --title "Weekend"
flask lists delete --list-id 12
flask lists rename --list-id 12 --title "Nowa nazwa listy"
flask lists duplicate-many --source-list-id 12 --when-list "2026-03-23 08:00,2026-03-24 08:00,2026-03-25 08:00"
flask lists duplicate-many --source-list-id 12 --when-list "2026-03-23 08:00,2026-03-24 08:00" --owner admin --title-prefix "Sklep"
Zasady dzialania
----------------
- copy-schedule tworzy nowa liste na podstawie istniejacej
- copy-schedule kopiuje pozycje i przypisane kategorie
- copy-schedule 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
- move przenosi istniejaca liste na wskazany dzien/godzine
- move domyslnie przesuwa rowniez czasy pozycji i expires_at o ten sam offset czasu
- move z opcja --keep-item-times zostawia added_at i purchased_at bez zmian
- move z opcja --keep-expiry zostawia expires_at bez zmian
- archive oznacza liste jako archiwalna
- unarchive przywraca liste z archiwum
- assign-owner zmienia wlasciciela listy
- create-from-template tworzy nowa liste z szablonu dla wskazanego wlasciciela
- create-from-template bez --when ustawia biezacy czas UTC
- delete usuwa liste wraz z powiazanymi pozycjami, historią i paragonami zaleznymi od relacji bazy
- rename zmienia tytul listy
- duplicate-many tworzy wiele kopii tej samej listy dla wielu terminow przekazanych w --when-list
- duplicate-many opcjonalnie pozwala zmienic wlasciciela i nadac prefiks nazwy nowym listom
SZABLONY I HISTORIA:
- Historia zmian listy jest widoczna w widoku listy wlasciciela.
- Szablon mozna utworzyc z panelu admina lub z poziomu listy wlasciciela.
- Admin moze szybko utworzyc liste z szablonu i zduplikowac liste jednym kliknieciem.
- Operacje CLI takie jak copy-schedule, move, archive, unarchive, assign-owner, rename i create-from-template sa zapisywane w historii listy.
-1
View File
@@ -1 +0,0 @@
deploy/app/Dockerfile
-66
View File
@@ -1,66 +0,0 @@
# =========================
# Stage 1 – Build
# =========================
FROM python:3.13-alpine AS builder
WORKDIR /app
# Instalacja bibliotek do kompilacji + zależności runtime
RUN apk add --no-cache \
tesseract-ocr \
tesseract-ocr-data-pol \
poppler-utils \
libjpeg-turbo \
zlib \
libpng \
libwebp \
libffi \
libmagic \
&& apk add --no-cache --virtual .build-deps \
build-base \
jpeg-dev \
zlib-dev \
libpng-dev \
libwebp-dev \
libffi-dev
# Kopiujemy plik wymagań
COPY requirements.txt .
# Instalujemy zależności Pythona do folderu tymczasowego
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# =========================
# Stage 2 – Final image
# =========================
FROM python:3.13-alpine
WORKDIR /app
# Instalacja tylko bibliotek runtime (bez dev)
RUN apk add --no-cache \
tesseract-ocr \
tesseract-ocr-data-pol \
poppler-utils \
libjpeg-turbo \
zlib \
libpng \
libwebp \
libffi \
libmagic
# Kopiujemy zbudowane biblioteki z buildera
COPY --from=builder /install /usr/local
# Kopiujemy kod aplikacji
COPY . .
# Ustawiamy entrypoint
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Otwieramy port aplikacji
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
+51 -36
View File
@@ -1,59 +1,50 @@
# Aplikacja List Zakupów
Prosta aplikacja webowa do zarządzania listami zakupów z obsługą użytkowników, OCR paragonów, statystykami i trybem współdzielenia.
## Główne funkcje
- Logowanie i zarządzanie użytkownikami (admin/user)
- Tworzenie list zakupów z pozycjami i ilością
- Wgrywanie paragonów (podstawowa obsługa OCR)
- 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)
Aplikacja webowa do zarządzania listami zakupów z obsługą użytkowników, OCR paragonów, statystykami i trybem współdzielenia.
## Wymagania
- Python 3.9+
- Docker (opcjonalnie dla produkcji)
- Docker
## Instalacja lokalna
## Instalacja
1. Sklonuj repozytorium:
```bash
git https://gitea.linuxiarz.pl/gru/lista_zakupowa_live.git
git pull https://git.linuxiarz.pl/gru/lista_zakupowa_live.git
cd lista_zakupowa_live
```
2. Utwórz i uzupełnij plik `.env` (zobacz `.env example`).
1. Skonfiguruj `.env` z pliku `.env.example`
3. Utwórz środowisko i zainstaluj zależności:
2.1 Uruchom: (pgsql)
```bash
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
bash deploy_docker.sh pgsql
```
4. Uruchom aplikację:
2.2 Uruchom: (mysql)
```bash
flask --app app.py run
bash deploy_docker.sh mysql
```
## Deploy z Docker Compose
1. Skonfiguruj `.env`.
2. Uruchom:
2.3 Uruchom: (sqlite)
```bash
docker-compose up --build
bash deploy_docker.sh sqlite
```
2.3 Restart:
```bash
bash deploy_docker.sh pgsql restart
lub
bash deploy_docker.sh sqlite restart
```
Aplikacja będzie dostępna pod `http://localhost:8000`.
## Domyślne dane logowania
## Domyślne dane logowania - konfigurowane z pliku `.env`
- Główne hasło systemowe: `admin`
- Admin: `admin` / `admin123`
@@ -66,11 +57,35 @@ Ustaw `DB_ENGINE` oraz odpowiednie zmienne w `.env`:
Przykład dla PostgreSQL:
```env
DB_ENGINE=pgsql
DB_HOST=db
DB_PORT=5432
DB_NAME=myapp
DB_USER=user
DB_PASSWORD=pass
```
```bash
DB_ENGINE=pgsql
DB_HOST=db
DB_PORT=5432
DB_NAME=myapp
DB_USER=user
DB_PASSWORD=pass
```
## CLI
Opis komend administracyjnych znajduje sie w pliku `KOMENDY_CLI.txt`.
Komendy CLI uruchamiamy wewnatrz kontenera aplikacji. Najwygodniej wejsc do katalogu projektu i wykonac polecenie przez `docker compose exec app`.
Przykladowe:
```bash
cd /opt/lista_zakupowa_live
docker compose -f docker/compose.yml exec app sh -c 'flask lists copy-schedule --source-list-id 393 --when "2026-03-22 11:30" --owner admin'
```
Dodatkowe przyklady:
```bash
docker compose -f docker/compose.yml exec app sh -c 'flask lists move --list-id 393 --when "2026-03-23 08:00"'
docker compose -f docker/compose.yml exec app sh -c 'flask lists rename --list-id 393 --title "Zakupy na poniedzialek"'
docker compose -f docker/compose.yml exec app sh -c 'flask lists create-from-template --template-id 7 --owner admin --when "2026-03-24 09:15" --title "Poranna lista"'
```
+6 -4312
View File
File diff suppressed because it is too large. Load diff
+82 -54
View File
@@ -1,85 +1,113 @@
import os
from urllib.parse import quote_plus
basedir = os.path.abspath(os.path.dirname(__file__))
def env_str(name, default=None):
value = os.environ.get(name)
return default if value is None else value
def env_int(name, default):
value = os.environ.get(name)
if value is None or value == "":
return default
try:
return int(value)
except (TypeError, ValueError):
return default
def env_bool(name, default=False):
value = os.environ.get(name)
if value is None:
return default
return str(value).strip().lower() in ("1", "true", "yes", "on")
class Config:
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax" # działa w HTTP i HTTPS
SECRET_KEY = os.environ.get("SECRET_KEY", "D8pceNZ8q%YR7^7F&9wAC2")
SESSION_COOKIE_SAMESITE = "Lax"
APP_PORT = int(os.environ.get("APP_PORT", "8000") or "8000")
SECRET_KEY = env_str("SECRET_KEY", "D8pceNZ8q%YR7^7F&9wAC2")
APP_PORT = env_int("APP_PORT", 8000)
DB_ENGINE = env_str("DB_ENGINE", "sqlite").lower()
DB_ENGINE = os.environ.get("DB_ENGINE", "sqlite").lower()
if DB_ENGINE == "sqlite":
SQLALCHEMY_DATABASE_URI = (
f"sqlite:///{os.path.join(basedir, 'db', 'shopping.db')}"
)
elif DB_ENGINE == "pgsql":
SQLALCHEMY_DATABASE_URI = f"postgresql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 5432)}/{os.environ['DB_NAME']}"
db_user = quote_plus(env_str("DB_USER", "user"))
db_password = quote_plus(env_str("DB_PASSWORD", "pass"))
db_host = env_str("DB_HOST", "pgsql")
db_port = env_str("DB_PORT", "5432")
db_name = quote_plus(env_str("DB_NAME", "myapp"))
SQLALCHEMY_DATABASE_URI = (
f"postgresql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
)
elif DB_ENGINE == "mysql":
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{os.environ['DB_USER']}:{os.environ['DB_PASSWORD']}@{os.environ['DB_HOST']}:{os.environ.get('DB_PORT', 3306)}/{os.environ['DB_NAME']}"
db_user = quote_plus(env_str("DB_USER", "user"))
db_password = quote_plus(env_str("DB_PASSWORD", "pass"))
db_host = env_str("DB_HOST", "mysql")
db_port = env_str("DB_PORT", "3306")
db_name = quote_plus(env_str("DB_NAME", "myapp"))
SQLALCHEMY_DATABASE_URI = (
f"mysql+pymysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
)
else:
raise ValueError("Nieobsługiwany typ bazy danych.")
SQLALCHEMY_TRACK_MODIFICATIONS = False
SYSTEM_PASSWORD = os.environ.get("SYSTEM_PASSWORD", "admin")
DEFAULT_ADMIN_USERNAME = os.environ.get("DEFAULT_ADMIN_USERNAME", "admin")
DEFAULT_ADMIN_PASSWORD = os.environ.get("DEFAULT_ADMIN_PASSWORD", "admin123")
UPLOAD_FOLDER = os.environ.get("UPLOAD_FOLDER", "uploads")
AUTHORIZED_COOKIE_VALUE = os.environ.get("AUTHORIZED_COOKIE_VALUE", "cookievalue")
BCRYPT_PEPPER = os.environ.get("BCRYPT_PEPPER", "sekretnyKluczBcrypt")
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "0") == "1"
HEALTHCHECK_TOKEN = os.environ.get("HEALTHCHECK_TOKEN", "alamapsaikota1234")
try:
AUTH_COOKIE_MAX_AGE = int(
os.environ.get("AUTH_COOKIE_MAX_AGE", "86400") or "86400"
)
except ValueError:
AUTH_COOKIE_MAX_AGE = 86400
SYSTEM_PASSWORD = env_str("SYSTEM_PASSWORD", "admin")
DEFAULT_ADMIN_USERNAME = env_str("DEFAULT_ADMIN_USERNAME", "admin")
DEFAULT_ADMIN_PASSWORD = env_str("DEFAULT_ADMIN_PASSWORD", "admin123")
UPLOAD_FOLDER = env_str("UPLOAD_FOLDER", "uploads")
AUTHORIZED_COOKIE_VALUE = env_str("AUTHORIZED_COOKIE_VALUE", "cookievalue")
BCRYPT_PEPPER = env_str("BCRYPT_PEPPER", "sekretnyKluczBcrypt")
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", False)
HEALTHCHECK_TOKEN = env_str("HEALTHCHECK_TOKEN", "alamapsaikota1234")
try:
SESSION_TIMEOUT_MINUTES = int(
os.environ.get("SESSION_TIMEOUT_MINUTES", "10080") or "10080"
)
except ValueError:
SESSION_TIMEOUT_MINUTES = 10080
AUTH_COOKIE_MAX_AGE = env_int("AUTH_COOKIE_MAX_AGE", 86400)
SESSION_TIMEOUT_MINUTES = env_int("SESSION_TIMEOUT_MINUTES", 10080)
ENABLE_HSTS = os.environ.get("ENABLE_HSTS", "0") == "1"
ENABLE_XFO = os.environ.get("ENABLE_XFO", "0") == "1"
ENABLE_XCTO = os.environ.get("ENABLE_XCTO", "0") == "1"
ENABLE_CSP = os.environ.get("ENABLE_CSP", "0") == "1"
ENABLE_PP = os.environ.get("ENABLE_PP", "0") == "1"
REFERRER_POLICY = os.environ.get("REFERRER_POLICY") or None
ENABLE_HSTS = env_bool("ENABLE_HSTS", False)
ENABLE_XFO = env_bool("ENABLE_XFO", False)
ENABLE_XCTO = env_bool("ENABLE_XCTO", False)
ENABLE_CSP = env_bool("ENABLE_CSP", False)
ENABLE_PP = env_bool("ENABLE_PP", False)
DEBUG_MODE = os.environ.get("DEBUG_MODE", "1") == "1"
DISABLE_ROBOTS = os.environ.get("DISABLE_ROBOTS", "0") == "1"
REFERRER_POLICY = env_str("REFERRER_POLICY") or None
JS_CACHE_CONTROL = os.environ.get(
"JS_CACHE_CONTROL", "no-cache"
)
CSS_CACHE_CONTROL = os.environ.get(
"CSS_CACHE_CONTROL", "no-cache"
)
LIB_JS_CACHE_CONTROL = os.environ.get(
"LIB_JS_CACHE_CONTROL", "max-age=604800"
)
LIB_CSS_CACHE_CONTROL = os.environ.get(
"LIB_CSS_CACHE_CONTROL", "max-age=604800"
)
UPLOADS_CACHE_CONTROL = os.environ.get(
"UPLOADS_CACHE_CONTROL", "public, max-age=2592000, immutable"
DEBUG_MODE = env_bool("DEBUG_MODE", True)
DISABLE_ROBOTS = env_bool("DISABLE_ROBOTS", False)
JS_CACHE_CONTROL = env_str("JS_CACHE_CONTROL", "no-cache")
CSS_CACHE_CONTROL = env_str("CSS_CACHE_CONTROL", "no-cache")
LIB_JS_CACHE_CONTROL = env_str("LIB_JS_CACHE_CONTROL", "max-age=604800")
LIB_CSS_CACHE_CONTROL = env_str("LIB_CSS_CACHE_CONTROL", "max-age=604800")
UPLOADS_CACHE_CONTROL = env_str(
"UPLOADS_CACHE_CONTROL",
"public, max-age=2592000, immutable",
)
DEFAULT_CATEGORIES = [
c.strip() for c in os.environ.get(
c.strip()
for c in env_str(
"DEFAULT_CATEGORIES",
"Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,"
"Spożywcze,Budowlane,Zabawki,Chemia,Inne,Elektronika,Odzież i obuwie,Jedzenie poza domem,"
"Artykuły biurowe,Kosmetyki i higiena,Motoryzacja,Ogród i rośliny,"
"Zwierzęta,Sprzęt sportowy,Książki i prasa,Narzędzia i majsterkowanie,"
"RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo,Różne,Chiny,Dom,Leki,Odzież,Samochód"
).split(",") if c.strip()
"RTV / AGD,Apteka i suplementy,Artykuły dekoracyjne,Gry i hobby,Usługi,Pieczywo,Różne,Chiny,Dom,Leki,Odzież,Samochód,Dzieci",
).split(",")
if c.strip()
]
+1 -2
View File
@@ -1,5 +1,4 @@
FROM python:3.14-rc-trixie
FROM python:3.14-trixie
#FROM python:3.13-slim
WORKDIR /app
+35 -6
View File
@@ -65,7 +65,7 @@ sub vcl_recv {
# }
# ---- STATYCZNE – agresywny cache + ignorujemy sesję ----
if (req.url ~ "^/static/" || req.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
if (req.url ~ "^/static/" || req.url ~ "^/uploads/" || req.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
unset req.http.Cookie;
unset req.http.Authorization;
return (hash);
@@ -75,6 +75,27 @@ sub vcl_recv {
set req.http.X-Forwarded-Proto = "https";
}
if (req.url == "/healthcheck" || req.http.X-Internal-Check) {
set req.http.X-Pass-Reason = "internal";
return (pass);
}
if (req.method != "GET" && req.method != "HEAD") {
set req.http.X-Pass-Reason = "method";
return (pass);
}
if (req.http.Authorization) {
set req.http.X-Pass-Reason = "auth";
return (pass);
}
# jeśli chcesz PASS przy cookie:
# if (req.http.Cookie) {
# set req.http.X-Pass-Reason = "cookie";
# return (pass);
# }
return (hash);
}
@@ -107,6 +128,7 @@ sub vcl_backend_response {
if (beresp.http.Cache-Control ~ "(?i)no-store|private") {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
set beresp.http.X-Pass-Reason = "no-store";
return (deliver);
}
@@ -114,12 +136,12 @@ sub vcl_backend_response {
if (beresp.status >= 300 && beresp.status < 400) {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
set beresp.http.X-Pass-Reason = "redirect";
return (deliver);
}
# Nie cache'uj statyków, jeśli status ≠ 200
if (bereq.url ~ "^/static/" ||
bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)($|\?)") {
if (bereq.url ~ "^/static/" || bereq.url ~ "^/uploads/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)($|\?)") {
if (beresp.status != 200) {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
@@ -147,7 +169,7 @@ sub vcl_backend_response {
}
# ---- STATYCZNE: zdejmij Set-Cookie i Vary: Cookie, zapewnij TTL ----
if (bereq.url ~ "^/static/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
if (bereq.url ~ "^/static/" || bereq.url ~ "^/uploads/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)$") {
unset beresp.http.Set-Cookie;
# Jeśli backend dodał Vary: Cookie, usuńmy ten element (nie wpływa na statyki)
@@ -210,7 +232,14 @@ sub vcl_backend_error {
# ===== DELIVER =====
sub vcl_deliver {
if (obj.uncacheable) {
set resp.http.X-Cache = "PASS";
if (req.http.X-Pass-Reason) {
set resp.http.X-Cache = "PASS:" + req.http.X-Pass-Reason;
} else if (resp.http.X-Pass-Reason) { # z backendu
set resp.http.X-Cache = "PASS:" + resp.http.X-Pass-Reason;
} else {
set resp.http.X-Cache = "PASS";
}
unset resp.http.X-Pass-Reason;
unset resp.http.Age;
} else if (obj.hits > 0) {
set resp.http.X-Cache = "HIT";
@@ -231,4 +260,4 @@ sub vcl_synth {
# ===== PURGE HANDLER =====
sub vcl_purge {
return (synth(200, "Purged"));
}
}
+85 -21
View File
@@ -1,7 +1,8 @@
#!/bin/bash
set -e
set -euo pipefail
COMPOSE_FILE="docker/compose.yml"
# --- Wczytaj zmienne z .env ---
if [[ -f .env ]]; then
set -a
source .env
@@ -9,23 +10,63 @@ if [[ -f .env ]]; then
fi
APP_PORT="${APP_PORT:-8080}"
DEFAULT_ENGINE="${DB_ENGINE:-sqlite}"
PROFILE=$1
print_usage() {
echo "Użycie:"
echo " $0 [sqlite|pgsql|mysql] [deploy|restart]"
echo
echo "Przykłady:"
echo " $0 pgsql deploy"
echo " $0 mysql restart"
echo " $0 sqlite"
echo
echo "Domyślnie:"
echo " silnik: z DB_ENGINE z .env albo sqlite"
echo " akcja: deploy"
}
if [[ -z "$PROFILE" ]]; then
echo "Użycie: $0 {pgsql|mysql|sqlite}"
exit 1
validate_engine() {
local engine="$1"
case "$engine" in
sqlite|pgsql|mysql)
return 0
;;
*)
echo "Błąd: nieobsługiwany silnik bazy: '$engine'"
echo "Dozwolone wartości: sqlite, pgsql, mysql"
exit 1
;;
esac
}
validate_action() {
local action="$1"
case "$action" in
deploy|restart)
return 0
;;
*)
echo "Błąd: nieobsługiwana akcja: '$action'"
echo "Dozwolone wartości: deploy, restart"
exit 1
;;
esac
}
PROFILE="${1:-$DEFAULT_ENGINE}"
ACTION="${2:-deploy}"
validate_engine "$PROFILE"
validate_action "$ACTION"
if [[ -n "${DB_ENGINE:-}" && "$DB_ENGINE" != "$PROFILE" ]]; then
echo "Uwaga: DB_ENGINE w .env ma wartość '$DB_ENGINE', a uruchamiasz profil '$PROFILE'."
echo "Kontynuuję z profilem z argumentu: '$PROFILE'"
fi
echo "Zatrzymuję kontenery aplikacji i bazy..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose stop
else
docker compose --profile "$PROFILE" stop
fi
echo "Pobieram najnowszy kod z repozytorium..."
git pull
echo "Wybrany silnik bazy: $PROFILE"
echo "Wybrana akcja: $ACTION"
echo "Generowanie default.vcl z APP_PORT=$APP_PORT"
envsubst < deploy/varnish/default.vcl.template > deploy/varnish/default.vcl
@@ -33,11 +74,34 @@ envsubst < deploy/varnish/default.vcl.template > deploy/varnish/default.vcl
echo "Zapisuję hash commita do version.txt..."
git rev-parse --short HEAD > version.txt
echo "Buduję i uruchamiam kontenery..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose up -d --build
else
DB_ENGINE="$PROFILE" docker compose --profile "$PROFILE" up -d --build
if [[ "$ACTION" == "restart" ]]; then
echo "Odtwarzam kontenery bez przebudowy obrazu..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
else
DB_ENGINE="$PROFILE" docker compose -f "$COMPOSE_FILE" --profile "$PROFILE" up -d --force-recreate
fi
echo "Gotowe! Wersja aplikacji: $(cat version.txt)"
exit 0
fi
echo "Gotowe! Wersja aplikacji: $(cat version.txt)"
echo "Zatrzymuję kontenery aplikacji i bazy..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose -f "$COMPOSE_FILE" stop
else
DB_ENGINE="$PROFILE" docker compose -f "$COMPOSE_FILE" --profile "$PROFILE" stop
fi
echo "Pobieram najnowszy kod z repozytorium..."
git pull
echo "Uruchamiam kontenery z przebudową obrazu..."
if [[ "$PROFILE" == "sqlite" ]]; then
docker compose -f "$COMPOSE_FILE" up -d --build
else
DB_ENGINE="$PROFILE" docker compose -f "$COMPOSE_FILE" --profile "$PROFILE" up -d --build
fi
echo "Gotowe! Wersja aplikacji: $(cat version.txt)"
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.14-trixie
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-pol \
libglib2.0-0 \
libsm6 \
libxrender1 \
libxext6 \
poppler-utils \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY docker/requirements.txt /app/requirements.txt
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
#EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.14-slim-trixie
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-pol \
libglib2.0-0 \
libsm6 \
libxrender1 \
libxext6 \
poppler-utils \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY docker/requirements.txt /app/requirements.txt
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
#EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
+27
View File
@@ -0,0 +1,27 @@
FROM python:3.14-slim-trixie
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-pol \
libglib2.0-0 \
libsm6 \
libxrender1 \
libxext6 \
poppler-utils \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY docker/requirements-stable.txt /app/requirements.txt
RUN pip install --upgrade pip
RUN pip install --no-cache-dir -r requirements.txt
COPY . /app
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
#EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
+18 -19
View File
@@ -1,23 +1,29 @@
services:
app:
build: .
build:
context: ..
dockerfile: docker/Dockerfile.debian-stable-slim
container_name: lista-zakupow-app
#ports:
# - "${APP_PORT:-8000}:8000"
expose:
- "${APP_PORT:-8000}"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; import sys; req = urllib.request.Request('http://localhost:${APP_PORT:-8000}/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).read() == b'OK' else sys.exit(1)"]
test:
[
"CMD",
"python",
"-c",
"import urllib.request; import sys; req = urllib.request.Request('http://localhost:${APP_PORT:-8000}/healthcheck', headers={'X-Internal-Check': '${HEALTHCHECK_TOKEN}'}); sys.exit(0) if urllib.request.urlopen(req).getcode() == 200 else sys.exit(1)",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
env_file:
- .env
- ../.env
volumes:
- .:/app
- ./uploads:/app/uploads
- ./instance:/app/instance
- ../:/app
- ../uploads:/app/uploads
- ../instance:/app/instance
networks:
- lista-zakupow_network
restart: unless-stopped
@@ -31,16 +37,9 @@ services:
ports:
- "${APP_PORT:-8000}:80"
volumes:
- ./deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro
- ../deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro
environment:
- VARNISH_SIZE=256m
healthcheck:
test: [ "CMD-SHELL", "curl -fsS -H 'X-Internal-Check=${HEALTHCHECK_TOKEN}' http://localhost/healthcheck | grep -q OK" ]
interval: 30s
timeout: 5s
retries: 3
env_file:
- .env
networks:
- lista-zakupow_network
restart: unless-stopped
@@ -54,7 +53,7 @@ services:
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: 89o38kUX5T4C
volumes:
- ./db/mysql:/var/lib/mysql
- ../db/mysql:/var/lib/mysql
restart: unless-stopped
networks:
- lista-zakupow_network
@@ -69,7 +68,7 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
PGDATA: /var/lib/postgresql
volumes:
- ./db/pgsql/:/var/lib/postgresql
- ../db/pgsql:/var/lib/postgresql
networks:
- lista-zakupow_network
restart: unless-stopped
@@ -77,4 +76,4 @@ services:
networks:
lista-zakupow_network:
driver: bridge
driver: bridge
+21
View File
@@ -0,0 +1,21 @@
bcrypt==5.0.0
cryptography==46.0.5
Flask==3.1.3
Flask-Compress==1.23
Flask-Login==0.6.3
Flask-Session==0.8.0
Flask-SocketIO==5.6.1
Flask-SQLAlchemy==3.1.1
flask-talisman==1.1.0
gevent==25.9.1
gevent-websocket==0.10.1
opencv-python-headless>=4.12.0.88
pdf2image==1.17.0
pillow==12.1.1
pillow_heif==1.3.0
psutil==7.2.2
psycopg2-binary==2.9.11
PyMySQL==1.1.2
pytesseract==0.3.13
SQLAlchemy==2.0.48
Werkzeug==3.1.6
+2 -1
View File
@@ -3,7 +3,8 @@ Flask-SQLAlchemy
Flask-Login
Flask-SocketIO
Flask-Compress
eventlet
#eventlet
gevent-websocket
Werkzeug
Pillow
psutil
+10 -4
View File
@@ -1,10 +1,16 @@
#!/bin/sh
# Czekaj na bazę w Pythonie
python _tools/wait_for_db.py
# Jeśli nie przekazano zmiennej środowiskowej DB_ENGINE, ustaw na sqlite
DB_ENGINE=${DB_ENGINE:-sqlite}
# Jak baza gotowa, to migruj li daj informacje
echo "Starting app with database engine: $DB_ENGINE"
# Czekaj na bazę, jeśli jest inna niż sqlite (np. PostgreSQL)
if [ "$DB_ENGINE" != "sqlite" ]; then
python _tools/wait_for_db.py --engine "$DB_ENGINE"
fi
# Migracje i start aplikacji
flask db upgrade 2>/dev/null || flask db_info
# Start aplikacji
exec python app.py
+11
View File
@@ -0,0 +1,11 @@
from .app_setup import app, db, socketio, login_manager, APP_PORT, DEBUG_MODE, static_bp
from . import models # noqa: F401
from . import helpers # noqa: F401
app.register_blueprint(static_bp)
from . import web # noqa: F401
from . import routes_main # noqa: F401
from . import routes_secondary # noqa: F401
from . import routes_admin # noqa: F401
from . import sockets # noqa: F401
__all__ = ["app", "db", "socketio", "login_manager", "APP_PORT", "DEBUG_MODE"]
+127
View File
@@ -0,0 +1,127 @@
from .deps import *
app = Flask(__name__)
app.config.from_object(Config)
csp_policy = (
{
"default-src": "'self'",
"script-src": "'self' 'unsafe-inline'",
"style-src": "'self' 'unsafe-inline'",
"img-src": "'self' data:",
"connect-src": "'self'",
}
if app.config.get("ENABLE_CSP", True)
else None
)
permissions_policy = {"browsing-topics": "()"} if app.config.get("ENABLE_PP") else None
talisman_kwargs = {
"force_https": False,
"strict_transport_security": app.config.get("ENABLE_HSTS", True),
"frame_options": "DENY" if app.config.get("ENABLE_XFO", True) else None,
"permissions_policy": permissions_policy,
"content_security_policy": csp_policy,
"x_content_type_options": app.config.get("ENABLE_XCTO", True),
"strict_transport_security_include_subdomains": False,
}
referrer_policy = app.config.get("REFERRER_POLICY")
if referrer_policy:
talisman_kwargs["referrer_policy"] = referrer_policy
effective_headers = {
k: v
for k, v in talisman_kwargs.items()
if k != "referrer_policy" and v not in (None, False)
}
if effective_headers:
from flask_talisman import Talisman
talisman = Talisman(
app,
session_cookie_secure=app.config.get("SESSION_COOKIE_SECURE", True),
**talisman_kwargs,
)
print("[TALISMAN] Włączony z nagłówkami:", list(effective_headers.keys()))
else:
print("[TALISMAN] Pominięty — wszystkie nagłówki security wyłączone.")
register_heif_opener()
SQLALCHEMY_ECHO = True
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "heic", "pdf"}
SYSTEM_PASSWORD = app.config.get("SYSTEM_PASSWORD")
DEFAULT_ADMIN_USERNAME = app.config.get("DEFAULT_ADMIN_USERNAME")
DEFAULT_ADMIN_PASSWORD = app.config.get("DEFAULT_ADMIN_PASSWORD")
UPLOAD_FOLDER = app.config.get("UPLOAD_FOLDER")
AUTHORIZED_COOKIE_VALUE = app.config.get("AUTHORIZED_COOKIE_VALUE")
AUTH_COOKIE_MAX_AGE = app.config.get("AUTH_COOKIE_MAX_AGE")
HEALTHCHECK_TOKEN = app.config.get("HEALTHCHECK_TOKEN")
SESSION_TIMEOUT_MINUTES = int(app.config.get("SESSION_TIMEOUT_MINUTES"))
SESSION_COOKIE_SECURE = app.config.get("SESSION_COOKIE_SECURE")
APP_PORT = int(app.config.get("APP_PORT"))
app.config["COMPRESS_ALGORITHM"] = ["zstd", "br", "gzip", "deflate"]
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=SESSION_TIMEOUT_MINUTES)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
DEBUG_MODE = app.config.get("DEBUG_MODE", False)
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
db_uri = app.config.get("SQLALCHEMY_DATABASE_URI", "")
if db_uri.startswith("sqlite:///"):
sqlite_path = db_uri.replace("sqlite:///", "", 1)
sqlite_dir = os.path.dirname(sqlite_path)
if sqlite_dir:
os.makedirs(sqlite_dir, exist_ok=True)
failed_login_attempts = defaultdict(deque)
MAX_ATTEMPTS = 10
TIME_WINDOW = 60 * 60
WEBP_SAVE_PARAMS = {
"format": "WEBP",
"lossless": False,
"method": 6,
"quality": 95,
}
def read_commit(filename="version.txt", root_path=None):
base = root_path or os.path.dirname(os.path.abspath(__file__))
path = os.path.join(base, filename)
if not os.path.exists(path):
return None
try:
commit = open(path, "r", encoding="utf-8").read().strip()
return commit[:12] if commit else None
except Exception:
return None
def get_file_md5(path):
try:
digest = hashlib.md5()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
digest.update(chunk)
return digest.hexdigest()[:12]
except Exception:
return "dev"
commit = read_commit("version.txt", root_path=os.path.dirname(os.path.dirname(__file__))) or "dev"
APP_VERSION = commit
app.config["APP_VERSION"] = APP_VERSION
db = SQLAlchemy(app)
socketio = SocketIO(app, async_mode="gevent")
login_manager = LoginManager(app)
login_manager.login_view = "login"
app.config["SESSION_TYPE"] = "sqlalchemy"
app.config["SESSION_SQLALCHEMY"] = db
Session(app)
compress = Compress()
compress.init_app(app)
static_bp = Blueprint("static_bp", __name__)
active_users = {}
def utcnow():
return datetime.now(timezone.utc)
app_start_time = utcnow()
+39
View File
@@ -0,0 +1,39 @@
import os
import secrets
import time
import mimetypes
import sys
import platform
import psutil
import hashlib
import re
import traceback
import bcrypt
import colorsys
from pillow_heif import register_heif_opener
from datetime import datetime, timedelta, UTC, timezone
from urllib.parse import urlparse, urlunparse, urlencode
from flask import (
Flask, render_template, redirect, url_for, request, flash, Blueprint,
send_from_directory, abort, session, jsonify, g, render_template_string
)
from flask_sqlalchemy import SQLAlchemy
from flask_login import (
LoginManager, UserMixin, login_user, login_required, logout_user, current_user
)
from flask_compress import Compress
from flask_socketio import SocketIO, emit, join_room
from config import Config
from PIL import Image, ExifTags, ImageFilter, ImageOps
from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import func, extract, inspect, or_, case, text, and_, literal
from sqlalchemy.orm import joinedload, load_only, aliased
from collections import defaultdict, deque
from functools import wraps
from flask_session import Session
from types import SimpleNamespace
from pdf2image import convert_from_bytes
from typing import Sequence, Any
import pytesseract
from pytesseract import Output
import logging
File diff suppressed because it is too large. Load diff
+216
View File
@@ -0,0 +1,216 @@
from .deps import *
from .app_setup import db, utcnow
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
password_hash = db.Column(db.String(512), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
# Tabela pośrednia
shopping_list_category = db.Table(
"shopping_list_category",
db.Column(
"shopping_list_id",
db.Integer,
db.ForeignKey("shopping_list.id"),
primary_key=True,
),
db.Column(
"category_id", db.Integer, db.ForeignKey("category.id"), primary_key=True
),
)
class Category(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), unique=True, nullable=False)
class ShoppingList(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(150), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
owner_id = db.Column(db.Integer, db.ForeignKey("user.id"))
owner = db.relationship("User", backref="lists", foreign_keys=[owner_id])
is_temporary = db.Column(db.Boolean, default=False)
share_token = db.Column(db.String(64), unique=True, nullable=True)
expires_at = db.Column(db.DateTime(timezone=True), nullable=True)
owner = db.relationship("User", backref="lists", lazy=True)
is_archived = db.Column(db.Boolean, default=False)
is_public = db.Column(db.Boolean, default=False)
# Relacje
items = db.relationship("Item", back_populates="shopping_list", lazy="select")
receipts = db.relationship(
"Receipt",
back_populates="shopping_list",
cascade="all, delete-orphan",
lazy="select",
)
expenses = db.relationship("Expense", back_populates="shopping_list", lazy="select")
categories = db.relationship(
"Category",
secondary=shopping_list_category,
backref=db.backref("shopping_lists", lazy="dynamic"),
)
class Item(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"))
name = db.Column(db.String(150), nullable=False)
# added_at = db.Column(db.DateTime, default=datetime.utcnow)
added_at = db.Column(db.DateTime, default=utcnow)
added_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
added_by_user = db.relationship(
"User", backref="added_items", lazy="joined", foreign_keys=[added_by]
)
purchased = db.Column(db.Boolean, default=False)
purchased_at = db.Column(db.DateTime, nullable=True)
quantity = db.Column(db.Integer, default=1)
note = db.Column(db.Text, nullable=True)
not_purchased = db.Column(db.Boolean, default=False)
not_purchased_reason = db.Column(db.Text, nullable=True)
position = db.Column(db.Integer, default=0)
shopping_list = db.relationship("ShoppingList", back_populates="items")
class SuggestedProduct(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), unique=True, nullable=False)
usage_count = db.Column(db.Integer, default=0)
class Expense(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id"))
amount = db.Column(db.Float, nullable=False)
added_at = db.Column(db.DateTime, default=datetime.utcnow)
receipt_filename = db.Column(db.String(255), nullable=True)
shopping_list = db.relationship("ShoppingList", back_populates="expenses")
class Receipt(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(
db.Integer,
db.ForeignKey("shopping_list.id", ondelete="CASCADE"),
nullable=False,
)
filename = db.Column(db.String(255), nullable=False)
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
filesize = db.Column(db.Integer, nullable=True)
file_hash = db.Column(db.String(64), nullable=True, unique=True)
uploaded_by = db.Column(db.Integer, db.ForeignKey("user.id"))
version_token = db.Column(db.String(32), nullable=True)
shopping_list = db.relationship("ShoppingList", back_populates="receipts")
uploaded_by_user = db.relationship("User", backref="uploaded_receipts")
class ListPermission(db.Model):
__tablename__ = "list_permission"
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(
db.Integer,
db.ForeignKey("shopping_list.id", ondelete="CASCADE"),
nullable=False,
)
user_id = db.Column(
db.Integer,
db.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
__table_args__ = (db.UniqueConstraint("list_id", "user_id", name="uq_list_user"),)
ShoppingList.permitted_users = db.relationship(
"User",
secondary="list_permission",
backref=db.backref("permitted_lists", lazy="dynamic"),
lazy="dynamic",
)
class AppSetting(db.Model):
key = db.Column(db.String(64), primary_key=True)
value = db.Column(db.Text, nullable=True)
class ApiToken(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True)
token_prefix = db.Column(db.String(18), nullable=False)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
last_used_at = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
scopes = db.Column(db.String(255), nullable=False, default="expenses:read")
allowed_endpoints = db.Column(db.String(255), nullable=False, default="/api/expenses/latest")
max_limit = db.Column(db.Integer, nullable=False, default=100)
creator = db.relationship(
"User", backref="created_api_tokens", lazy="joined", foreign_keys=[created_by]
)
class ListTemplate(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(150), nullable=False)
description = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
creator = db.relationship("User", backref="list_templates", lazy="joined")
items = db.relationship(
"ListTemplateItem",
back_populates="template",
cascade="all, delete-orphan",
lazy="select",
order_by="ListTemplateItem.position.asc()",
)
class ListTemplateItem(db.Model):
id = db.Column(db.Integer, primary_key=True)
template_id = db.Column(db.Integer, db.ForeignKey("list_template.id", ondelete="CASCADE"), nullable=False)
name = db.Column(db.String(150), nullable=False)
quantity = db.Column(db.Integer, default=1)
note = db.Column(db.Text, nullable=True)
position = db.Column(db.Integer, default=0)
template = db.relationship("ListTemplate", back_populates="items")
class ListActivityLog(db.Model):
id = db.Column(db.Integer, primary_key=True)
list_id = db.Column(db.Integer, db.ForeignKey("shopping_list.id", ondelete="CASCADE"), nullable=False, index=True)
actor_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True)
actor_name = db.Column(db.String(150), nullable=False, default="System")
action = db.Column(db.String(64), nullable=False)
item_name = db.Column(db.String(150), nullable=True)
details = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=utcnow, nullable=False, index=True)
shopping_list = db.relationship("ShoppingList", backref=db.backref("activity_logs", lazy="dynamic", cascade="all, delete-orphan"))
actor = db.relationship("User", backref="list_activity_logs", lazy="joined")
class CategoryColorOverride(db.Model):
id = db.Column(db.Integer, primary_key=True)
category_id = db.Column(
db.Integer, db.ForeignKey("category.id"), unique=True, nullable=False
)
color_hex = db.Column(db.String(7), nullable=False) # "#rrggbb"
File diff suppressed because it is too large. Load diff
+878
View File
@@ -0,0 +1,878 @@
from .deps import *
from .app_setup import *
from .models import *
from .helpers import *
@app.route("/")
def main_page():
perm_subq = (
user_permission_subq(current_user.id) if current_user.is_authenticated else None
)
now = datetime.now(timezone.utc)
month_param = request.args.get("m", None)
start = end = None
if month_param in (None, ""):
# domyślnie: bieżący miesiąc
month_str = now.strftime("%Y-%m")
start = datetime(now.year, now.month, 1, tzinfo=timezone.utc)
end = (start + timedelta(days=31)).replace(day=1)
elif month_param == "all":
month_str = "all"
start = end = None
else:
month_str = month_param
try:
year, month = map(int, month_str.split("-"))
start = datetime(year, month, 1, tzinfo=timezone.utc)
end = (start + timedelta(days=31)).replace(day=1)
except ValueError:
# jeśli m ma zły format – pokaż wszystko
month_str = "all"
start = end = None
def date_filter(query):
if start and end:
query = query.filter(
ShoppingList.created_at >= start, ShoppingList.created_at < end
)
return query
if current_user.is_authenticated:
user_lists = (
date_filter(
ShoppingList.query.filter(
ShoppingList.owner_id == current_user.id,
ShoppingList.is_archived == False,
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now),
)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
archived_lists = (
ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=True)
.order_by(ShoppingList.created_at.desc())
.all()
)
# publiczne cudze + udzielone mi (po list_permission)
public_lists = (
date_filter(
ShoppingList.query.filter(
ShoppingList.owner_id != current_user.id,
ShoppingList.is_archived == False,
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now),
or_(
ShoppingList.is_public == True,
ShoppingList.id.in_(perm_subq),
),
)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
accessible_lists = public_lists # alias do szablonu: publiczne + udostępnione
else:
user_lists = []
archived_lists = []
public_lists = (
date_filter(
ShoppingList.query.filter(
ShoppingList.is_public == True,
(ShoppingList.expires_at == None) | (ShoppingList.expires_at > now),
ShoppingList.is_archived == False,
)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
accessible_lists = public_lists # dla gościa = tylko publiczne
# Zakres miesięcy do selektora
if current_user.is_authenticated:
visible_lists_query = ShoppingList.query.filter(
or_(
ShoppingList.owner_id == current_user.id,
ShoppingList.is_public == True,
ShoppingList.id.in_(perm_subq),
)
)
else:
visible_lists_query = ShoppingList.query.filter(ShoppingList.is_public == True)
month_options = get_active_months_query(visible_lists_query)
# Statystyki dla wszystkich widocznych sekcji
all_lists = user_lists + accessible_lists + archived_lists
all_ids = [l.id for l in all_lists]
if all_ids:
stats = (
db.session.query(
Item.list_id,
func.count(Item.id).label("total_count"),
func.sum(case((((Item.purchased == True) & (Item.not_purchased == False)), 1), else_=0)).label(
"purchased_count"
),
func.sum(case((Item.not_purchased == True, 1), else_=0)).label(
"not_purchased_count"
),
)
.filter(Item.list_id.in_(all_ids))
.group_by(Item.list_id)
.all()
)
stats_map = {
s.list_id: (
s.total_count or 0,
s.purchased_count or 0,
s.not_purchased_count or 0,
)
for s in stats
}
latest_expenses_map = dict(
db.session.query(
Expense.list_id, func.coalesce(func.sum(Expense.amount), 0)
)
.filter(Expense.list_id.in_(all_ids))
.group_by(Expense.list_id)
.all()
)
for l in all_lists:
total_count, purchased_count, not_purchased_count = stats_map.get(
l.id, (0, 0, 0)
)
l.total_count = total_count
l.purchased_count = purchased_count
l.not_purchased_count = not_purchased_count
l.total_expense = latest_expenses_map.get(l.id, 0)
l.category_badges = [
{"name": c.name, "color": category_color_for(c)} for c in l.categories
]
else:
for l in all_lists:
l.total_count = 0
l.purchased_count = 0
l.not_purchased_count = 0
l.total_expense = 0
l.category_badges = []
def build_progress_summary(lists_):
total_lists = len(lists_)
total_products = sum(getattr(l, "total_count", 0) or 0 for l in lists_)
purchased_products = sum(getattr(l, "purchased_count", 0) or 0 for l in lists_)
not_purchased_products = sum(getattr(l, "not_purchased_count", 0) or 0 for l in lists_)
total_expense = float(sum((getattr(l, "total_expense", 0) or 0) for l in lists_))
completion_percent = (
(purchased_products / total_products) * 100 if total_products > 0 else 0
)
return {
"list_count": total_lists,
"total_products": total_products,
"purchased_products": purchased_products,
"not_purchased_products": not_purchased_products,
"remaining_products": max(total_products - purchased_products - not_purchased_products, 0),
"total_expense": round(total_expense, 2),
"completion_percent": completion_percent,
}
user_lists_summary = build_progress_summary(user_lists)
accessible_lists_summary = build_progress_summary(accessible_lists)
expiring_lists = get_expiring_lists_for_user(current_user.id) if current_user.is_authenticated else []
templates = (ListTemplate.query.filter_by(is_active=True, created_by=current_user.id).order_by(ListTemplate.name.asc()).all() if current_user.is_authenticated else [])
return render_template(
"main.html",
user_lists=user_lists,
public_lists=public_lists,
accessible_lists=accessible_lists,
archived_lists=archived_lists,
now=now,
timedelta=timedelta,
month_options=month_options,
selected_month=month_str,
expiring_lists=expiring_lists,
templates=templates,
user_lists_summary=user_lists_summary,
accessible_lists_summary=accessible_lists_summary,
)
@app.route("/system-auth", methods=["GET", "POST"])
def system_auth():
if request.cookies.get("authorized") == AUTHORIZED_COOKIE_VALUE:
flash("Jesteś już autoryzowany.", "info")
return redirect(url_for("main_page"))
ip = request.access_route[0]
next_page = request.args.get("next") or url_for("main_page")
if is_ip_blocked(ip):
flash(
"Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.",
"danger",
)
return render_template("system_auth.html"), 403
if request.method == "POST":
if request.form["password"] == SYSTEM_PASSWORD:
reset_failed_attempts(ip)
resp = redirect(next_page)
return set_authorized_cookie(resp)
else:
register_failed_attempt(ip)
if is_ip_blocked(ip):
flash(
"Przekroczono limit prób logowania. Dostęp zablokowany na 1 godzinę.",
"danger",
)
return render_template("system_auth.html"), 403
remaining = attempts_remaining(ip)
flash(f"Nieprawidłowe hasło. Pozostało {remaining} prób.", "warning")
return render_template("system_auth.html")
@app.route("/edit_my_list/<int:list_id>", methods=["GET", "POST"])
@login_required
def edit_my_list(list_id):
# --- Pobranie listy i weryfikacja właściciela ---
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
abort(403, description="Nie jesteś właścicielem tej listy.")
# Dane do widoku
receipts = (
Receipt.query.filter_by(list_id=list_id)
.order_by(Receipt.uploaded_at.desc())
.all()
)
categories = Category.query.order_by(Category.name.asc()).all()
selected_categories_ids = {c.id for c in l.categories}
next_page = request.args.get("next") or request.referrer
wants_json = (
"application/json" in (request.headers.get("Accept") or "")
or request.headers.get("X-Requested-With") == "fetch"
)
if request.method == "POST":
action = request.form.get("action")
# --- Nadanie dostępu (grant) ---
if action == "grant":
grant_username = (request.form.get("grant_username") or "").strip().lower()
if not grant_username:
if wants_json:
return jsonify(ok=False, error="empty"), 400
flash("Podaj nazwę użytkownika do nadania dostępu.", "danger")
return redirect(next_page or request.url)
u = User.query.filter(func.lower(User.username) == grant_username).first()
if not u:
if wants_json:
return jsonify(ok=False, error="not_found"), 404
flash("Użytkownik nie istnieje.", "danger")
return redirect(next_page or request.url)
if u.id == current_user.id:
if wants_json:
return jsonify(ok=False, error="owner"), 409
flash("Jesteś właścicielem tej listy.", "info")
return redirect(next_page or request.url)
exists = (
db.session.query(ListPermission.id)
.filter(
ListPermission.list_id == l.id,
ListPermission.user_id == u.id,
)
.first()
)
if not exists:
db.session.add(ListPermission(list_id=l.id, user_id=u.id))
db.session.commit()
if wants_json:
return jsonify(ok=True, user={"id": u.id, "username": u.username})
flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success")
else:
if wants_json:
return jsonify(ok=False, error="exists"), 409
flash("Ten użytkownik już ma dostęp.", "info")
return redirect(next_page or request.url)
# --- Odebranie dostępu (revoke) ---
revoke_user_id = request.form.get("revoke_user_id")
if revoke_user_id:
try:
uid = int(revoke_user_id)
except ValueError:
if wants_json:
return jsonify(ok=False, error="bad_id"), 400
flash("Błędny identyfikator użytkownika.", "danger")
return redirect(next_page or request.url)
ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete()
db.session.commit()
if wants_json:
return jsonify(ok=True, removed_user_id=uid)
flash("Odebrano dostęp użytkownikowi.", "success")
return redirect(next_page or request.url)
# --- Przywracanie z archiwum ---
if "unarchive" in request.form:
l.is_archived = False
db.session.commit()
if wants_json:
return jsonify(ok=True, unarchived=True)
flash(f"Lista „{l.title}” została przywrócona.", "success")
return redirect(next_page or request.url)
# --- Główny zapis pól formularza ---
move_to_month = request.form.get("move_to_month")
if move_to_month:
try:
year, month = map(int, move_to_month.split("-"))
l.created_at = datetime(year, month, 1, tzinfo=timezone.utc)
if not wants_json:
flash(
f"Zmieniono datę utworzenia listy na {l.created_at.strftime('%Y-%m-%d')}",
"success",
)
except ValueError:
if not wants_json:
flash(
"Nieprawidłowy format miesiąca — zignorowano zmianę miesiąca.",
"danger",
)
new_title = (request.form.get("title") or "").strip()
is_public = "is_public" in request.form
is_temporary = "is_temporary" in request.form
is_archived = "is_archived" in request.form
expires_date = request.form.get("expires_date")
expires_time = request.form.get("expires_time")
if not new_title:
if wants_json:
return jsonify(ok=False, error="title_empty"), 400
flash("Podaj poprawny tytuł", "danger")
return redirect(next_page or request.url)
l.title = new_title
l.is_public = is_public
l.is_temporary = is_temporary
l.is_archived = is_archived
if expires_date and expires_time:
try:
combined = f"{expires_date} {expires_time}"
expires_dt = datetime.strptime(combined, "%Y-%m-%d %H:%M")
l.expires_at = expires_dt.replace(tzinfo=timezone.utc)
except ValueError:
if wants_json:
return jsonify(ok=False, error="bad_expiry"), 400
flash("Błędna data lub godzina wygasania", "danger")
return redirect(next_page or request.url)
else:
l.expires_at = None
# Kategorie (używa Twojej pomocniczej funkcji)
update_list_categories_from_form(l, request.form)
db.session.commit()
if wants_json:
return jsonify(ok=True, saved=True)
flash("Zaktualizowano dane listy", "success")
return redirect(next_page or request.url)
# GET: użytkownicy z dostępem
permitted_users = (
db.session.query(User)
.join(ListPermission, ListPermission.user_id == User.id)
.where(ListPermission.list_id == l.id)
.order_by(User.username.asc())
.all()
)
all_usernames = [
u.username
for u in User.query.filter(User.id != current_user.id)
.order_by(func.lower(User.username).asc())
.limit(300)
.all()
]
return render_template(
"edit_my_list.html",
list=l,
receipts=receipts,
categories=categories,
selected_categories=selected_categories_ids,
permitted_users=permitted_users,
all_usernames=all_usernames,
)
@app.route("/edit_my_list/<int:list_id>/suggestions", methods=["GET"])
@login_required
def edit_my_list_suggestions(list_id: int):
# Weryfikacja listy i właściciela (prywatność)
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
abort(403, description="Nie jesteś właścicielem tej listy.")
q = (request.args.get("q") or "").strip().lower()
# Historia nadawań uprawnień przez tego właściciela (po wszystkich jego listach)
subq = (
db.session.query(
ListPermission.user_id.label("uid"),
func.count(ListPermission.id).label("grant_count"),
func.max(ListPermission.id).label("last_grant_id"),
)
.join(ShoppingList, ShoppingList.id == ListPermission.list_id)
.filter(ShoppingList.owner_id == current_user.id)
.group_by(ListPermission.user_id)
.subquery()
)
query = (
db.session.query(User.username, subq.c.grant_count, subq.c.last_grant_id)
.outerjoin(subq, subq.c.uid == User.id)
.filter(User.id != current_user.id)
)
if q:
query = query.filter(func.lower(User.username).like(f"{q}%"))
rows = (
query.order_by(
func.coalesce(subq.c.grant_count, 0).desc(),
func.coalesce(subq.c.last_grant_id, 0).desc(),
func.lower(User.username).asc(),
)
.limit(20)
.all()
)
return jsonify({"users": [r.username for r in rows]})
@app.route("/delete_user_list/<int:list_id>", methods=["POST"])
@login_required
def delete_user_list(list_id):
l = db.session.get(ShoppingList, list_id)
if l is None or l.owner_id != current_user.id:
abort(403, description="Nie jesteś właścicielem tej listy.")
l = db.session.get(ShoppingList, list_id)
if l is None or l.owner_id != current_user.id:
abort(403)
delete_receipts_for_list(list_id)
Item.query.filter_by(list_id=list_id).delete()
Expense.query.filter_by(list_id=list_id).delete()
db.session.delete(l)
db.session.commit()
flash("Lista została usunięta", "success")
return redirect(url_for("main_page"))
@app.route("/toggle_visibility/<int:list_id>", methods=["GET", "POST"])
@login_required
def toggle_visibility(list_id):
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
if request.is_json or request.method == "POST":
return {"error": "Unauthorized"}, 403
flash("Nie masz uprawnień do tej listy", "danger")
return redirect(url_for("main_page"))
l.is_public = not l.is_public
db.session.commit()
share_url = f"{request.url_root}share/{l.share_token}"
if request.is_json or request.method == "POST":
return {"is_public": l.is_public, "share_url": share_url}
if l.is_public:
flash("Lista została udostępniona publicznie", "success")
else:
flash("Lista została ukryta przed gośćmi", "info")
return redirect(url_for("main_page"))
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username_input = request.form["username"].lower()
user = User.query.filter(func.lower(User.username) == username_input).first()
if user and check_password(user.password_hash, request.form["password"]):
session.permanent = True
login_user(user)
session.modified = True
flash("Zalogowano pomyślnie", "success")
return redirect(url_for("main_page"))
flash("Nieprawidłowy login lub hasło", "danger")
return render_template("login.html")
@app.route("/logout")
@login_required
def logout():
logout_user()
flash("Wylogowano pomyślnie", "success")
return redirect(url_for("main_page"))
@app.route("/create", methods=["POST"])
@login_required
def create_list():
title = request.form.get("title")
is_temporary = request.form.get("temporary") == "1"
token = generate_share_token(8)
expires_at = (
datetime.now(timezone.utc) + timedelta(days=7) if is_temporary else None
)
new_list = ShoppingList(
title=title,
owner_id=current_user.id,
is_temporary=is_temporary,
share_token=token,
expires_at=expires_at,
)
db.session.add(new_list)
db.session.commit()
log_list_activity(new_list.id, 'list_created', actor=current_user, actor_name=current_user.username, details='Utworzono listę ręcznie')
db.session.commit()
flash("Utworzono nową listę", "success")
return redirect(url_for("view_list", list_id=new_list.id))
@app.route("/list/<int:list_id>")
@login_required
def view_list(list_id):
shopping_list = db.session.get(ShoppingList, list_id)
if not shopping_list:
abort(404)
is_owner = current_user.id == shopping_list.owner_id
if not is_owner:
flash(
"Nie jesteś właścicielem listy, przekierowano do widoku publicznego.",
"warning",
)
if current_user.is_admin:
flash(
"W celu modyfikacji listy, przejdź do panelu administracyjnego.", "info"
)
return redirect(url_for("shared_list", token=shopping_list.share_token))
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
total_count = len(items)
purchased_count = len([i for i in items if i.purchased])
percent = (purchased_count / total_count * 100) if total_count > 0 else 0
for item in items:
if item.added_by and item.added_by != shopping_list.owner_id:
item.added_by_display = (
item.added_by_user.username if item.added_by_user else None
)
else:
item.added_by_display = None
shopping_list.category_badges = [
{"name": c.name, "color": category_color_for(c)}
for c in shopping_list.categories
]
# Wszystkie kategorie (do selecta)
categories = Category.query.order_by(Category.name.asc()).all()
selected_categories_ids = {c.id for c in shopping_list.categories}
# Najczęściej używane kategorie właściciela (top N)
popular_categories = (
db.session.query(Category)
.join(
shopping_list_category,
shopping_list_category.c.category_id == Category.id,
)
.join(
ShoppingList,
ShoppingList.id == shopping_list_category.c.shopping_list_id,
)
.filter(ShoppingList.owner_id == current_user.id)
.group_by(Category.id)
.order_by(func.count(ShoppingList.id).desc(), func.lower(Category.name).asc())
.limit(6)
.all()
)
# Użytkownicy z uprawnieniami do listy
permitted_users = (
db.session.query(User)
.join(ListPermission, ListPermission.user_id == User.id)
.filter(ListPermission.list_id == shopping_list.id)
.order_by(User.username.asc())
.all()
)
activity_logs = (
ListActivityLog.query.filter_by(list_id=list_id)
.order_by(ListActivityLog.created_at.desc(), ListActivityLog.id.desc())
.limit(20)
.all()
)
all_usernames = [
u.username
for u in User.query.filter(User.id != current_user.id)
.order_by(func.lower(User.username).asc())
.limit(300)
.all()
]
return render_template(
"list.html",
list=shopping_list,
items=items,
receipts=receipts,
total_count=total_count,
purchased_count=purchased_count,
percent=percent,
expenses=expenses,
total_expense=total_expense,
is_share=False,
is_owner=is_owner,
categories=categories,
selected_categories=selected_categories_ids,
permitted_users=permitted_users,
popular_categories=popular_categories,
activity_logs=activity_logs,
action_label=action_label,
all_usernames=all_usernames,
)
@app.route("/list/<int:list_id>/settings", methods=["POST"])
@login_required
def list_settings(list_id):
# Uprawnienia: właściciel
l = db.session.get(ShoppingList, list_id)
if l is None:
abort(404)
if l.owner_id != current_user.id:
abort(403, description="Brak uprawnień do ustawień tej listy.")
next_page = request.form.get("next") or url_for("view_list", list_id=list_id)
wants_json = (
"application/json" in (request.headers.get("Accept") or "")
or request.headers.get("X-Requested-With") == "fetch"
)
action = request.form.get("action")
# 1) Ustawienie kategorii (pojedynczy wybór z list.html -> modal kategorii)
if action == "set_category":
cid = request.form.get("category_id")
if cid in (None, "", "none"):
# usunięcie kategorii lub brak zmiany – w zależności od Twojej logiki
l.categories = []
db.session.commit()
if wants_json:
return jsonify(ok=True, saved=True)
flash("Zapisano kategorię.", "success")
return redirect(next_page)
try:
cid = int(cid)
except (TypeError, ValueError):
if wants_json:
return jsonify(ok=False, error="bad_category"), 400
flash("Błędna kategoria.", "danger")
return redirect(next_page)
c = db.session.get(Category, cid)
if not c:
if wants_json:
return jsonify(ok=False, error="bad_category"), 400
flash("Błędna kategoria.", "danger")
return redirect(next_page)
# Jeśli jeden wybór – zastąp listę kategorii jedną:
l.categories = [c]
db.session.commit()
if wants_json:
return jsonify(ok=True, saved=True)
flash("Zapisano kategorię.", "success")
return redirect(next_page)
# 2) Nadanie dostępu (akceptuj 'grant_access' i 'grant')
if action in ("grant_access", "grant"):
grant_username = (request.form.get("grant_username") or "").strip().lower()
if not grant_username:
if wants_json:
return jsonify(ok=False, error="empty_username"), 400
flash("Podaj nazwę użytkownika.", "danger")
return redirect(next_page)
# Szukamy użytkownika po username (case-insensitive)
u = User.query.filter(func.lower(User.username) == grant_username).first()
if not u:
if wants_json:
return jsonify(ok=False, error="not_found"), 404
flash("Użytkownik nie istnieje.", "danger")
return redirect(next_page)
# Właściciel już ma dostęp
if u.id == l.owner_id:
if wants_json:
return jsonify(ok=False, error="owner"), 409
flash("Jesteś właścicielem tej listy.", "info")
return redirect(next_page)
# Czy już ma dostęp?
exists = (
db.session.query(ListPermission.id)
.filter(ListPermission.list_id == l.id, ListPermission.user_id == u.id)
.first()
)
if exists:
if wants_json:
return jsonify(ok=False, error="exists"), 409
flash("Ten użytkownik już ma dostęp.", "info")
return redirect(next_page)
# Zapis uprawnienia
db.session.add(ListPermission(list_id=l.id, user_id=u.id))
db.session.commit()
if wants_json:
# Zwracamy usera, żeby JS mógł dokleić token bez odświeżania
return jsonify(ok=True, user={"id": u.id, "username": u.username})
flash(f"Nadano dostęp użytkownikowi „{u.username}”.", "success")
return redirect(next_page)
# 3) Odebranie dostępu (po polu revoke_user_id, nie po action)
revoke_uid = request.form.get("revoke_user_id")
if revoke_uid:
try:
uid = int(revoke_uid)
except (TypeError, ValueError):
if wants_json:
return jsonify(ok=False, error="bad_user_id"), 400
flash("Błędny identyfikator użytkownika.", "danger")
return redirect(next_page)
# Nie pozwalaj usunąć właściciela
if uid == l.owner_id:
if wants_json:
return jsonify(ok=False, error="cannot_revoke_owner"), 400
flash("Nie można odebrać dostępu właścicielowi.", "danger")
return redirect(next_page)
ListPermission.query.filter_by(list_id=l.id, user_id=uid).delete()
db.session.commit()
if wants_json:
return jsonify(ok=True, removed_user_id=uid)
flash("Odebrano dostęp użytkownikowi.", "success")
return redirect(next_page)
# 4) Nieznana akcja
if wants_json:
return jsonify(ok=False, error="unknown_action"), 400
flash("Nieznana akcja.", "danger")
return redirect(next_page)
@app.route('/my-templates', methods=['GET', 'POST'])
@login_required
def my_templates():
if request.method == 'POST':
action = (request.form.get('action') or 'create_manual').strip()
if action == 'create_manual':
name = (request.form.get('name') or '').strip()
description = (request.form.get('description') or '').strip()
raw_items = (request.form.get('items_text') or '').splitlines()
if not name:
flash('Podaj nazwę szablonu.', 'danger')
return redirect(url_for('my_templates'))
template = ListTemplate(name=name, description=description, created_by=current_user.id, is_active=True)
db.session.add(template)
db.session.flush()
pos = 1
for line in raw_items:
line = line.strip()
if not line:
continue
qty = 1
item_name = line
match = re.match(r'^(.*?)(?:\s+[xX](\d+))?$', line)
if match:
item_name = (match.group(1) or '').strip() or line
if match.group(2):
qty = max(1, int(match.group(2)))
db.session.add(ListTemplateItem(template_id=template.id, name=item_name, quantity=qty, position=pos))
pos += 1
db.session.commit()
flash(f'Utworzono szablon „{template.name}”.', 'success')
return redirect(url_for('my_templates'))
elif action == 'delete':
template = ListTemplate.query.options(joinedload(ListTemplate.items)).get_or_404(request.form.get('template_id', type=int))
if template.created_by != current_user.id and not current_user.is_admin:
abort(403)
db.session.delete(template)
db.session.commit()
flash(f'Usunięto szablon „{template.name}”.', 'warning')
return redirect(url_for('my_templates'))
templates = ListTemplate.query.options(joinedload(ListTemplate.items)).filter_by(created_by=current_user.id, is_active=True).order_by(ListTemplate.created_at.desc(), ListTemplate.id.desc()).all()
source_lists = ShoppingList.query.filter_by(owner_id=current_user.id, is_archived=False).order_by(ShoppingList.created_at.desc()).limit(100).all()
return render_template('my_templates.html', templates=templates, source_lists=source_lists)
@app.route('/templates/<int:template_id>/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/<int:list_id>', 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'))
+740
View File
@@ -0,0 +1,740 @@
from .deps import *
from .app_setup import *
from .models import *
from .helpers import *
@app.route("/expenses")
@login_required
def expenses():
start_date_str = request.args.get("start_date")
end_date_str = request.args.get("end_date")
category_id = request.args.get("category_id", type=str)
show_all = request.args.get("show_all", "true").lower() == "true"
now = datetime.now(timezone.utc)
visible_clause = visible_lists_clause_for_expenses(
user_id=current_user.id, include_shared=show_all, now_dt=now
)
lists_q = ShoppingList.query.filter(*visible_clause)
if start_date_str and end_date_str:
try:
start = datetime.strptime(start_date_str, "%Y-%m-%d")
end = datetime.strptime(end_date_str, "%Y-%m-%d") + timedelta(days=1)
lists_q = lists_q.filter(
ShoppingList.created_at >= start,
ShoppingList.created_at < end,
)
except ValueError:
flash("Błędny zakres dat", "danger")
if category_id:
if category_id == "none":
lists_q = lists_q.filter(~ShoppingList.categories.any())
else:
try:
cid = int(category_id)
lists_q = lists_q.join(
shopping_list_category,
shopping_list_category.c.shopping_list_id == ShoppingList.id,
).filter(shopping_list_category.c.category_id == cid)
except (TypeError, ValueError):
pass
lists_filtered = (
lists_q.options(
joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)
)
.order_by(ShoppingList.created_at.desc())
.all()
)
list_ids = [l.id for l in lists_filtered] or [-1]
expenses = (
Expense.query.options(
joinedload(Expense.shopping_list).joinedload(ShoppingList.owner),
joinedload(Expense.shopping_list).joinedload(ShoppingList.categories),
)
.filter(Expense.list_id.in_(list_ids))
.order_by(Expense.added_at.desc())
.all()
)
totals_rows = (
db.session.query(
ShoppingList.id.label("lid"),
func.coalesce(func.sum(Expense.amount), 0).label("total_expense"),
)
.select_from(ShoppingList)
.filter(ShoppingList.id.in_(list_ids))
.outerjoin(Expense, Expense.list_id == ShoppingList.id)
.group_by(ShoppingList.id)
.all()
)
totals_map = {row.lid: float(row.total_expense or 0) for row in totals_rows}
categories = (
Category.query.join(
shopping_list_category, shopping_list_category.c.category_id == Category.id
)
.join(
ShoppingList, ShoppingList.id == shopping_list_category.c.shopping_list_id
)
.filter(ShoppingList.id.in_(list_ids))
.distinct()
.order_by(Category.name.asc())
.all()
)
categories.append(SimpleNamespace(id="none", name="Bez kategorii"))
expense_table = [
{
"title": (e.shopping_list.title if e.shopping_list else "Nieznana"),
"amount": e.amount,
"added_at": e.added_at,
}
for e in expenses
]
lists_data = [
{
"id": l.id,
"title": l.title,
"created_at": l.created_at,
"total_expense": totals_map.get(l.id, 0.0),
"owner_username": l.owner.username if l.owner else "?",
"categories": [c.id for c in l.categories],
}
for l in lists_filtered
]
return render_template(
"expenses.html",
expense_table=expense_table,
lists_data=lists_data,
categories=categories,
selected_category=category_id,
show_all=show_all,
)
@app.route("/expenses_data")
@login_required
def expenses_data():
range_type = request.args.get("range", "monthly")
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
show_all = request.args.get("show_all", "true").lower() == "true"
category_id = request.args.get("category_id")
by_category = request.args.get("by_category", "false").lower() == "true"
if not start_date or not end_date:
sd, ed, bucket = resolve_range(range_type)
if sd and ed:
start_date = sd
end_date = ed
range_type = bucket
if by_category:
result = get_total_expenses_grouped_by_category(
show_all=show_all,
range_type=range_type,
start_date=start_date,
end_date=end_date,
user_id=current_user.id,
category_id=category_id,
)
else:
result = get_total_expenses_grouped_by_list_created_at(
user_only=False,
admin=False,
show_all=show_all,
range_type=range_type,
start_date=start_date,
end_date=end_date,
user_id=current_user.id,
category_id=category_id,
)
if "error" in result:
return jsonify({"error": result["error"]}), 400
return jsonify(result)
@app.route("/api/expenses/latest")
@api_token_required
@require_api_scope('expenses:read')
def api_latest_expenses():
start_date_str = (request.args.get("start_date") or "").strip() or None
end_date_str = (request.args.get("end_date") or "").strip() or None
list_id = request.args.get("list_id", type=int)
owner_id = request.args.get("owner_id", type=int)
limit = request.args.get("limit", default=50, type=int) or 50
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
limit = max(1, min(limit, int(token_limit or 500), 500))
try:
start_date, end_date = parse_api_date_range(start_date_str, end_date_str)
except ValueError as exc:
return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400
filter_query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id)
if start_date:
filter_query = filter_query.filter(Expense.added_at >= start_date)
if end_date:
filter_query = filter_query.filter(Expense.added_at < end_date)
if list_id:
filter_query = filter_query.filter(Expense.list_id == list_id)
if owner_id:
filter_query = filter_query.filter(ShoppingList.owner_id == owner_id)
total_count = filter_query.with_entities(func.count(Expense.id)).scalar() or 0
total_amount = float(filter_query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0)
expenses = (
filter_query.options(
joinedload(Expense.shopping_list).joinedload(ShoppingList.owner),
joinedload(Expense.shopping_list).joinedload(ShoppingList.categories),
)
.order_by(Expense.added_at.desc(), Expense.id.desc())
.limit(limit)
.all()
)
items = []
for expense in expenses:
shopping_list = expense.shopping_list
owner = shopping_list.owner if shopping_list else None
items.append(
{
"expense_id": expense.id,
"amount": round(float(expense.amount or 0), 2),
"added_at": format_dt_for_api(expense.added_at),
"receipt_filename": expense.receipt_filename,
"list": {
"id": shopping_list.id if shopping_list else None,
"title": shopping_list.title if shopping_list else None,
"created_at": format_dt_for_api(shopping_list.created_at if shopping_list else None),
"is_archived": bool(shopping_list.is_archived) if shopping_list else None,
"is_public": bool(shopping_list.is_public) if shopping_list else None,
"categories": [c.name for c in shopping_list.categories] if shopping_list else [],
},
"owner": {
"id": owner.id if owner else None,
"username": owner.username if owner else None,
},
}
)
return jsonify(
{
"ok": True,
"filters": {
"start_date": start_date_str,
"end_date": end_date_str,
"list_id": list_id,
"owner_id": owner_id,
"limit": limit,
},
"meta": {
"returned_count": len(items),
"total_count": int(total_count),
"total_amount": round(total_amount, 2),
"token_name": g.api_token.name,
"token_prefix": g.api_token.token_prefix,
},
"items": items,
}
)
@app.route("/api/ping")
@api_token_required
def api_ping():
return jsonify({"ok": True, "message": "token accepted", "token_name": g.api_token.name, "token_prefix": g.api_token.token_prefix})
@app.route("/api/expenses/summary")
@api_token_required
@require_api_scope('expenses:read')
def api_expenses_summary():
start_date_str = (request.args.get("start_date") or "").strip() or None
end_date_str = (request.args.get("end_date") or "").strip() or None
list_id = request.args.get("list_id", type=int)
owner_id = request.args.get("owner_id", type=int)
try:
start_date, end_date = parse_api_date_range(start_date_str, end_date_str)
except ValueError as exc:
return jsonify({"ok": False, "error": "bad_request", "message": str(exc)}), 400
query = Expense.query.join(ShoppingList, ShoppingList.id == Expense.list_id)
if start_date:
query = query.filter(Expense.added_at >= start_date)
if end_date:
query = query.filter(Expense.added_at < end_date)
if list_id:
query = query.filter(Expense.list_id == list_id)
if owner_id:
query = query.filter(ShoppingList.owner_id == owner_id)
total_count = int(query.with_entities(func.count(Expense.id)).scalar() or 0)
total_amount = float(query.with_entities(func.coalesce(func.sum(Expense.amount), 0)).scalar() or 0)
by_list = (
query.with_entities(ShoppingList.id, ShoppingList.title, func.count(Expense.id), func.coalesce(func.sum(Expense.amount), 0))
.group_by(ShoppingList.id, ShoppingList.title)
.order_by(func.coalesce(func.sum(Expense.amount), 0).desc(), ShoppingList.id.desc())
.limit(100)
.all()
)
return jsonify({
"ok": True,
"filters": {"start_date": start_date_str, "end_date": end_date_str, "list_id": list_id, "owner_id": owner_id},
"meta": {"total_count": total_count, "total_amount": round(total_amount, 2)},
"lists": [{"id": row[0], "title": row[1], "expense_count": int(row[2] or 0), "total_amount": round(float(row[3] or 0), 2)} for row in by_list],
})
@app.route("/api/lists")
@api_token_required
@require_api_scope('lists:read')
def api_lists():
owner_id = request.args.get("owner_id", type=int)
limit = request.args.get("limit", default=50, type=int) or 50
token_limit = getattr(g, 'api_token', None).max_limit if getattr(g, 'api_token', None) else 500
limit = max(1, min(limit, int(token_limit or 500), 500))
query = ShoppingList.query.options(joinedload(ShoppingList.owner), joinedload(ShoppingList.categories)).order_by(ShoppingList.created_at.desc(), ShoppingList.id.desc())
if owner_id:
query = query.filter(ShoppingList.owner_id == owner_id)
rows = query.limit(limit).all()
return jsonify({
"ok": True,
"items": [{
"id": row.id,
"title": row.title,
"created_at": format_dt_for_api(row.created_at),
"owner": {"id": row.owner.id if row.owner else None, "username": row.owner.username if row.owner else None},
"is_temporary": bool(row.is_temporary),
"expires_at": format_dt_for_api(row.expires_at),
"is_archived": bool(row.is_archived),
"is_public": bool(row.is_public),
"categories": [c.name for c in row.categories],
} for row in rows],
})
@app.route("/api/lists/<int:list_id>/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/<token>")
# @app.route("/guest-list/<int:list_id>")
@app.route("/shared/<int:list_id>")
def shared_list(token=None, list_id=None):
now = datetime.now(timezone.utc)
if token:
shopping_list = ShoppingList.query.filter_by(share_token=token).first_or_404()
expires_at = shopping_list.expires_at
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
# jeśli lista wygasła – zablokuj (spójne z resztą aplikacji)
if shopping_list.is_temporary and expires_at and expires_at <= now:
flash("Link wygasł.", "warning")
return redirect(url_for("main_page"))
list_id = shopping_list.id
# jeśli zalogowany i nie jest właścicielem — auto-przypisz stałe uprawnienie
if current_user.is_authenticated and current_user.id != shopping_list.owner_id:
exists = (
db.session.query(ListPermission.id)
.filter(
ListPermission.list_id == shopping_list.id,
ListPermission.user_id == current_user.id,
)
.first()
)
if not exists:
db.session.add(
ListPermission(list_id=shopping_list.id, user_id=current_user.id)
)
db.session.commit()
else:
shopping_list = ShoppingList.query.get_or_404(list_id)
expires_at = shopping_list.expires_at
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if shopping_list.is_temporary and expires_at and expires_at <= now:
flash("Ta lista wygasła.", "warning")
return redirect(url_for("main_page"))
is_allowed = shopping_list.is_public
if current_user.is_authenticated:
is_allowed = is_allowed or shopping_list.owner_id == current_user.id or (
db.session.query(ListPermission.id)
.filter(
ListPermission.list_id == shopping_list.id,
ListPermission.user_id == current_user.id,
)
.first()
is not None
)
if not is_allowed:
flash("Ta lista nie jest publicznie dostępna.", "warning")
return redirect(url_for("main_page"))
total_expense = get_total_expense_for_list(list_id)
shopping_list, items, receipts, expenses, total_expense = get_list_details(list_id)
shopping_list.category_badges = [
{"name": c.name, "color": category_color_for(c)}
for c in shopping_list.categories
]
for item in items:
if item.added_by and item.added_by != shopping_list.owner_id:
item.added_by_display = (
item.added_by_user.username if item.added_by_user else None
)
else:
item.added_by_display = None
return render_template(
"list_share.html",
list=shopping_list,
items=items,
receipts=receipts,
expenses=expenses,
total_expense=total_expense,
is_share=True,
)
@app.route("/copy/<int:list_id>")
@login_required
def copy_list(list_id):
original = ShoppingList.query.get_or_404(list_id)
token = generate_share_token(8)
new_list = ShoppingList(
title=original.title + " (Kopia)", owner_id=current_user.id, share_token=token
)
db.session.add(new_list)
db.session.commit()
original_items = Item.query.filter_by(list_id=original.id).all()
for item in original_items:
copy_item = Item(list_id=new_list.id, name=item.name)
db.session.add(copy_item)
db.session.commit()
flash("Skopiowano listę", "success")
return redirect(url_for("view_list", list_id=new_list.id))
@app.route("/suggest_products")
def suggest_products():
query = request.args.get("q", "")
suggestions = []
if query:
suggestions = (
SuggestedProduct.query.filter(SuggestedProduct.name.ilike(f"%{query}%"))
.limit(5)
.all()
)
return {"suggestions": [s.name for s in suggestions]}
@app.route("/all_products")
def all_products():
sort = request.args.get("sort", "popularity")
limit = request.args.get("limit", type=int) or 100
offset = request.args.get("offset", type=int) or 0
products_from_items = db.session.query(
func.lower(func.trim(Item.name)).label("normalized_name"),
func.min(Item.name).label("display_name"),
func.count(func.distinct(Item.list_id)).label("count"),
).group_by(func.lower(func.trim(Item.name)))
products_from_suggested = (
db.session.query(
func.lower(func.trim(SuggestedProduct.name)).label("normalized_name"),
func.min(SuggestedProduct.name).label("display_name"),
db.literal(1).label("count"),
)
.filter(
~func.lower(func.trim(SuggestedProduct.name)).in_(
db.session.query(func.lower(func.trim(Item.name)))
)
)
.group_by(func.lower(func.trim(SuggestedProduct.name)))
)
union_q = products_from_items.union_all(products_from_suggested).subquery()
final_q = db.session.query(
union_q.c.normalized_name,
union_q.c.display_name,
func.sum(union_q.c.count).label("count"),
).group_by(union_q.c.normalized_name, union_q.c.display_name)
if sort == "alphabetical":
final_q = final_q.order_by(func.lower(union_q.c.display_name).asc())
else:
final_q = final_q.order_by(
func.sum(union_q.c.count).desc(), func.lower(union_q.c.display_name).asc()
)
total_count = (
db.session.query(func.count()).select_from(final_q.subquery()).scalar()
)
products = final_q.offset(offset).limit(limit).all()
out = [{"name": row.display_name, "count": row.count} for row in products]
return jsonify({"products": out, "total_count": total_count})
@app.route("/upload_receipt/<int:list_id>", methods=["POST"])
@login_required
def upload_receipt(list_id):
l = db.session.get(ShoppingList, list_id)
file = request.files.get("receipt")
if not file or file.filename == "":
return receipt_error("Nie wybrano pliku")
if not allowed_file(file.filename):
return receipt_error("Niedozwolony format pliku")
file_bytes = file.read()
file.seek(0)
file_hash = hashlib.sha256(file_bytes).hexdigest()
existing = Receipt.query.filter_by(file_hash=file_hash).first()
if existing:
return receipt_error("Taki plik już istnieje")
now = datetime.now(timezone.utc)
timestamp = now.strftime("%Y%m%d_%H%M")
random_part = secrets.token_hex(3)
webp_filename = f"list_{list_id}_{timestamp}_{random_part}.webp"
file_path = os.path.join(app.config["UPLOAD_FOLDER"], webp_filename)
try:
if file.filename.lower().endswith(".pdf"):
file.seek(0)
save_pdf_as_webp(file, file_path)
else:
save_resized_image(file, file_path)
except ValueError as e:
return receipt_error(str(e))
try:
new_receipt = Receipt(
list_id=list_id,
filename=webp_filename,
filesize=os.path.getsize(file_path),
uploaded_at=now,
file_hash=file_hash,
uploaded_by=current_user.id,
version_token=generate_version_token(),
)
db.session.add(new_receipt)
db.session.commit()
except Exception as e:
return receipt_error(f"Błąd zapisu do bazy: {str(e)}")
if request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest":
url = (
url_for("uploaded_file", filename=webp_filename)
+ f"?v={new_receipt.version_token or '0'}"
)
socketio.emit("receipt_added", {"url": url}, to=str(list_id))
return jsonify({"success": True, "url": url})
flash("Wgrano paragon", "success")
return redirect(request.referrer or url_for("main_page"))
@app.route("/uploads/<filename>")
def uploaded_file(filename):
response = send_from_directory(app.config["UPLOAD_FOLDER"], filename)
response.headers["Cache-Control"] = app.config["UPLOADS_CACHE_CONTROL"]
response.headers.pop("Content-Disposition", None)
mime, _ = mimetypes.guess_type(filename)
if mime:
response.headers["Content-Type"] = mime
return response
@app.route("/reorder_items", methods=["POST"])
@login_required
def reorder_items():
data = request.get_json()
list_id = data.get("list_id")
order = data.get("order")
for index, item_id in enumerate(order):
item = db.session.get(Item, item_id)
if item and item.list_id == list_id:
item.position = index
db.session.commit()
socketio.emit(
"items_reordered", {"list_id": list_id, "order": order}, to=str(list_id)
)
return jsonify(success=True)
@app.route("/rotate_receipt/<int:receipt_id>")
@login_required
def rotate_receipt_user(receipt_id):
receipt = Receipt.query.get_or_404(receipt_id)
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
if not (current_user.is_admin or current_user.id == list_obj.owner_id):
flash("Brak uprawnień do tej operacji", "danger")
return redirect(url_for("main_page"))
try:
rotate_receipt_by_id(receipt_id)
recalculate_filesizes(receipt_id)
flash("Obrócono paragon", "success")
except FileNotFoundError:
flash("Plik nie istnieje", "danger")
except Exception as e:
flash(f"Błąd przy obracaniu: {str(e)}", "danger")
return redirect(request.referrer or url_for("main_page"))
@app.route("/delete_receipt/<int:receipt_id>")
@login_required
def delete_receipt_user(receipt_id):
receipt = Receipt.query.get_or_404(receipt_id)
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
if not (current_user.is_admin or current_user.id == list_obj.owner_id):
flash("Brak uprawnień do tej operacji", "danger")
return redirect(url_for("main_page"))
try:
delete_receipt_by_id(receipt_id)
flash("Paragon usunięty", "success")
except Exception as e:
flash(f"Błąd przy usuwaniu pliku: {str(e)}", "danger")
return redirect(request.referrer or url_for("main_page"))
# OCR
@app.route("/lists/<int:list_id>/analyze", methods=["POST"])
@login_required
def analyze_receipts_for_list(list_id):
receipt_objs = Receipt.query.filter_by(list_id=list_id).all()
existing_expenses = {
e.receipt_filename
for e in Expense.query.filter_by(list_id=list_id).all()
if e.receipt_filename
}
results = []
total = 0.0
for receipt in receipt_objs:
filepath = os.path.join(app.config["UPLOAD_FOLDER"], receipt.filename)
if not os.path.exists(filepath):
continue
try:
raw_image = Image.open(filepath).convert("RGB")
image = preprocess_image_for_tesseract(raw_image)
value, lines = extract_total_tesseract(image)
except Exception as e:
print(f"OCR error for {receipt.filename}:\n{traceback.format_exc()}")
value = 0.0
lines = []
already_added = receipt.filename in existing_expenses
results.append(
{
"id": receipt.id,
"filename": receipt.filename,
"amount": round(value, 2),
"debug_text": lines,
"already_added": already_added,
}
)
# if not already_added:
total += value
return jsonify({"results": results, "total": round(total, 2)})
@app.route("/user_crop_receipt", methods=["POST"])
@login_required
def crop_receipt_user():
receipt_id = request.form.get("receipt_id")
file = request.files.get("cropped_image")
receipt = Receipt.query.get_or_404(receipt_id)
list_obj = ShoppingList.query.get_or_404(receipt.list_id)
if list_obj.owner_id != current_user.id and not current_user.is_admin:
return jsonify(success=False, error="Brak dostępu"), 403
result = handle_crop_receipt(receipt_id, file)
return jsonify(result)
+785
View File
@@ -0,0 +1,785 @@
import click
from .deps import *
from .app_setup import *
from .models import *
from .helpers import *
from flask import render_template_string
@app.route('/admin/debug-socket')
@login_required
@admin_required
def debug_socket():
return render_template_string('''
<!DOCTYPE html>
<html>
<head>
<title>Socket Debug</title>
<script src="{{ url_for('static_bp.serve_js_lib', filename='socket.io.min.js') }}?v={{ APP_VERSION }}"></script>
<style>
body { font-family: monospace; background: #1e1e1e; color: #fff; padding: 20px; }
#log { height: 400px; overflow-y: scroll; background: #2d2d2d; padding: 15px; border-radius: 8px; margin: 10px 0; white-space: pre-wrap; }
button { background: #007bff; color: white; border: none; padding: 10px 20px; margin: 5px; border-radius: 5px; cursor: pointer; }
button:hover { background: #0056b3; }
.status { font-size: 18px; font-weight: bold; margin: 10px 0; }
.connected { color: #28a745; }
.disconnected { color: #dc3545; }
</style>
</head>
<body>
<h1>Socket.IO Debug Tool</h1>
<div id="status" class="status disconnected">Rozlaczony</div>
<div id="info">
Transport: <span id="transport">-</span> |
Ping: <span id="ping">-</span>ms |
SID: <span id="sid">-</span>
</div>
<button onclick="connect()">Polacz</button>
<button onclick="disconnect()">Rozlacz</button>
<button onclick="emitTest()">Emit Test</button>
<button onclick="forcePolling()">Force Polling</button>
<h3>Logi:</h3>
<div id="log"></div>
<script>
let socket;
let logLines = 0;
let isPollingOnly = true;
function log(msg, color = '#fff') {
const logEl = document.getElementById('log');
const time = new Date().toLocaleTimeString();
logEl.innerHTML += `[${time}] ${msg}\n`;
logEl.scrollTop = logEl.scrollHeight;
logLines++;
if (logLines > 200) {
const lines = logEl.innerHTML.split('\\n');
logEl.innerHTML = lines.slice(-200).join('\\n');
logLines = 200;
}
}
function updateStatus(connected) {
const status = document.getElementById('status');
status.textContent = connected ? 'Polaczony' : 'Rozlaczony';
status.className = `status ${connected ? 'connected' : 'disconnected'}`;
}
function connect() {
if (socket) {
socket.disconnect();
socket = null;
}
const transports = isPollingOnly ? ['polling'] : ['polling', 'websocket'];
log(`Polaczenie z: ${transports.join(', ')}`);
socket = io('', {
transports: transports,
timeout: 20000,
autoConnect: false,
forceNew: true
});
socket.on('connect', function() {
log('CONNECTED OK');
updateStatus(true);
try {
const transport = socket.io.engine.transport.name;
document.getElementById('transport').textContent = transport;
document.getElementById('sid').textContent = socket.id.substring(0,8) + '...';
} catch(e) {
log('Transport info error: ' + e.message);
}
socket.emit('requestfulllist', {listid: 1});
});
socket.on('disconnect', function(reason) {
log('DISCONNECTED: ' + reason);
updateStatus(false);
});
socket.on('connect_error', function(err) {
log('CONNECT ERROR: ' + err.message + ' (' + (err.type || 'unknown') + ')');
});
socket.onAny(function(event, ...args) {
log('RECV ' + event + ': ' + JSON.stringify(args).substring(0,100));
});
socket.connect();
}
function disconnect() {
if (socket) {
socket.disconnect();
socket = null;
}
}
function emitTest() {
if (!socket || !socket.connected) {
log('Niepolaczony!');
return;
}
const now = Date.now();
socket.emit('pingtest', now);
log('SENT pingtest ' + now);
}
function forcePolling() {
isPollingOnly = !isPollingOnly;
log('Polling only: ' + isPollingOnly);
connect();
}
// STATUS check co 30s
setInterval(function() {
if (socket && socket.connected) {
const transport = socket.io.engine ? socket.io.engine.transport.name : 'unknown';
log('STATUS OK: ' + transport + ' | SID: ' + (socket.id ? socket.id.substring(0,8) : 'none'));
emitTest();
} else {
log('STATUS: Offline');
}
}, 30000);
// Start
connect();
</script>
</body>
</html>
''')
# =========================================================================================
# SOCKET.IO
# =========================================================================================
@socketio.on("delete_item")
def handle_delete_item(data):
# item = Item.query.get(data["item_id"])
item = db.session.get(Item, data["item_id"])
if item:
list_id = item.list_id
log_list_activity(list_id, 'item_deleted', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.delete(item)
db.session.commit()
emit("item_deleted", {"item_id": item.id}, to=str(item.list_id))
purchased_count, total_count, percent = get_progress(list_id)
emit(
"progress_updated",
{
"purchased_count": purchased_count,
"total_count": total_count,
"percent": percent,
},
to=str(list_id),
)
@socketio.on("edit_item")
def handle_edit_item(data):
item = db.session.get(Item, data["item_id"])
new_name = data["new_name"]
new_quantity = data.get("new_quantity", item.quantity)
if item and new_name.strip():
item.name = new_name.strip()
try:
new_quantity = int(new_quantity)
if new_quantity < 1:
new_quantity = 1
except:
new_quantity = 1
item.quantity = new_quantity
db.session.commit()
emit(
"item_edited",
{"item_id": item.id, "new_name": item.name, "new_quantity": item.quantity},
to=str(item.list_id),
)
@socketio.on("join_list")
def handle_join(data):
global active_users
room = str(data["room"])
username = data.get("username", "Gość")
join_room(room)
if room not in active_users:
active_users[room] = set()
active_users[room].add(username)
shopping_list = db.session.get(ShoppingList, int(data["room"]))
list_title = shopping_list.title if shopping_list else "Twoja lista"
emit("user_joined", {"username": username}, to=room)
emit("user_list", {"users": list(active_users[room])}, to=room)
emit("joined_confirmation", {"room": room, "list_title": list_title})
@socketio.on("disconnect")
def handle_disconnect(sid):
global active_users
username = current_user.username if current_user.is_authenticated else "Gość"
for room, users in active_users.items():
if username in users:
users.remove(username)
emit("user_left", {"username": username}, to=room)
emit("user_list", {"users": list(users)}, to=room)
@socketio.on("add_item")
def handle_add_item(data):
list_id = data["list_id"]
name = data["name"].strip()
quantity = data.get("quantity", 1)
list_obj = db.session.get(ShoppingList, list_id)
if not list_obj:
return
try:
quantity = int(quantity)
if quantity < 1:
quantity = 1
except:
quantity = 1
existing_item = Item.query.filter(
Item.list_id == list_id,
func.lower(Item.name) == name.lower(),
Item.not_purchased == False,
).first()
if existing_item:
existing_item.quantity += quantity
db.session.commit()
emit(
"item_edited",
{
"item_id": existing_item.id,
"new_name": existing_item.name,
"new_quantity": existing_item.quantity,
},
to=str(list_id),
)
else:
max_position = (
db.session.query(func.max(Item.position))
.filter_by(list_id=list_id)
.scalar()
)
if max_position is None:
max_position = 0
user_id = current_user.id if current_user.is_authenticated else None
user_name = current_user.username if current_user.is_authenticated else "Gość"
new_item = Item(
list_id=list_id,
name=name,
quantity=quantity,
position=max_position + 1,
added_by=user_id,
)
db.session.add(new_item)
if not SuggestedProduct.query.filter(
func.lower(SuggestedProduct.name) == name.lower()
).first():
new_suggestion = SuggestedProduct(name=name)
db.session.add(new_suggestion)
log_list_activity(list_id, 'item_added', item_name=new_item.name, actor=current_user if current_user.is_authenticated else None, actor_name=user_name, details=f'ilość: {new_item.quantity}')
db.session.commit()
emit(
"item_added",
{
"id": new_item.id,
"name": new_item.name,
"quantity": new_item.quantity,
"added_by": user_name,
"added_by_id": user_id,
"owner_id": list_obj.owner_id,
},
to=str(list_id),
include_self=True,
)
purchased_count, total_count, percent = get_progress(list_id)
emit(
"progress_updated",
{
"purchased_count": purchased_count,
"total_count": total_count,
"percent": percent,
},
to=str(list_id),
)
@socketio.on("check_item")
def handle_check_item(data):
item = db.session.get(Item, data["item_id"])
if item:
item.purchased = True
item.purchased_at = datetime.now(UTC)
item.not_purchased = False
item.not_purchased_reason = None
log_list_activity(item.list_id, 'item_checked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.commit()
purchased_count, total_count, percent = get_progress(item.list_id)
emit("item_checked", {"item_id": item.id}, to=str(item.list_id))
emit(
"progress_updated",
{
"purchased_count": purchased_count,
"total_count": total_count,
"percent": percent,
},
to=str(item.list_id),
)
@socketio.on("uncheck_item")
def handle_uncheck_item(data):
item = db.session.get(Item, data["item_id"])
if item:
item.purchased = False
item.purchased_at = None
log_list_activity(item.list_id, 'item_unchecked', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.commit()
purchased_count, total_count, percent = get_progress(item.list_id)
emit("item_unchecked", {"item_id": item.id}, to=str(item.list_id))
emit(
"progress_updated",
{
"purchased_count": purchased_count,
"total_count": total_count,
"percent": percent,
},
to=str(item.list_id),
)
@socketio.on("request_full_list")
def handle_request_full_list(data):
list_id = data["list_id"]
shopping_list = db.session.get(ShoppingList, list_id)
if not shopping_list:
return
owner_id = shopping_list.owner_id
items = (
Item.query.options(joinedload(Item.added_by_user))
.filter_by(list_id=list_id)
.order_by(Item.position.asc())
.all()
)
items_data = []
for item in items:
items_data.append(
{
"id": item.id,
"name": item.name,
"quantity": item.quantity,
"purchased": item.purchased if not item.not_purchased else False,
"not_purchased": item.not_purchased,
"not_purchased_reason": item.not_purchased_reason,
"note": item.note or "",
"added_by": item.added_by_user.username if item.added_by_user else None,
"added_by_id": item.added_by_user.id if item.added_by_user else None,
"owner_id": owner_id,
}
)
emit("full_list", {"items": items_data}, to=request.sid)
@socketio.on("update_note")
def handle_update_note(data):
item_id = data["item_id"]
note = data["note"]
item = Item.query.get(item_id)
if item:
item.note = note
db.session.commit()
emit("note_updated", {"item_id": item_id, "note": note}, to=str(item.list_id))
@socketio.on("add_expense")
def handle_add_expense(data):
list_id = data["list_id"]
amount = data["amount"]
receipt_filename = data.get("receipt_filename")
if receipt_filename:
existing = Expense.query.filter_by(
list_id=list_id, receipt_filename=receipt_filename
).first()
if existing:
return
new_expense = Expense(
list_id=list_id, amount=amount, receipt_filename=receipt_filename
)
db.session.add(new_expense)
log_list_activity(list_id, 'expense_added', item_name=None, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=f'kwota: {float(amount):.2f} PLN')
db.session.commit()
total = (
db.session.query(func.sum(Expense.amount)).filter_by(list_id=list_id).scalar()
or 0
)
emit("expense_added", {"amount": amount, "total": total}, to=str(list_id))
@socketio.on("mark_not_purchased")
def handle_mark_not_purchased(data):
item = db.session.get(Item, data["item_id"])
reason = data.get("reason", "")
if item:
item.not_purchased = True
item.not_purchased_reason = reason
item.purchased = False
item.purchased_at = None
log_list_activity(item.list_id, 'item_marked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość', details=reason or None)
db.session.commit()
emit(
"item_marked_not_purchased",
{"item_id": item.id, "reason": reason},
to=str(item.list_id),
)
@socketio.on("unmark_not_purchased")
def handle_unmark_not_purchased(data):
item = db.session.get(Item, data["item_id"])
if item:
item.not_purchased = False
item.purchased = False
item.purchased_at = None
item.not_purchased_reason = None
log_list_activity(item.list_id, 'item_unmarked_not_purchased', item_name=item.name, actor=current_user if current_user.is_authenticated else None, actor_name=current_user.username if current_user.is_authenticated else 'Gość')
db.session.commit()
emit("item_unmarked_not_purchased", {"item_id": item.id}, to=str(item.list_id))
@app.cli.command("db_info")
def create_db():
with app.app_context():
inspector = inspect(db.engine)
actual_tables = inspector.get_table_names()
table_count = len(actual_tables)
record_total = 0
with db.engine.connect() as conn:
for table in actual_tables:
try:
count = conn.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar()
record_total += count
except Exception:
pass
print("\nStruktura bazy danych jest poprawna.")
print(f"Silnik: {db.engine.name}")
print(f"Liczba tabel: {table_count}")
print(f"Łączna liczba rekordów: {record_total}")
if __name__ == "__main__":
logging.basicConfig(level=logging.DEBUG if DEBUG_MODE else logging.INFO)
socketio.run(app, host="0.0.0.0", port=APP_PORT, debug=False)
@app.cli.group("admins")
def admins_cli():
"""Zarzadzanie kontami administratorow z CLI."""
@admins_cli.command("list")
def admins_list_command():
with app.app_context():
users = User.query.order_by(User.username.asc()).all()
if not users:
click.echo('Brak uzytkownikow.')
return
for user in users:
role = 'admin' if user.is_admin else 'user'
click.echo(f"{user.id} {user.username} {role}")
@admins_cli.command("create")
@click.argument("username")
@click.argument("password")
@click.option("--admin/--user", "make_admin", default=True, show_default=True, help="Utworz konto admina albo zwyklego uzytkownika.")
def admins_create_command(username, password, make_admin):
with app.app_context():
user, created, _ = create_or_update_admin_user(username, password=password, make_admin=make_admin, update_password=False)
status = 'Utworzono' if created else 'Istnieje juz'
click.echo(f"{status} konto: id={user.id}, username={user.username}, admin={user.is_admin}")
@admins_cli.command("promote")
@click.argument("username")
def admins_promote_command(username):
with app.app_context():
user = resolve_user_identifier(username)
if not user:
raise click.ClickException('Nie znaleziono uzytkownika.')
user.is_admin = True
db.session.commit()
click.echo(f"Uzytkownik {user.username} ma teraz uprawnienia admina.")
@admins_cli.command("demote")
@click.argument("username")
def admins_demote_command(username):
with app.app_context():
user = resolve_user_identifier(username)
if not user:
raise click.ClickException('Nie znaleziono uzytkownika.')
user.is_admin = False
db.session.commit()
click.echo(f"Uzytkownik {user.username} nie jest juz adminem.")
@admins_cli.command("set-password")
@click.argument("username")
@click.argument("password")
def admins_set_password_command(username, password):
with app.app_context():
user = resolve_user_identifier(username)
if not user:
raise click.ClickException('Nie znaleziono uzytkownika.')
user.password_hash = hash_password(password)
db.session.commit()
click.echo(f"Zmieniono haslo dla {user.username}.")
@app.cli.group("lists")
def lists_cli():
"""Operacje CLI na listach zakupowych."""
def _load_list_for_cli(list_id: int):
return ShoppingList.query.options(joinedload(ShoppingList.items), joinedload(ShoppingList.categories), joinedload(ShoppingList.owner)).get(list_id)
def _parse_many_when_values(raw_values: str):
values = []
for part in (raw_values or '').split(','):
normalized = part.strip()
if not normalized:
continue
values.append(parse_cli_datetime(normalized))
if not values:
raise ValueError('Podaj co najmniej jedna date w --when-list.')
return values
@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 = _load_list_for_cli(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)
log_list_activity(new_list.id, 'list_duplicated', actor_name='CLI', details=f'copy-schedule ze zrodla #{source_list.id}')
db.session.commit()
click.echo(
f"Utworzono kopie listy: nowa_id={new_list.id}, tytul={new_list.title}, created_at={new_list.created_at.isoformat()}"
)
@lists_cli.command("move")
@click.option("--list-id", required=True, type=int, help="ID listy.")
@click.option("--when", "when_value", required=True, help="Nowy termin listy: YYYY-MM-DD lub YYYY-MM-DD HH:MM")
@click.option("--keep-item-times", is_flag=True, help="Nie przesuwaj added_at/purchased_at pozycji.")
@click.option("--keep-expiry", is_flag=True, help="Nie przesuwaj expires_at.")
def lists_move_command(list_id, when_value, keep_item_times, keep_expiry):
with app.app_context():
shopping_list = _load_list_for_cli(list_id)
if not shopping_list:
raise click.ClickException('Nie znaleziono listy.')
try:
new_when = parse_cli_datetime(when_value)
except ValueError as exc:
raise click.ClickException(str(exc))
old_created = shopping_list.created_at
move_list_schedule(shopping_list, new_when, keep_item_times=keep_item_times, keep_expiry=keep_expiry)
log_list_activity(shopping_list.id, 'list_moved', actor_name='CLI', details=f'Z {old_created} na {shopping_list.created_at}')
db.session.commit()
click.echo(f'Przeniesiono liste #{shopping_list.id} na {shopping_list.created_at.isoformat()}')
@lists_cli.command("archive")
@click.option("--list-id", required=True, type=int, help="ID listy.")
def lists_archive_command(list_id):
with app.app_context():
shopping_list = _load_list_for_cli(list_id)
if not shopping_list:
raise click.ClickException('Nie znaleziono listy.')
set_list_archived(shopping_list, archived=True)
log_list_activity(shopping_list.id, 'list_archived', actor_name='CLI')
db.session.commit()
click.echo(f'Zarchiwizowano liste #{shopping_list.id}.')
@lists_cli.command("unarchive")
@click.option("--list-id", required=True, type=int, help="ID listy.")
def lists_unarchive_command(list_id):
with app.app_context():
shopping_list = _load_list_for_cli(list_id)
if not shopping_list:
raise click.ClickException('Nie znaleziono listy.')
set_list_archived(shopping_list, archived=False)
log_list_activity(shopping_list.id, 'list_unarchived', actor_name='CLI')
db.session.commit()
click.echo(f'Przywrocono liste #{shopping_list.id} z archiwum.')
@lists_cli.command("assign-owner")
@click.option("--list-id", required=True, type=int, help="ID listy.")
@click.option("--owner", "owner_value", required=True, help="Nowy wlasciciel: username albo ID.")
def lists_assign_owner_command(list_id, owner_value):
with app.app_context():
shopping_list = _load_list_for_cli(list_id)
if not shopping_list:
raise click.ClickException('Nie znaleziono listy.')
owner = resolve_user_identifier(owner_value)
if not owner:
raise click.ClickException('Nie znaleziono docelowego wlasciciela.')
previous_owner = shopping_list.owner.username if shopping_list.owner else shopping_list.owner_id
assign_list_owner(shopping_list, owner)
log_list_activity(shopping_list.id, 'list_owner_changed', actor_name='CLI', details=f'{previous_owner} -> {owner.username}')
db.session.commit()
click.echo(f'Zmieniono wlasciciela listy #{shopping_list.id} na {owner.username}.')
@lists_cli.command("create-from-template")
@click.option("--template-id", required=True, type=int, help="ID szablonu.")
@click.option("--owner", "owner_value", required=True, help="Wlasciciel nowej listy: username albo ID.")
@click.option("--when", "when_value", default=None, help="Termin utworzenia: YYYY-MM-DD lub YYYY-MM-DD HH:MM")
@click.option("--title", default=None, help="Tytul nowej listy.")
def lists_create_from_template_command(template_id, owner_value, when_value, title):
with app.app_context():
template = ListTemplate.query.options(joinedload(ListTemplate.items)).get(template_id)
if not template:
raise click.ClickException('Nie znaleziono szablonu.')
owner = resolve_user_identifier(owner_value)
if not owner:
raise click.ClickException('Nie znaleziono docelowego wlasciciela.')
try:
scheduled_for = parse_cli_datetime(when_value) if when_value else datetime.now(timezone.utc)
except ValueError as exc:
raise click.ClickException(str(exc))
new_list = create_list_from_template_at_schedule(template, owner=owner, scheduled_for=scheduled_for, title=title)
log_list_activity(new_list.id, 'template_created', actor_name='CLI', details=f'create-from-template z szablonu #{template.id}')
db.session.commit()
click.echo(f'Utworzono liste z szablonu: nowa_id={new_list.id}, tytul={new_list.title}, created_at={new_list.created_at.isoformat()}')
@lists_cli.command("delete")
@click.option("--list-id", required=True, type=int, help="ID listy.")
def lists_delete_command(list_id):
with app.app_context():
shopping_list = _load_list_for_cli(list_id)
if not shopping_list:
raise click.ClickException('Nie znaleziono listy.')
title = shopping_list.title
delete_list_with_relations(shopping_list)
db.session.commit()
click.echo(f'Usunieto liste #{list_id}: {title}')
@lists_cli.command("rename")
@click.option("--list-id", required=True, type=int, help="ID listy.")
@click.option("--title", "new_title", required=True, help="Nowy tytul listy.")
def lists_rename_command(list_id, new_title):
with app.app_context():
shopping_list = _load_list_for_cli(list_id)
if not shopping_list:
raise click.ClickException('Nie znaleziono listy.')
old_title = shopping_list.title
try:
rename_list(shopping_list, new_title)
except ValueError as exc:
raise click.ClickException(str(exc))
log_list_activity(shopping_list.id, 'list_renamed', actor_name='CLI', details=f'{old_title} -> {shopping_list.title}')
db.session.commit()
click.echo(f'Zmieniono tytul listy #{shopping_list.id} na: {shopping_list.title}')
@lists_cli.command("duplicate-many")
@click.option("--source-list-id", required=True, type=int, help="ID listy zrodlowej.")
@click.option("--when-list", required=True, help="Lista terminow rozdzielona przecinkami.")
@click.option("--owner", "owner_value", default=None, help="Nowy wlasciciel: username albo ID.")
@click.option("--title-prefix", default=None, help="Prefiks tytulu dla nowych list.")
def lists_duplicate_many_command(source_list_id, when_list, owner_value, title_prefix):
with app.app_context():
source_list = _load_list_for_cli(source_list_id)
if not source_list:
raise click.ClickException('Nie znaleziono listy zrodlowej.')
owner = None
if owner_value:
owner = resolve_user_identifier(owner_value)
if not owner:
raise click.ClickException('Nie znaleziono docelowego wlasciciela.')
try:
schedule_values = _parse_many_when_values(when_list)
except ValueError as exc:
raise click.ClickException(str(exc))
created_lists = duplicate_list_many(source_list, schedule_values=schedule_values, owner=owner, title_prefix=title_prefix)
for new_list in created_lists:
log_list_activity(new_list.id, 'list_duplicated', actor_name='CLI', details=f'duplicate-many ze zrodla #{source_list.id}')
db.session.commit()
click.echo('Utworzono listy: ' + ', '.join([f'#{row.id}@{row.created_at.isoformat()}' for row in created_lists]))
+95
View File
@@ -0,0 +1,95 @@
import os
import sys
import platform
import socket
from datetime import datetime
import psutil
try:
from sqlalchemy import text
except Exception:
text = None
def mb(x):
return int(x / 1024 / 1024)
def get_db_type(app):
uri = app.config.get("SQLALCHEMY_DATABASE_URI") or app.config.get("DATABASE_URL", "")
if not uri:
return "NONE"
if uri.startswith("sqlite"):
return "SQLite"
if uri.startswith("mysql"):
return "MySQL"
if uri.startswith("postgresql"):
return "PostgreSQL"
return "OTHER"
def print_startup_info(app):
host = os.getenv("HOST", "127.0.0.1")
port = int(os.getenv("PORT", "8000"))
rules = list(app.url_map.iter_rules())
cpu = psutil.cpu_percent(interval=0.2)
ram = psutil.virtual_memory()
proc = psutil.Process(os.getpid())
db_type = get_db_type(app)
print("\n" + "="*52)
print(" APP START")
print("="*52)
# SYSTEM
print("\n[ SYSTEM ]")
print(f"Time : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"OS : {platform.system()} {platform.release()} ({platform.machine()})")
print(f"Python : {sys.version.split()[0]}")
print(f"Host : {socket.gethostname()}")
# SERVER
print("\n[ SERVER ]")
print(f"Bind : {host}:{port}")
print(f"URL : http://127.0.0.1:{port}")
# APP
print("\n[ APP ]")
print(f"Name : {app.name}")
print(f"Mode : {'DEV' if app.debug else 'PROD'}")
print(f"Debug : {app.debug}")
# RESOURCES
print("\n[ RESOURCES ]")
print(f"CPU : {cpu:>5.1f}%")
print(f"RAM : {ram.percent:>5.1f}% ({mb(ram.used)} / {mb(ram.total)} MB)")
print(f"PROC : {mb(proc.memory_info().rss)} MB")
# DATABASE
print("\n[ DATABASE ]")
print(f"Type : {db_type}")
# SECURITY
print("\n[ SECURITY ]")
print(f"Secret : {'OK' if app.config.get('SECRET_KEY') else 'MISSING'}")
print(f"Talis : {'OFF' if app.config.get('TALISMAN_DISABLED') else 'ON'}")
# HEALTH
print("\n[ HEALTH ]")
print(f"Uploads: {'OK' if os.path.exists('uploads') else 'MISS'}")
print(f"Static : {'OK' if os.path.exists(app.static_folder) else 'MISS'}")
# ROUTES
print("\n[ ROUTES ]")
print(f"Total : {len(rules)}")
# STATUS
print("\n[ STATUS ]")
print("READY")
print("="*52 + "\n")
+52
View File
@@ -0,0 +1,52 @@
/* =========================================================
Variables (single source of truth)
========================================================= */
:root {
/* brand / info */
--primary: #184076;
--primary-border: #153866;
--primary-text: #e6f0ff;
--info: var(--primary);
--info-border: var(--primary-border);
--info-text: var(--primary-text);
/* success */
--success: #1c6930;
--success-border: #165024;
--success-text: #eaffea;
/* warning */
--warning: #665c1e;
--warning-border: #4d4415;
--warning-text: #fffbe5;
/* danger */
--danger: #6e1a1e;
--danger-border: #531417;
--danger-text: #ffeaea;
/* neutrals / dark */
--dark-900: #181a1b;
--dark-800: #1c1f22;
--dark-750: #1f2225;
--dark-700: #212529;
--dark-650: #23272a;
--dark-600: #2a2d31;
--dark-550: #2b2f33;
--dark-500: #2c2f33;
--dark-480: #2c3034;
--dark-470: #2a2d31;
--dark-450: #3a3f44;
--dark-400: #343a40;
--dark-350: #3d4248;
--dark-300: #495057;
--text-strong: #f8f9fa;
--text: #e2e3e5;
--text-dim: #e1e1e1;
--muted: #6c757d;
/* defaults */
--progress-default: #3d7bd6;
}
+46
View File
@@ -0,0 +1,46 @@
/* =========================================================
Utilities & Sizes
========================================================= */
.large-checkbox {
width: 1.5em;
height: 1.5em;
}
.clickable-item {
cursor: pointer;
}
.progress-thin {
height: 12px;
}
.item-not-checked {
background-color: var(--dark-500) !important;
color: #fff !important;
}
#empty-placeholder {
font-style: italic;
pointer-events: none;
}
.fade-out {
opacity: 0;
transition: opacity 0.5s ease;
}
@media (pointer: fine) {
.only-mobile {
display: none !important;
}
}
/* Bootstrap bg overrides via variables */
.bg-success {
background-color: var(--success) !important;
}
.bg-warning {
background-color: var(--warning) !important;
}
+84
View File
@@ -0,0 +1,84 @@
/* =========================================================
Buttons
========================================================= */
/* Primary */
.btn-primary {
background-color: var(--primary) !important;
border-color: var(--primary-border) !important;
}
.btn-primary:hover,
.btn-primary:focus,
.btn-primary:active {
background-color: #13315f !important;
border-color: #10284f !important;
}
/* Success */
.btn-success {
background-color: var(--success) !important;
border-color: var(--success-border) !important;
color: #fff !important;
}
.btn-success:hover,
.btn-success:focus,
.btn-success:active {
background-color: #155627 !important;
border-color: #124521 !important;
color: #fff !important;
}
/* Warning */
.btn-warning {
background-color: var(--warning) !important;
border-color: var(--warning-border) !important;
color: var(--warning-text) !important;
}
.btn-warning:hover,
.btn-warning:focus,
.btn-warning:active {
background-color: #5c4c17 !important;
border-color: #3e3610 !important;
color: var(--warning-text) !important;
}
/* Outline */
.btn-outline-success {
color: var(--success) !important;
border-color: var(--success) !important;
}
.btn-outline-success:hover,
.btn-outline-success:focus,
.btn-outline-success:active {
background-color: var(--success) !important;
border-color: var(--success-border) !important;
color: #fff !important;
}
.btn-outline-warning {
color: #d9c97a !important;
border-color: var(--warning) !important;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus,
.btn-outline-warning:active {
background-color: var(--warning) !important;
border-color: var(--warning-border) !important;
color: var(--warning-text) !important;
}
/* File input button */
input[type="file"]::file-selector-button {
background-color: #1b4a29;
color: #f0f0f0;
border: none;
padding: .5em 1em;
border-radius: 4px;
font-weight: bold;
cursor: pointer;
transition: background .2s;
}
+139
View File
@@ -0,0 +1,139 @@
/* =========================================================
Forms
========================================================= */
.form-select,
.form-control,
textarea.form-control {
background-color: var(--dark-700) !important;
color: var(--text-strong) !important;
border: 1px solid var(--dark-300) !important;
}
.form-select:focus,
.form-control:focus,
textarea.form-control:focus {
background-color: var(--dark-800) !important;
border-color: var(--primary) !important;
color: #fff !important;
box-shadow: 0 0 0 .25rem rgba(24, 64, 118, .35) !important;
}
.form-control:disabled,
textarea.form-control:disabled {
background-color: var(--dark-550) !important;
color: var(--muted) !important;
cursor: not-allowed;
}
/* Switch */
.form-switch .form-check-input {
background-color: var(--dark-400) !important;
border-color: var(--dark-300) !important;
}
.form-switch .form-check-input:checked {
background-color: var(--primary) !important;
border-color: var(--primary-border) !important;
}
/* Placeholders */
.form-control::placeholder,
.bg-dark .form-control::placeholder {
color: #aaa !important;
opacity: 1 !important;
}
/* Paired corners (utility) */
#tempToggle {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.create-list-input-group > input.form-control {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
/* XXL custom checkbox */
input[type="checkbox"].large-checkbox {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
width: 1.5em;
height: 1.5em;
margin: 0;
padding: 0;
outline: none;
background: none;
cursor: pointer;
position: relative;
vertical-align: middle;
}
input[type="checkbox"].large-checkbox::before {
content: '✗';
color: #dc3545;
font-size: 1.6em;
font-weight: 700;
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: color .2s;
}
input[type="checkbox"].large-checkbox:checked::before {
content: '✓';
color: #fff;
}
input[type="checkbox"].large-checkbox:disabled::before {
opacity: .5;
cursor: not-allowed;
}
input[type="checkbox"].large-checkbox:disabled {
cursor: not-allowed;
}
/* Tom-Select / TS */
.tom-dark .ts-control {
background-color: var(--dark-700) !important;
color: #fff !important;
border: 1px solid var(--dark-300) !important;
border-radius: .375rem;
min-height: 38px;
padding: .25rem .5rem;
box-sizing: border-box;
}
.tom-dark .ts-control .item {
background-color: var(--dark-400) !important;
color: #fff !important;
border-radius: .25rem;
padding: 2px 8px;
margin-right: 4px;
}
.ts-dropdown {
background-color: var(--dark-700) !important;
color: #fff !important;
border: 1px solid var(--dark-300);
border-radius: .375rem;
z-index: 9999 !important;
max-height: 300px;
overflow-y: auto;
}
.ts-dropdown .active {
background-color: var(--dark-300) !important;
color: #fff !important;
}
td select.tom-dark {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
+82
View File
@@ -0,0 +1,82 @@
/* =========================================================
Alerts, Badges, Background helpers
========================================================= */
/* Alerts */
.alert-success {
background-color: #225d36 !important;
color: var(--success-text) !important;
border-color: #174428 !important;
}
.alert-danger {
background-color: #7a1f23 !important;
color: var(--danger-text) !important;
border-color: #531417 !important;
}
.alert-info {
background-color: var(--primary) !important;
color: var(--primary-text) !important;
border-color: var(--primary-border) !important;
}
.alert-warning {
background-color: var(--warning) !important;
color: var(--warning-text) !important;
border-color: var(--warning-border) !important;
}
.alert-light {
background-color: #3a3f44 !important;
color: var(--text-strong) !important;
border-color: var(--dark-480) !important;
}
/* Badges */
.badge.bg-success,
.badge.text-bg-success {
background-color: #225d36 !important;
color: var(--success-text) !important;
}
.badge.bg-danger,
.badge.text-bg-danger {
background-color: #7a1f23 !important;
color: var(--danger-text) !important;
}
.badge.bg-info,
.badge.text-bg-info {
background-color: #1d3a4d !important;
color: #eaf6ff !important;
}
.badge.bg-warning,
.badge.text-bg-warning {
background-color: var(--warning) !important;
color: var(--warning-text) !important;
}
.badge.bg-secondary,
.badge.text-bg-secondary {
background-color: var(--dark-400) !important;
color: #e2e3e5 !important;
}
.badge.bg-primary,
.badge.text-bg-primary {
background-color: var(--primary) !important;
color: var(--primary-text) !important;
}
.badge.bg-light,
.badge.text-bg-light {
background-color: var(--dark-350) !important;
color: #f1f3f5 !important;
}
.badge.bg-dark,
.badge.text-bg-dark {
background-color: var(--dark-900) !important;
color: var(--text-strong) !important;
}
@@ -0,0 +1,56 @@
/* =========================================================
Progress
========================================================= */
.progress-dark {
background-color: var(--dark-700) !important;
border-radius: 20px !important;
overflow: hidden;
}
.progress {
background-color: #2a2d31 !important;
border-radius: 20px !important;
}
.progress-bar {
border-radius: 0 !important;
transition: width .4s ease, background-color .4s ease;
background-color: var(--progress-default) !important;
}
.progress-bar:first-child {
border-top-left-radius: 20px !important;
border-bottom-left-radius: 20px !important;
}
.progress-bar:last-child {
border-top-right-radius: 20px !important;
border-bottom-right-radius: 20px !important;
}
.progress-bar.bg-success {
background-color: var(--success) !important;
}
.progress-bar.bg-danger {
background-color: var(--danger) !important;
}
.progress-bar.bg-warning {
background-color: var(--warning) !important;
color: #fff !important;
}
.progress-bar.bg-info {
background-color: #16425a !important;
}
/* Label (parent must be position-relative) */
.progress-label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
white-space: nowrap;
}
@@ -0,0 +1,52 @@
/* =========================================================
Cards & Tables
========================================================= */
.card {
background-color: var(--dark-500) !important;
border: 1px solid var(--dark-450) !important;
color: var(--text) !important;
}
.card-header,
.card-footer {
background-color: var(--dark-650) !important;
border-color: var(--dark-450) !important;
color: #f1f3f5 !important;
}
.card .table {
border-radius: 0 !important;
overflow: hidden;
margin-bottom: 0;
}
.table-responsive {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table-responsive table {
min-width: 1000px;
}
.table-dark.table-striped tbody tr:nth-of-type(odd) {
background-color: rgba(255, 255, 255, .025);
}
.table-dark tbody tr:hover {
background-color: rgba(255, 255, 255, .04);
}
.table-dark thead th {
background-color: var(--dark-800);
color: var(--text-dim);
font-weight: 500;
border-bottom: 1px solid var(--dark-450);
}
.table-dark td,
.table-dark th {
padding: .6rem .75rem;
vertical-align: middle;
border-top: 1px solid var(--dark-450);
}
+34
View File
@@ -0,0 +1,34 @@
/* =========================================================
Navs & Pagination
========================================================= */
.nav-tabs .nav-link.active,
.nav-tabs .nav-item.show .nav-link {
background-color: var(--dark-500) !important;
color: var(--text-strong) !important;
border-color: var(--dark-450) var(--dark-450) var(--dark-500) !important;
}
.page-link {
color: #e0e0e0 !important;
background-color: var(--dark-750) !important;
border: 1px solid var(--dark-450) !important;
}
.page-link:hover,
.page-link:focus {
color: #fff !important;
background-color: var(--dark-400) !important;
border-color: var(--dark-300) !important;
}
.page-item.active .page-link {
color: #fff !important;
background-color: var(--primary) !important;
border-color: var(--primary-border) !important;
}
.page-item.disabled .page-link {
color: var(--muted) !important;
background-color: var(--dark-550) !important;
border-color: var(--dark-450) !important;
}
+49
View File
@@ -0,0 +1,49 @@
/* =========================================================
Lists & Misc UI
========================================================= */
.list-group-item {
display: flex;
align-items: center;
justify-content: space-between;
}
/* Zachowaj ostre krawędzie tylko dla list flush (np. w panelach edycji) */
.list-group-flush > .list-group-item:first-child,
.list-group-flush > .list-group-item:last-child {
border-radius: 0 !important;
}
#items li.hide-purchased {
display: none !important;
}
#mass-add-list li {
transition: background .2s;
}
#mass-add-list li.active {
background: #198754 !important;
color: #fff !important;
border: 1px solid #000 !important;
}
.quantity-input {
width: 60px;
background: var(--dark-400);
color: #fff;
border: 1px solid var(--dark-300);
border-radius: 4px;
text-align: center;
}
.add-btn {
margin-left: 10px;
}
.quantity-controls {
min-width: 120px;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
@@ -0,0 +1,92 @@
/* =========================================================
Toasts & Info Bar
========================================================= */
.toast {
animation: fadeInUp .5s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Base toast when not using text-bg-* */
.toast:not([class*="text-bg-"]) {
background-color: var(--dark-500) !important;
color: #f1f1f1 !important;
border: 1px solid var(--dark-450) !important;
animation: fadeInUp .5s ease;
}
.toast .toast-body {
color: inherit !important;
}
.toast .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
/* Typed toasts (override Bootstrap text-bg-*) */
.toast.text-bg-primary {
background-color: var(--info) !important;
color: var(--info-text) !important;
border-color: var(--info-border) !important;
}
.toast.text-bg-info {
background-color: var(--info) !important;
color: var(--info-text) !important;
border-color: var(--info-border) !important;
}
.toast.text-bg-success {
background-color: var(--success) !important;
color: var(--success-text) !important;
border-color: var(--success-border) !important;
}
.toast.text-bg-warning {
background-color: var(--warning) !important;
color: var(--warning-text) !important;
border-color: var(--warning-border) !important;
}
.toast.text-bg-danger {
background-color: var(--danger) !important;
color: var(--danger-text) !important;
border-color: var(--danger-border) !important;
}
.toast-body {
color: #fff !important;
font-weight: 500 !important;
}
.info-bar-fixed {
width: 100%;
color: var(--text-strong);
background-color: var(--dark-700);
border-radius: 12px 12px 0 0;
text-align: center;
padding: 10px 10px;
font-size: .95rem;
box-sizing: border-box;
margin-top: 2rem;
box-shadow: 0 -1px 4px rgba(0, 0, 0, .25);
}
@media (max-width: 768px) {
.info-bar-fixed {
position: static;
font-size: .85rem;
padding: 8px 4px;
border-radius: 0;
}
}
@@ -0,0 +1,39 @@
/* =========================================================
Modals (incl. fullscreen chart modal)
========================================================= */
.modal-content {
background-color: var(--dark-470) !important;
color: #f1f1f1 !important;
border: 1px solid var(--dark-450) !important;
}
.modal-header,
.modal-footer {
background-color: var(--dark-650) !important;
border-color: var(--dark-450) !important;
}
/* Fullscreen chart modal */
#chartFullscreenModal .modal-dialog {
max-width: 100vw;
width: 100vw;
margin: 0;
}
#chartFullscreenModal .modal-content {
height: 100vh;
border-radius: 0;
}
#chartFullscreenModal .modal-body {
display: flex;
flex: 1 1 auto;
padding: 0;
overflow: hidden;
}
#chartFullscreenCanvas {
display: block;
width: 100%;
height: 100%;
}
+876
View File
@@ -0,0 +1,876 @@
/* =========================================================
Dropdown (TS already above) — active
========================================================= */
.ts-dropdown .active {
background-color: var(--dark-300) !important;
}
.list-group-item.bg-success {
background-color: var(--success) !important;
border-color: var(--success-border) !important;
color: var(--success-text) !important;
--bs-bg-opacity: 1 !important;
}
.list-group-item.bg-warning {
background-color: var(--warning) !important;
border-color: var(--warning-border) !important;
color: var(--warning-text) !important;
--bs-bg-opacity: 1 !important;
}
.btn-outline-light {
color: #f8f9fa !important;
border-color: #f8f9fa !important;
background-color: transparent !important;
/* brak białego tła domyślnie */
}
.btn-outline-light:hover,
.btn-outline-light:focus {
background-color: #6c757d !important;
/* szare, jak wcześniej */
color: #fff !important;
border-color: #6c757d !important;
}
.btn-outline-light:active,
.btn-outline-light.active,
.show>.btn-outline-light.dropdown-toggle {
background-color: #5a6268 !important;
/* ciemniejsze szare na active */
color: #fff !important;
border-color: #545b62 !important;
}
.btn-outline-info {
color: var(--info) !important;
border-color: var(--info) !important;
background-color: transparent !important;
}
.btn-outline-info:hover,
.btn-outline-info:focus {
background-color: #1d4d8c !important;
border-color: #1d4d8c !important;
color: var(--info-text) !important;
}
.btn-outline-info:active,
.btn-outline-info.active,
.show>.btn-outline-info.dropdown-toggle {
background-color: var(--info) !important;
border-color: var(--info-border) !important;
color: var(--info-text) !important;
}
/* Tekstowe kolory */
.text-success {
color: var(--success) !important;
}
.text-warning {
color: var(--warning) !important;
}
.text-info {
color: var(--info) !important;
}
.text-danger {
color: var(--danger) !important;
}
.settings-category-card {
background: rgba(255,255,255,.03);
border: 1px solid rgba(255,255,255,.09);
border-radius: 16px;
padding: 1rem;
height: 100%;
}
.settings-category-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
}
.settings-category-name {
font-size: 1rem;
font-weight: 700;
line-height: 1.2;
}
.settings-override-badge {
white-space: nowrap;
}
.settings-color-controls {
display: flex;
align-items: stretch;
gap: .75rem;
}
.settings-color-controls .category-color {
width: 72px;
min-width: 72px;
height: auto;
padding: .35rem;
border-radius: 14px !important;
border: 1px solid rgba(255,255,255,.14);
background: rgba(255,255,255,.04);
}
.settings-color-actions {
display: flex;
flex: 1 1 auto;
}
.settings-color-actions .btn {
flex: 1 1 0;
min-height: 44px;
border-radius: 14px !important;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
}
.settings-color-actions .btn + .btn {
margin-left: .5rem;
}
@media (min-width: 992px) {
.settings-category-name {
font-size: 1.08rem;
}
}
@media (max-width: 767.98px) {
.settings-category-card {
padding: .9rem;
}
.settings-color-controls {
flex-direction: column;
}
.settings-color-controls .category-color {
width: 100%;
min-width: 0;
height: 48px;
}
.settings-color-actions {
flex-direction: column;
}
.settings-color-actions .btn + .btn {
margin-left: 0;
margin-top: .5rem;
}
}
/* ========== Kolorowe wskaźniki pod pickerem ========== */
.color-indicators .indicator {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: .5rem;
}
.color-indicators .bar {
height: 10px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,.25);
box-shadow: inset 0 0 0 1px rgba(0,0,0,.25);
}
/* ========== Swatch + zapisy heksowe ========== */
.swatch {
width: 16px;
height: 16px;
border-radius: 50%;
display: inline-block;
border: 1px solid rgba(0,0,0,.15);
}
.hex,
.hex-label {
font-variant-numeric: lining-nums;
letter-spacing: .2px;
}
/* ========== OCR textarea ========== */
.settings-ocr-textarea {
font: inherit;
line-height: 1.45;
}
/* ========== Odznaka poziomu czułości ========== */
.sens-badge { font-weight: 600; }
.sens-low { background: rgba(108,117,125,.25); color: #ced4da; } /* szary */
.sens-mid { background: rgba(13,110,253,.25); color: #9ec5fe; } /* niebieski */
.sens-high { background: rgba(220,53,69,.25); color: #f1aeb5; } /* czerwony */
/* =========================================================
COMPACT: przyciski akcji na listach
- Desktop: standard Bootstrap
- <=576px: kompakt
========================================================= */
/* <=420px: tylko emoji */
@media (max-width: 420px) {
.btn-group-compact .btn-text {
display: none !important;
}
.btn-group-compact .btn {
padding: 0.22rem 0.45rem;
min-width: auto;
font-size: 0.9rem;
line-height: 1.1;
}
}
/* 421–576px: lekko ciaśniej, ale tekst zostaje */
@media (min-width: 421px) and (max-width: 576px) {
.btn-group-compact .btn {
padding: 0.24rem 0.45rem;
font-size: 0.82rem;
line-height: 1.1;
}
.btn-group-compact .btn-text {
font-size: 0.75rem;
}
}
/* ================================================
RESPONSIVE NAVBAR
================================================ */
/* Wąskie ekrany (np. iPhone 11) */
@media (max-width: 420px) {
.navbar .container-fluid {
gap: 4px;
}
.navbar-brand-compact {
font-size: 0.9rem !important;
margin-right: 0.25rem;
white-space: nowrap;
}
.navbar-brand-compact .navbar-brand-text {
font-size: 0.95em;
}
.user-info-compact {
font-size: 0.72rem !important;
line-height: 0.9;
white-space: nowrap;
}
.user-info-compact .badge {
font-size: 0.68rem;
padding: 0.2rem 0.45rem;
}
.nav-buttons-compact .nav-btn-text {
display: none !important;
}
.nav-buttons-compact {
gap: 0.35rem !important;
flex-wrap: nowrap;
}
.nav-buttons-compact .btn {
padding: 0.22rem 0.45rem;
min-width: auto;
line-height: 1.1;
}
}
/* Małe ekrany (np. 421-576px) */
@media (min-width: 421px) and (max-width: 576px) {
.navbar .container-fluid {
gap: 8px;
}
.navbar-brand-compact {
font-size: 1.25rem !important;
white-space: nowrap;
}
.user-info-compact {
font-size: 0.8rem !important;
white-space: nowrap;
}
.user-info-compact .badge {
font-size: 0.75rem;
}
.nav-buttons-compact {
flex-wrap: nowrap;
}
.nav-buttons-compact .btn {
font-size: 0.8rem;
padding: 0.25rem 0.45rem;
}
.nav-buttons-compact .nav-btn-text {
font-size: 0.75rem;
}
}
@media (max-width: 420px) {
.user-label-desktop { display: none !important; }
.user-label-mobile { display: inline !important; }
}
@media (min-width: 421px) {
.user-label-desktop { display: inline !important; }
.user-label-mobile { display: none !important; }
}
.category-dot-pure {
width: 10px;
height: 10px;
display: inline-block;
flex: 0 0 auto;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.8);
background-clip: padding-box;
vertical-align: middle;
margin-left: 0.35rem;
opacity: 1;
padding: 0;
line-height: 1;
font-size: 0;
text-indent: -9999px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.category-dot-pure::before,
.category-dot-pure::after {
content: none !important;
}
/* Hover efekt */
.category-dot:hover {
transform: scale(1.3) !important;
box-shadow: 0 2px 6px rgba(0,0,0,0.4) !important;
}
.list-title {
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
max-width: 70% !important;
display: inline-block !important;
}
/* Bardzo małe ekrany */
@media (max-width: 420px) {
.list-title {
max-width: 60% !important;
}
}
/* ===== 2026 app refresh ===== */
:root {
--app-bg: #07111f;
--app-bg-soft: #0d1b2d;
--app-surface: rgba(11, 23, 39, 0.88);
--app-surface-strong: rgba(15, 28, 46, 0.98);
--app-surface-muted: rgba(255, 255, 255, 0.04);
--app-border: rgba(255, 255, 255, 0.1);
--app-border-strong: rgba(255, 255, 255, 0.16);
--app-text: #f3f8ff;
--app-text-muted: #9fb0c8;
--app-success: #27d07d;
--app-warning: #f6c453;
--app-danger: #ff6b7a;
--app-shadow: 0 18px 50px rgba(0, 0, 0, 0.28);
--app-radius: 22px;
}
html, body {
min-height: 100%;
background-color: var(--app-bg);
background-image:
radial-gradient(circle at top left, rgba(39, 208, 125, 0.18), transparent 24%),
radial-gradient(circle at top right, rgba(74, 144, 226, 0.16), transparent 22%),
linear-gradient(180deg, #09111d 0%, #08121f 38%, #060d18 100%);
background-repeat: no-repeat;
color: var(--app-text);
}
html {
color-scheme: dark;
}
body.app-body {
position: relative;
min-height: 100vh;
min-height: 100dvh;
min-height: 100svh;
display: flex;
flex-direction: column;
margin: 0;
font-feature-settings: "ss01" on, "cv02" on;
overflow-x: hidden;
}
@supports (padding: env(safe-area-inset-top)) {
html, body {
min-height: calc(100% + env(safe-area-inset-top, 0px) + env(safe-area-inset-bottom, 0px));
}
}
.app-backdrop {
position: fixed;
inset: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(255,255,255,0.02), transparent 28%);
}
.app-header {
z-index: 1035;
padding: calc(0.75rem + env(safe-area-inset-top, 0px)) 0 0;
backdrop-filter: blur(12px);
}
.app-navbar {
background: transparent;
}
.app-navbar .container-xxl {
border: 1px solid var(--app-border);
background: rgba(6, 15, 27, 0.74);
backdrop-filter: blur(16px);
border-radius: 999px;
min-height: 68px;
box-shadow: var(--app-shadow);
}
.app-brand {
display: inline-flex;
align-items: center;
gap: 0.85rem;
font-weight: 800;
color: var(--app-text) !important;
}
.app-brand__icon {
display: inline-grid;
place-items: center;
width: 2.6rem;
height: 2.6rem;
border-radius: 16px;
background: linear-gradient(135deg, rgba(39,208,125,0.22), rgba(74,144,226,0.18));
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
}
.app-brand__title { color: #ffffff; }
.app-brand__accent { color: #7ce4a8; margin-left: 0.3rem; }
.app-navbar__actions,
.app-navbar__meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.app-user-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.45rem 0.4rem 0.75rem;
border-radius: 999px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.08);
}
.app-user-chip__label {
font-size: 0.75rem;
color: var(--app-text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.app-main {
flex: 1 0 auto;
padding: 1rem 0 2.5rem;
}
.app-content-frame {
padding: 0.25rem 0 0;
}
.app-footer {
margin-top: auto;
padding: 1rem 0 calc(1.35rem + env(safe-area-inset-bottom, 0px));
}
.app-footer__inner {
border-top: 1px solid rgba(255,255,255,0.08);
padding-top: 1.25rem;
}
h1, h2, h3, h4, h5, h6 {
color: #ffffff;
letter-spacing: -0.02em;
}
.card,
.modal-content,
.dropdown-menu,
.list-group-item,
.table,
.alert,
.pagination .page-link,
.nav-tabs,
.input-group-text,
.form-control,
.form-select,
.btn,
.progress,
.toast {
border-radius: 18px;
}
.card,
.modal-content,
.table-responsive,
.alert,
.list-group-item,
.pagination .page-link,
.nav-tabs,
.input-group-text,
.form-control,
.form-select,
.progress,
.toast,
.page-link,
.table,
.btn-group > .btn {
border-color: var(--app-border) !important;
}
.card,
.modal-content,
.table-responsive,
.alert,
.list-group-item,
.progress,
.toast {
background: var(--app-surface) !important;
box-shadow: var(--app-shadow);
backdrop-filter: blur(10px);
}
.card-body,
.modal-body,
.modal-header,
.modal-footer {
background: transparent;
}
.bg-dark,
.table-dark,
.list-group-item.bg-dark,
.modal-content.bg-dark,
.card.bg-dark,
.card.bg-secondary,
.list-group-item.item-not-checked {
background: var(--app-surface) !important;
color: var(--app-text) !important;
}
.card.bg-secondary.bg-opacity-10,
#share-card {
background: linear-gradient(180deg, rgba(16, 29, 49, 0.96), rgba(10, 20, 36, 0.94)) !important;
}
.table-dark {
--bs-table-bg: transparent;
--bs-table-striped-bg: rgba(255,255,255,0.03);
--bs-table-hover-bg: rgba(255,255,255,0.05);
--bs-table-color: var(--app-text);
margin-bottom: 0;
}
.table > :not(caption) > * > * {
padding: 0.9rem 1rem;
border-bottom-color: rgba(255,255,255,0.08);
}
.list-group {
gap: 0.8rem;
}
.list-group-item {
margin-bottom: 0;
padding: 1rem 1rem;
color: var(--app-text) !important;
}
.list-group-item.bg-success {
background: linear-gradient(135deg, rgba(39,208,125,0.92), rgba(22,150,91,0.96)) !important;
}
.list-group-item.bg-warning {
background: linear-gradient(135deg, rgba(246,196,83,0.96), rgba(224,164,26,0.96)) !important;
color: #1c1b17 !important;
}
.btn {
border-radius: 14px;
font-weight: 600;
padding: 0.7rem 1rem;
box-shadow: none;
}
.btn-sm {
padding: 0.55rem 0.85rem;
border-radius: 12px;
}
.btn-success,
.btn-outline-success:hover {
background: linear-gradient(135deg, #29d17d, #1ea860);
border-color: rgba(41,209,125,0.9);
}
.btn-outline-light,
.btn-outline-secondary,
.btn-outline-warning,
.btn-outline-primary,
.btn-outline-success {
background: rgba(255,255,255,0.02);
}
.btn:hover,
.btn:focus {
transform: translateY(-1px);
}
.form-control,
.form-select,
.input-group-text {
min-height: 48px;
background: rgba(5, 13, 23, 0.86) !important;
color: var(--app-text) !important;
box-shadow: none !important;
}
.form-control::placeholder { color: rgba(210, 224, 244, 0.45); }
.form-control:focus,
.form-select:focus {
border-color: rgba(39,208,125,0.5) !important;
box-shadow: 0 0 0 0.2rem rgba(39,208,125,0.15) !important;
}
.nav-tabs {
gap: 0.5rem;
border-bottom: none;
background: rgba(255,255,255,0.03);
padding: 0.4rem;
}
.nav-tabs .nav-link {
border-radius: 14px;
color: var(--app-text-muted);
border: none;
padding: 0.8rem 1rem;
}
.nav-tabs .nav-link.active {
background: rgba(39,208,125,0.12);
color: #fff;
}
.progress {
overflow: hidden;
background: rgba(255,255,255,0.06);
min-height: 1rem;
}
.badge {
border: 1px solid rgba(255,255,255,0.08);
}
.pagination .page-link {
background: rgba(255,255,255,0.03);
color: var(--app-text);
margin: 0 0.15rem;
}
.pagination .page-item.active .page-link {
background: rgba(39,208,125,0.18);
color: #fff;
}
.modal-content {
overflow: hidden;
}
.toast-container { z-index: 1200; }
#items .list-group-item {
border-radius: 18px !important;
padding: 1rem 1rem;
}
#items .btn-group {
gap: 0.4rem;
}
#items .btn-group .btn {
border-radius: 12px !important;
}
.large-checkbox {
width: 1.35rem;
height: 1.35rem;
accent-color: #29d17d;
}
#share-card .badge,
#total-expense1,
#total-expense2,
#total-expense {
background: transparent;
color: #dfffea !important;
}
#share-card,
.card,
.table-responsive,
.alert,
.modal-content,
#expenseChartWrapper,
#categoryChartWrapper {
border-radius: var(--app-radius) !important;
}
.endpoint-login .app-content-frame,
.endpoint-system_auth .app-content-frame,
.endpoint-page_not_found .app-content-frame,
.endpoint-forbidden .app-content-frame {
max-width: 560px;
margin: 3rem auto 0;
}
.endpoint-main_page .list-group-item,
.endpoint-expenses .card,
.endpoint-admin_panel .card,
.endpoint-view_list .card,
.endpoint-shared_list .card,
.endpoint-edit_my_list .card,
[class*="endpoint-admin_"] .card {
overflow: hidden;
}
input[type="checkbox"].form-check-input {
width: 2.9rem;
height: 1.5rem;
}
hr {
border-color: rgba(255,255,255,0.08);
}
@media (max-width: 991.98px) {
.app-header {
padding-top: 0.55rem;
}
.app-navbar .container-xxl {
border-radius: 26px;
padding-top: 0.8rem;
padding-bottom: 0.8rem;
align-items: flex-start;
}
.app-navbar__actions,
.app-navbar__meta {
width: 100%;
justify-content: flex-start;
}
}
@media (max-width: 767.98px) {
.app-main {
padding-top: 0.7rem;
}
.card-body,
.list-group-item,
.modal-body,
.modal-header,
.modal-footer,
.table > :not(caption) > * > * {
padding-left: 0.85rem;
padding-right: 0.85rem;
}
.btn-group,
.d-flex.gap-2,
.d-flex.gap-3 {
gap: 0.45rem !important;
}
.btn-group > .btn,
.btn.w-100,
.input-group > .btn {
min-height: 46px;
}
.input-group:not(.ui-password-group) {
flex-wrap: wrap;
gap: 0.55rem;
}
.input-group:not(.ui-password-group) > .form-control,
.input-group:not(.ui-password-group) > .form-select,
.input-group:not(.ui-password-group) > .btn,
.input-group:not(.ui-password-group) > .input-group-text {
width: 100% !important;
flex: 1 1 100% !important;
border-radius: 14px !important;
max-width: 100% !important;
}
#items .d-flex.align-items-center.gap-2.flex-grow-1 {
width: 100%;
align-items: flex-start !important;
}
#items .btn-group {
width: 100%;
margin-top: 0.85rem;
}
#items .btn-group .btn {
flex: 1 1 0;
}
.table-responsive {
border-radius: 18px;
}
.pagination {
flex-wrap: wrap;
gap: 0.25rem;
justify-content: center;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,32 @@
/* =========================================================
COMPACT: przyciski akcji na listach
- Desktop: standard Bootstrap
- <=576px: kompakt
========================================================= */
/* <=420px: tylko emoji */
@media (max-width: 420px) {
.btn-group-compact .btn-text {
display: none !important;
}
.btn-group-compact .btn {
padding: 0.22rem 0.45rem;
min-width: auto;
font-size: 0.9rem;
line-height: 1.1;
}
}
/* 421–576px: lekko ciaśniej, ale tekst zostaje */
@media (min-width: 421px) and (max-width: 576px) {
.btn-group-compact .btn {
padding: 0.24rem 0.45rem;
font-size: 0.82rem;
line-height: 1.1;
}
.btn-group-compact .btn-text {
font-size: 0.75rem;
}
}
+926
View File
@@ -0,0 +1,926 @@
/* =========================================================
Share page toolbar and shared-list header actions
========================================================= */
.share-page-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: .75rem;
width: 100%;
}
.share-page-toolbar__spacer {
flex: 1 1 auto;
}
.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;
}
.share-page-toolbar__spacer {
display: none;
}
.hide-purchased-switch {
padding-left: 2.95rem;
}
.hide-purchased-switch.form-check.app-switch {
width: 100%;
}
.switch-grid,
.endpoint-edit_my_list .switch-grid {
grid-template-columns: 1fr;
}
}
/* final polish v2 */
:root {
--nav-btn-height: 2.35rem;
}
.app-navbar__actions {
gap: .4rem;
}
.app-navbar__actions .btn,
.app-nav-action {
min-height: var(--nav-btn-height);
padding: .42rem .78rem;
font-size: .92rem;
border-radius: .9rem !important;
}
.form-check.form-switch.app-switch {
min-height: 2.95rem;
padding: .65rem .9rem;
}
.form-check.form-switch.app-switch .form-check-input {
width: 2.75em !important;
height: 1.45em !important;
transition: background-position .18s ease-in-out, background-color .18s ease-in-out, border-color .18s ease-in-out, box-shadow .18s ease-in-out !important;
}
.form-check.form-switch.app-switch .form-check-label {
min-height: 1.45rem;
}
.endpoint-admin_templates .table-responsive {
overflow-x: auto;
}
.endpoint-admin_templates .table-responsive > table.table {
width: 100% !important;
min-width: 100% !important;
table-layout: auto;
}
@media (max-width: 991.98px) {
.app-navbar__actions .btn,
.app-nav-action {
font-size: .86rem;
padding: .48rem .6rem;
}
.endpoint-admin_templates .table-responsive > table.table {
width: max-content !important;
min-width: 900px !important;
}
}
/* responsive mobile category badges + smaller animated switches */
.mobile-list-heading {
width: 100%;
min-width: 0;
max-width: 100%;
justify-content: flex-start;
}
.mobile-list-heading__title {
min-width: 0;
}
.mobile-category-badges {
display: inline-flex;
align-items: center;
gap: .3rem;
min-width: 0;
max-width: 100%;
}
.mobile-category-badge {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: .68rem;
line-height: 1;
padding: .26rem .52rem;
opacity: .95;
}
.mobile-category-badge__dot {
display: none;
width: .55rem;
height: .55rem;
border-radius: 999px;
background: currentColor;
}
.mobile-category-badges.is-compact .mobile-category-badge {
width: .9rem;
min-width: .9rem;
height: .9rem;
padding: 0;
border-radius: 999px;
}
.mobile-category-badges.is-compact .mobile-category-badge__text {
display: none;
}
.mobile-category-badges.is-compact .mobile-category-badge__dot {
display: block;
}
.form-check.form-switch.app-switch {
min-height: 2.75rem;
padding: .58rem .82rem;
}
.form-check.form-switch.app-switch .form-check-input,
.hide-purchased-switch .form-check-input {
width: 2.45em !important;
height: 1.3em !important;
background-position: left center !important;
transition: background-position .18s ease-in-out, background-color .18s ease-in-out, border-color .18s ease-in-out, box-shadow .18s ease-in-out !important;
}
.form-check.form-switch.app-switch .form-check-input:checked,
.hide-purchased-switch .form-check-input:checked {
background-position: right center !important;
}
.form-check.form-switch.app-switch .form-check-label {
min-height: 1.3rem;
}
.hide-purchased-switch.form-check.app-switch {
padding: .5rem .82rem;
}
@media (max-width: 576px) {
.mobile-list-heading {
display: inline-flex;
max-width: 100%;
}
.mobile-list-heading__title {
max-width: 100%;
}
}
.endpoint-main_page .list-group-item > .main-list-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
flex-wrap: nowrap;
}
.endpoint-main_page .list-main-meta {
min-width: 0;
flex: 1 1 auto;
}
.endpoint-main_page .list-main-title {
display: flex;
align-items: center;
align-content: center;
flex-wrap: wrap;
gap: 0.15rem;
min-width: 0;
line-height: 1;
}
.endpoint-main_page .list-main-actions {
flex: 0 0 auto;
align-self: center;
}
@media (max-width: 575.98px) {
.endpoint-main_page .list-group-item > .main-list-row {
flex-direction: column;
align-items: stretch;
}
.endpoint-main_page .list-main-actions {
width: 100%;
}
}
/* mobile UX fixes 2026-03-14 */
.list-main-title__link {
min-width: 0;
display: inline-flex;
align-items: center;
align-content: center;
flex-wrap: wrap;
gap: .15rem;
line-height: 1;
}
.shopping-item-row {
gap: .75rem;
}
.shopping-item-main {
min-width: 0;
flex: 1 1 auto;
flex-wrap: wrap;
}
.shopping-item-main span[id^="name-"] {
min-width: 0;
}
.shopping-item-actions {
flex: 0 0 auto;
margin-left: auto;
align-self: flex-start;
}
.shopping-compact-input-group {
flex-wrap: nowrap !important;
align-items: stretch;
}
.shopping-qty-input {
flex: 0 0 4.5rem;
max-width: 4.5rem;
}
.shopping-compact-submit {
flex: 0 0 auto;
width: auto;
white-space: nowrap;
}
@media (max-width: 991.98px) {
.app-navbar__actions {
grid-template-columns: repeat(auto-fit, minmax(8.25rem, max-content));
justify-content: end;
}
.app-navbar__actions .btn,
.app-nav-action {
width: auto;
min-width: 8.25rem;
justify-self: end;
}
}
@media (max-width: 575.98px) {
.endpoint-main_page .list-group-item > .main-list-row {
flex-direction: row;
align-items: center;
}
.endpoint-main_page .list-main-actions {
width: auto;
margin-left: auto;
}
.endpoint-main_page .list-main-actions .btn {
padding: .38rem .52rem;
min-width: 2.35rem;
}
.endpoint-main_page .list-main-title {
display: flex;
flex-wrap: wrap;
gap: .15rem;
}
.endpoint-main_page .list-main-meta {
flex: 1 1 auto;
min-width: 0;
}
.endpoint-main_page .list-main-title__link {
min-width: 0;
max-width: 100%;
}
.shopping-item-row {
align-items: flex-start !important;
}
.shopping-item-actions {
width: auto;
margin-top: 0;
margin-left: auto;
justify-content: flex-end;
}
.shopping-item-actions .btn {
min-width: 2.35rem;
padding: .38rem .52rem;
}
.shopping-compact-input-group {
display: flex;
}
.shopping-compact-input-group > .form-control {
min-width: 0;
}
.shopping-qty-input {
flex-basis: 4rem;
max-width: 4rem;
}
.shopping-compact-submit {
min-width: auto;
padding-left: .8rem;
padding-right: .8rem;
}
.ui-password-group > .ui-password-toggle {
min-width: 2.75rem;
padding-left: .7rem;
padding-right: .7rem;
}
}
/* UX refactor 2026-03-14 b */
.app-navbar-toggler {
border-color: rgba(255,255,255,.28);
padding: .3rem .55rem;
}
.app-navbar-toggler:focus {
box-shadow: 0 0 0 .2rem rgba(255,255,255,.1);
}
.app-navbar-toggler .navbar-toggler-icon {
filter: invert(1) grayscale(1);
}
#createListTempToggle,
.create-list-temp-toggle {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
.create-list-input-group > .form-control {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.create-list-input-group > .create-list-temp-toggle {
background: transparent;
white-space: nowrap;
}
.list-toolbar {
width: 100%;
}
.list-toolbar--share {
justify-content: flex-end;
}
.hide-purchased-switch--minimal {
border: 0;
background: transparent;
padding: 0;
margin-left: auto;
}
.shopping-item-row {
padding: .8rem .95rem;
}
.shopping-item-main {
display: flex;
align-items: center;
gap: .75rem;
width: 100%;
}
.shopping-item-content {
flex: 1 1 auto;
min-width: 0;
}
.shopping-item-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: .75rem;
}
.shopping-item-text {
min-width: 0;
display: flex;
align-items: center;
gap: .35rem;
flex-wrap: wrap;
}
.shopping-item-name {
min-width: 0;
overflow-wrap: break-word;
word-break: normal;
}
.shopping-item-text .info-line {
flex-basis: 100%;
margin-top: .1rem;
overflow-wrap: break-word;
word-break: normal;
}
.shopping-item-actions {
display: inline-flex;
flex-wrap: nowrap;
gap: .35rem;
}
.shopping-product-input-group > .shopping-product-name-input,
.shopping-expense-input-group > .shopping-expense-amount-input {
flex: 1 1 auto;
}
.shopping-product-input-group > .shopping-qty-input {
flex: 0 0 4.5rem;
max-width: 4.5rem;
text-align: center;
}
.shopping-expense-input-group > .shopping-compact-submit,
.shopping-product-input-group > .shopping-compact-submit {
flex: 0 0 auto;
}
.endpoint-list .shopping-product-input-group > .shopping-compact-submit,
.endpoint-list_share .shopping-product-input-group > .shopping-compact-submit,
.endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit,
.endpoint-list .shopping-expense-input-group > .shopping-compact-submit,
.endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit,
.endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
border-top-right-radius: .9rem !important;
border-bottom-right-radius: .9rem !important;
margin-left: 0;
}
.endpoint-list .shopping-product-input-group,
.endpoint-list_share .shopping-product-input-group,
.endpoint-shared_list .shopping-product-input-group,
.endpoint-list .shopping-expense-input-group,
.endpoint-list_share .shopping-expense-input-group,
.endpoint-shared_list .shopping-expense-input-group {
flex-wrap: nowrap;
}
@media (max-width: 991.98px) {
.navbar-collapse .app-navbar__actions {
padding-top: .6rem;
justify-content: flex-end;
}
}
@media (max-width: 575.98px) {
.app-navbar__actions {
width: 100%;
justify-content: flex-end;
}
.app-navbar__actions .btn,
.app-nav-action {
min-width: 0;
width: auto;
}
.shopping-item-main {
gap: .55rem;
}
.shopping-item-head {
gap: .45rem;
}
.shopping-item-actions {
margin-left: auto;
align-self: flex-start;
}
.shopping-item-actions .btn {
min-width: 2.2rem;
padding: .34rem .48rem;
}
.shopping-product-input-group > .shopping-product-name-input,
.shopping-expense-input-group > .shopping-expense-amount-input {
flex: 0 0 60%;
min-width: 0;
}
.shopping-product-input-group > .shopping-qty-input {
flex: 0 0 15%;
max-width: 15%;
min-width: 0;
}
.shopping-product-input-group > .shopping-compact-submit {
flex: 0 0 25%;
width: 25%;
min-width: 0;
padding-left: .55rem;
padding-right: .55rem;
font-size: .95rem;
}
.shopping-expense-input-group > .shopping-compact-submit {
padding-left: .7rem;
padding-right: .7rem;
}
.list-toolbar {
align-items: flex-start !important;
}
.list-toolbar__sort {
flex: 0 0 auto;
}
.hide-purchased-switch--minimal {
font-size: .95rem;
}
}
/* UX tweak 2026-03-14 c: hamburger with full labels */
@media (max-width: 991.98px) {
.navbar-collapse .app-navbar__actions {
width: 100%;
display: flex;
flex-direction: column;
align-items: stretch;
gap: .5rem;
}
.navbar-collapse .app-navbar__actions .btn,
.navbar-collapse .app-nav-action {
width: 100%;
min-width: 0;
justify-content: flex-start;
text-align: left;
padding-left: .9rem;
padding-right: .9rem;
}
.navbar-collapse .app-navbar__actions .btn > span,
.navbar-collapse .app-nav-action > span {
display: inline !important;
}
}
/* form input/button unification fix 2026-03-15 */
.endpoint-list .shopping-product-input-group,
.endpoint-list_share .shopping-product-input-group,
.endpoint-shared_list .shopping-product-input-group,
.endpoint-list .shopping-expense-input-group,
.endpoint-list_share .shopping-expense-input-group,
.endpoint-shared_list .shopping-expense-input-group {
display: flex;
flex-wrap: nowrap !important;
align-items: stretch;
gap: 0 !important;
}
.endpoint-list .shopping-product-input-group > .form-control,
.endpoint-list_share .shopping-product-input-group > .form-control,
.endpoint-shared_list .shopping-product-input-group > .form-control,
.endpoint-list .shopping-expense-input-group > .form-control,
.endpoint-list_share .shopping-expense-input-group > .form-control,
.endpoint-shared_list .shopping-expense-input-group > .form-control,
.endpoint-list .shopping-product-input-group > .btn,
.endpoint-list_share .shopping-product-input-group > .btn,
.endpoint-shared_list .shopping-product-input-group > .btn,
.endpoint-list .shopping-expense-input-group > .btn,
.endpoint-list_share .shopping-expense-input-group > .btn,
.endpoint-shared_list .shopping-expense-input-group > .btn {
position: relative;
min-height: 46px;
box-shadow: none;
}
.endpoint-list .shopping-product-input-group > .shopping-product-name-input,
.endpoint-list_share .shopping-product-input-group > .shopping-product-name-input,
.endpoint-shared_list .shopping-product-input-group > .shopping-product-name-input,
.endpoint-list .shopping-expense-input-group > .shopping-expense-amount-input,
.endpoint-list_share .shopping-expense-input-group > .shopping-expense-amount-input,
.endpoint-shared_list .shopping-expense-input-group > .shopping-expense-amount-input {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.endpoint-list .shopping-product-input-group > .shopping-qty-input,
.endpoint-list_share .shopping-product-input-group > .shopping-qty-input,
.endpoint-shared_list .shopping-product-input-group > .shopping-qty-input {
border-radius: 0 !important;
border-left-width: 0 !important;
}
.endpoint-list .shopping-product-input-group > .shopping-compact-submit,
.endpoint-list_share .shopping-product-input-group > .shopping-compact-submit,
.endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit,
.endpoint-list .shopping-expense-input-group > .shopping-compact-submit,
.endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit,
.endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .35rem;
margin-left: 0 !important;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
border-top-right-radius: .9rem !important;
border-bottom-right-radius: .9rem !important;
border-left-width: 0 !important;
}
.endpoint-list .shopping-product-input-group > .shopping-compact-submit,
.endpoint-list .shopping-expense-input-group > .shopping-compact-submit,
.endpoint-list_share .shopping-product-input-group > .shopping-compact-submit,
.endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit,
.endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit,
.endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit,
.endpoint-list .share-submit-btn,
.endpoint-list_share .share-submit-btn,
.endpoint-shared_list .share-submit-btn {
min-width: 7.25rem;
}
.shopping-btn-icon {
line-height: 1;
}
.shopping-btn-label {
line-height: 1;
}
@media (max-width: 767.98px) {
.endpoint-list .shopping-product-input-group,
.endpoint-list_share .shopping-product-input-group,
.endpoint-shared_list .shopping-product-input-group,
.endpoint-list .shopping-expense-input-group,
.endpoint-list_share .shopping-expense-input-group,
.endpoint-shared_list .shopping-expense-input-group {
width: 100%;
}
.endpoint-list .shopping-product-input-group > .shopping-product-name-input,
.endpoint-list_share .shopping-product-input-group > .shopping-product-name-input,
.endpoint-shared_list .shopping-product-input-group > .shopping-product-name-input {
flex: 0 0 60% !important;
max-width: 60% !important;
min-width: 0;
}
.endpoint-list .shopping-product-input-group > .shopping-qty-input,
.endpoint-list_share .shopping-product-input-group > .shopping-qty-input,
.endpoint-shared_list .shopping-product-input-group > .shopping-qty-input {
flex: 0 0 15% !important;
max-width: 15% !important;
min-width: 0;
padding-left: .35rem;
padding-right: .35rem;
}
.endpoint-list .shopping-product-input-group > .shopping-compact-submit,
.endpoint-list_share .shopping-product-input-group > .shopping-compact-submit,
.endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit {
flex: 0 0 25% !important;
width: 25% !important;
min-width: 0 !important;
padding-left: .4rem;
padding-right: .4rem;
}
.endpoint-list .shopping-expense-input-group > .shopping-expense-amount-input,
.endpoint-list_share .shopping-expense-input-group > .shopping-expense-amount-input,
.endpoint-shared_list .shopping-expense-input-group > .shopping-expense-amount-input {
flex: 1 1 auto !important;
min-width: 0;
}
.endpoint-list .shopping-expense-input-group > .shopping-compact-submit,
.endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit,
.endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit {
flex: 0 0 5rem !important;
width: 5rem !important;
min-width: 5rem !important;
padding-left: .35rem;
padding-right: .35rem;
}
.endpoint-list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-label,
.endpoint-list_share .shopping-product-input-group > .shopping-compact-submit .shopping-btn-label,
.endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-label,
.endpoint-list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-label,
.endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-label,
.endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-label {
display: none;
}
.endpoint-list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-icon,
.endpoint-list_share .shopping-product-input-group > .shopping-compact-submit .shopping-btn-icon,
.endpoint-shared_list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-icon,
.endpoint-list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-icon,
.endpoint-list_share .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-icon,
.endpoint-shared_list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-icon {
margin: 0;
font-size: 1rem;
}
}
/* endpoint fix for /list route */
.endpoint-view_list .shopping-product-input-group,
.endpoint-view_list .shopping-expense-input-group {
display: flex;
flex-wrap: nowrap !important;
align-items: stretch;
gap: 0 !important;
width: 100%;
}
.endpoint-view_list .shopping-product-input-group > .form-control,
.endpoint-view_list .shopping-expense-input-group > .form-control,
.endpoint-view_list .shopping-product-input-group > .btn,
.endpoint-view_list .shopping-expense-input-group > .btn {
position: relative;
min-height: 46px;
box-shadow: none;
}
.endpoint-view_list .shopping-product-input-group > .shopping-product-name-input,
.endpoint-view_list .shopping-expense-input-group > .shopping-expense-amount-input {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.endpoint-view_list .shopping-product-input-group > .shopping-qty-input {
border-radius: 0 !important;
border-left-width: 0 !important;
}
.endpoint-view_list .shopping-product-input-group > .shopping-compact-submit,
.endpoint-view_list .shopping-expense-input-group > .shopping-compact-submit,
.endpoint-view_list .share-submit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .35rem;
margin-left: 0 !important;
min-width: 7.25rem;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
border-top-right-radius: .9rem !important;
border-bottom-right-radius: .9rem !important;
border-left-width: 0 !important;
}
@media (max-width: 767.98px) {
.endpoint-view_list .shopping-product-input-group > .shopping-product-name-input {
flex: 0 0 60% !important;
max-width: 60% !important;
min-width: 0;
}
.endpoint-view_list .shopping-product-input-group > .shopping-qty-input {
flex: 0 0 15% !important;
max-width: 15% !important;
min-width: 0;
padding-left: .35rem;
padding-right: .35rem;
}
.endpoint-view_list .shopping-product-input-group > .shopping-compact-submit {
flex: 0 0 25% !important;
width: 25% !important;
min-width: 0 !important;
padding-left: .4rem;
padding-right: .4rem;
}
.endpoint-view_list .shopping-expense-input-group > .shopping-expense-amount-input {
flex: 1 1 auto !important;
min-width: 0;
}
.endpoint-view_list .shopping-expense-input-group > .shopping-compact-submit {
flex: 0 0 5rem !important;
width: 5rem !important;
min-width: 5rem !important;
padding-left: .35rem;
padding-right: .35rem;
}
.endpoint-view_list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-label,
.endpoint-view_list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-label {
display: none;
}
.endpoint-view_list .shopping-product-input-group > .shopping-compact-submit .shopping-btn-icon,
.endpoint-view_list .shopping-expense-input-group > .shopping-compact-submit .shopping-btn-icon {
margin: 0;
font-size: 1rem;
}
}
/* --- JS render + progress bar consistency patch --- */
#progress-bar-purchased {
background: linear-gradient(135deg, rgba(39,208,125,0.96), rgba(22,150,91,0.98)) !important;
}
#progress-bar-not-purchased {
background: linear-gradient(135deg, rgba(246,196,83,0.96), rgba(224,164,26,0.98)) !important;
}
#progress-bar-remaining {
background: rgba(255,255,255,0.08) !important;
}
+661
View File
@@ -0,0 +1,661 @@
/* =========================================================
Shopping item rows: shared base alignment + mobile fixes
========================================================= */
/* v10.2 item row consistency and mobile share fixes */
.shopping-item-text {
line-height: 1.35;
}
.shopping-item-text .info-line {
display: inline;
flex-basis: auto;
margin-top: 0;
white-space: normal;
}
.shopping-item-text .info-line > span {
display: inline;
}
.shopping-item-text .item-added-by-meta {
color: currentColor;
opacity: .72;
font-size: .92em;
}
.shopping-item-text .item-added-by-meta b {
font-weight: 600;
}
.shopping-item-name,
.shopping-item-text .info-line {
overflow-wrap: break-word;
word-break: normal;
}
.shopping-item-actions {
flex: 0 0 auto;
}
@media (max-width: 575.98px) {
.shopping-item-head {
flex-wrap: wrap;
align-items: flex-start;
}
.shopping-item-text {
flex: 1 1 100%;
min-width: 0;
}
.shopping-item-actions {
width: 100%;
margin-left: 0;
justify-content: flex-end;
}
}
/* v10.3 alignment fixes for list/share */
.hide-purchased-switch--right {
display: inline-flex !important;
align-items: center;
justify-content: flex-end;
gap: .6rem;
width: auto;
margin-left: auto;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.hide-purchased-switch--right::before,
.hide-purchased-switch--right::after {
display: none !important;
}
.hide-purchased-switch--right .form-check-input {
order: 1;
margin: 0;
float: none;
flex: 0 0 auto;
}
.hide-purchased-switch--right .form-check-label {
order: 0;
margin: 0 !important;
white-space: nowrap;
}
.list-action-row {
display: flex;
flex-wrap: nowrap;
align-items: stretch;
gap: .65rem;
}
.list-action-row__form {
display: flex;
flex: 1 1 50%;
}
.list-action-row__btn {
width: 100%;
}
.list-action-row > .list-action-row__btn {
flex: 1 1 50%;
}
.shopping-item-head {
flex-wrap: nowrap;
align-items: center;
}
.shopping-item-text {
flex: 1 1 auto;
min-width: 0;
}
.shopping-item-text .info-line {
flex-basis: auto;
}
.shopping-item-actions {
align-items: center;
justify-content: flex-end;
flex: 0 0 auto;
white-space: nowrap;
}
.shopping-item-actions .btn {
flex: 0 0 auto;
}
@media (max-width: 575.98px) {
.shopping-item-main {
align-items: center;
}
.shopping-item-head {
flex-wrap: nowrap;
align-items: center;
gap: .4rem;
}
.shopping-item-text {
flex: 1 1 auto;
min-width: 0;
gap: .25rem;
}
.shopping-item-actions {
width: auto;
margin-left: auto;
gap: .25rem;
}
.shopping-item-actions .btn {
min-width: 2rem;
padding: .3rem .42rem;
}
.hide-purchased-switch--right {
width: auto;
max-width: 100%;
}
.list-action-row {
gap: .5rem;
}
.list-action-row > .list-action-row__btn,
.list-action-row__form {
flex: 1 1 50%;
min-width: 0;
}
.list-action-row__btn {
padding-left: .55rem;
padding-right: .55rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
/* layout polish 2026-03-15: toolbar, sorting and item alignment */
.list-header-toolbar {
width: 100%;
}
.list-header-toolbar > h2 {
flex: 1 1 auto;
min-width: 0;
}
.list-header-toolbar .list-toolbar {
flex: 0 0 auto;
width: auto;
margin-left: auto;
}
.list-toolbar__sort.btn {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
border-width: 1px !important;
}
.drag-handle {
display: none !important;
cursor: grab;
}
body.sorting-active .drag-handle {
display: inline-flex !important;
}
body.sorting-active .shopping-item-row {
cursor: grab;
}
body.sorting-active .shopping-item-row:active,
body.sorting-active .drag-handle:active {
cursor: grabbing;
}
body.sorting-active .shopping-item-row .large-checkbox {
pointer-events: none;
}
.endpoint-list_share .shopping-item-name,
.endpoint-list_share .shopping-item-text .info-line,
.endpoint-shared_list .shopping-item-name,
.endpoint-shared_list .shopping-item-text .info-line {
overflow-wrap: break-word;
word-break: normal;
}
.endpoint-list_share input[type="checkbox"].large-checkbox::before,
.endpoint-shared_list input[type="checkbox"].large-checkbox::before,
.endpoint-view_list input[type="checkbox"].large-checkbox::before {
font-size: 1.7em;
}
@media (max-width: 575.98px) {
.endpoint-view_list .list-toolbar {
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center !important;
gap: .4rem !important;
flex-wrap: nowrap !important;
}
.endpoint-view_list .list-toolbar__sort.btn {
min-width: 0;
padding: .35rem .55rem;
font-size: .82rem;
}
.endpoint-view_list .hide-purchased-switch--right {
min-width: 0;
gap: .25rem;
font-size: .82rem;
}
.endpoint-view_list .hide-purchased-switch--right .form-check-label {
margin-left: .25rem !important;
white-space: nowrap;
}
.endpoint-view_list .hide-purchased-switch--right .form-check-input {
transform: scale(.92);
transform-origin: center;
}
.list-header-toolbar {
align-items: flex-start !important;
}
.list-header-toolbar .list-toolbar {
width: 100%;
justify-content: flex-end !important;
}
}
/* hotfix 2026-03-15 v3: /share item layout parity with /list */
/* hotfix 2026-03-15: /share item layout parity with /list */
.endpoint-list_share .shopping-item-row,
.endpoint-shared_list .shopping-item-row {
overflow: hidden;
padding: .8rem .95rem;
}
.endpoint-list_share .shopping-item-main,
.endpoint-shared_list .shopping-item-main,
.endpoint-view_list .shopping-item-main,
.endpoint-list .shopping-item-main {
display: flex;
align-items: center;
gap: .75rem;
width: 100%;
}
.endpoint-list_share .shopping-item-content,
.endpoint-shared_list .shopping-item-content,
.endpoint-view_list .shopping-item-content,
.endpoint-list .shopping-item-content {
flex: 1 1 auto;
min-width: 0;
width: auto;
}
.endpoint-list_share .shopping-item-head,
.endpoint-shared_list .shopping-item-head,
.endpoint-view_list .shopping-item-head,
.endpoint-list .shopping-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
}
.endpoint-list_share .shopping-item-text,
.endpoint-shared_list .shopping-item-text,
.endpoint-view_list .shopping-item-text,
.endpoint-list .shopping-item-text {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
align-content: center;
gap: .35rem;
flex-wrap: wrap;
}
.endpoint-list_share .shopping-item-name,
.endpoint-shared_list .shopping-item-name,
.endpoint-view_list .shopping-item-name,
.endpoint-list .shopping-item-name {
display: inline;
min-width: 0;
max-width: 100%;
white-space: normal;
overflow-wrap: break-word;
word-break: normal;
}
.endpoint-list_share .shopping-item-text .badge,
.endpoint-shared_list .shopping-item-text .badge,
.endpoint-view_list .shopping-item-text .badge,
.endpoint-list .shopping-item-text .badge,
.endpoint-list_share .shopping-item-text .info-line,
.endpoint-shared_list .shopping-item-text .info-line,
.endpoint-view_list .shopping-item-text .info-line,
.endpoint-list .shopping-item-text .info-line {
align-self: center;
}
.endpoint-list_share .shopping-item-text .info-line,
.endpoint-shared_list .shopping-item-text .info-line,
.endpoint-view_list .shopping-item-text .info-line,
.endpoint-list .shopping-item-text .info-line {
display: block;
flex: 0 0 100%;
width: 100%;
margin-top: .1rem;
white-space: normal;
overflow-wrap: break-word;
word-break: normal;
}
.endpoint-list_share .shopping-item-actions,
.endpoint-shared_list .shopping-item-actions,
.endpoint-view_list .shopping-item-actions,
.endpoint-list .shopping-item-actions {
display: inline-flex;
flex: 0 0 auto;
flex-wrap: nowrap;
align-items: center;
align-self: center;
justify-content: flex-end;
gap: .35rem;
margin-left: 0;
white-space: nowrap;
}
.endpoint-list_share .shopping-item-actions .btn,
.endpoint-shared_list .shopping-item-actions .btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.25rem;
padding: .34rem .48rem;
}
.endpoint-list_share .shopping-item-main > .large-checkbox,
.endpoint-shared_list .shopping-item-main > .large-checkbox,
.endpoint-view_list .shopping-item-main > .large-checkbox {
flex: 0 0 auto;
align-self: center;
}
@media (max-width: 575.98px) {
.endpoint-list_share .shopping-item-main,
.endpoint-shared_list .shopping-item-main,
.endpoint-view_list .shopping-item-main,
.endpoint-list .shopping-item-main {
gap: .55rem;
}
.endpoint-list_share .shopping-item-head,
.endpoint-shared_list .shopping-item-head,
.endpoint-view_list .shopping-item-head,
.endpoint-list .shopping-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: .45rem;
}
.endpoint-list_share .shopping-item-text,
.endpoint-shared_list .shopping-item-text,
.endpoint-view_list .shopping-item-text,
.endpoint-list .shopping-item-text {
flex: 1 1 auto;
min-width: 0;
gap: .25rem;
}
.endpoint-list_share .shopping-item-actions,
.endpoint-shared_list .shopping-item-actions,
.endpoint-view_list .shopping-item-actions,
.endpoint-list .shopping-item-actions {
align-self: start;
width: auto;
margin-left: auto;
gap: .25rem;
}
.endpoint-list_share .shopping-item-actions .btn,
.endpoint-shared_list .shopping-item-actions .btn {
min-width: 2rem;
padding: .3rem .42rem;
}
}
.endpoint-list_share input[type="checkbox"].large-checkbox::before,
.endpoint-shared_list input[type="checkbox"].large-checkbox::before {
font-size: 1.75em;
}
/* mobile menu simplification 2026-03-15 */
.app-mobile-menu {
display: flex;
align-items: center;
}
.app-mobile-menu__toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 14px;
background: rgba(255,255,255,0.04);
}
.app-mobile-menu__panel {
min-width: 14rem;
padding: .4rem;
border-radius: 16px;
background: linear-gradient(180deg, rgba(14,22,35,0.98), rgba(9,15,26,0.98)) !important;
border: 1px solid rgba(255,255,255,0.1);
box-shadow: 0 18px 36px rgba(0,0,0,0.28);
}
.app-mobile-menu__item {
display: flex;
align-items: center;
gap: .55rem;
min-height: 2.6rem;
padding: .55rem .75rem;
color: #fff;
border-radius: 12px;
}
.app-mobile-menu__item:hover,
.app-mobile-menu__item:focus {
background: rgba(255,255,255,0.08);
color: #fff;
}
@media (min-width: 992px) {
.app-mobile-menu {
display: none !important;
}
}
/* v5.1 create-list temp toggle mobile/Desktop fix */
.create-list-input-group {
display: flex;
flex-wrap: nowrap !important;
align-items: stretch;
gap: 0 !important;
}
.create-list-input-group > .form-control {
flex: 1 1 auto !important;
width: 1% !important;
min-width: 0 !important;
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.create-list-input-group > .create-list-temp-toggle,
.create-list-input-group > #tempToggle {
flex: 0 0 auto !important;
width: auto !important;
min-width: 10rem;
margin-left: -1px;
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
border-top-right-radius: 14px !important;
border-bottom-right-radius: 14px !important;
background: rgba(255,255,255,0.03) !important;
border-color: var(--app-border) !important;
color: var(--app-text) !important;
transition: background-color .18s ease, border-color .18s ease, color .18s ease, box-shadow .18s ease;
}
.create-list-input-group > .create-list-temp-toggle.is-active,
.create-list-input-group > #tempToggle.is-active {
background: rgba(41, 209, 125, 0.16) !important;
border-color: rgba(41, 209, 125, 0.72) !important;
color: #9bf0c1 !important;
box-shadow: inset 0 0 0 1px rgba(41, 209, 125, 0.15);
}
.create-list-input-group > .create-list-temp-toggle:hover,
.create-list-input-group > #tempToggle:hover,
.create-list-input-group > .create-list-temp-toggle:focus,
.create-list-input-group > #tempToggle:focus {
background: rgba(255,255,255,0.06) !important;
color: var(--app-text) !important;
}
.create-list-input-group > .create-list-temp-toggle.is-active:hover,
.create-list-input-group > #tempToggle.is-active:hover,
.create-list-input-group > .create-list-temp-toggle.is-active:focus,
.create-list-input-group > #tempToggle.is-active:focus {
background: rgba(41, 209, 125, 0.2) !important;
color: #b7f7d2 !important;
}
@media (max-width: 767.98px) {
.create-list-input-group {
gap: 0 !important;
}
.create-list-input-group > .form-control {
padding-left: .9rem;
padding-right: .75rem;
}
.create-list-input-group > .create-list-temp-toggle,
.create-list-input-group > #tempToggle {
min-width: 8.75rem;
padding-left: .85rem;
padding-right: .85rem;
font-size: .92rem;
letter-spacing: 0;
}
}
.endpoint-list_share .shopping-item-actions,
.endpoint-shared_list .shopping-item-actions,
.endpoint-view_list .shopping-item-actions,
.endpoint-list .shopping-item-actions {
gap: .35rem !important;
min-height: 2.35rem !important;
}
.endpoint-list_share .shopping-action-btn,
.endpoint-shared_list .shopping-action-btn,
.endpoint-view_list .shopping-action-btn,
.endpoint-list .shopping-action-btn {
width: 2.35rem !important;
height: 2.35rem !important;
min-width: 2.35rem !important;
min-height: 2.35rem !important;
padding: 0 !important;
border-radius: .7rem !important;
font-size: 1rem !important;
line-height: 1 !important;
}
.endpoint-list_share .shopping-action-btn--wide,
.endpoint-shared_list .shopping-action-btn--wide,
.endpoint-view_list .shopping-action-btn--wide,
.endpoint-list .shopping-action-btn--wide {
width: auto !important;
min-width: 5.9rem !important;
padding: 0 .8rem !important;
}
.endpoint-list_share .shopping-action-btn--countdown,
.endpoint-shared_list .shopping-action-btn--countdown,
.endpoint-view_list .shopping-action-btn--countdown,
.endpoint-list .shopping-action-btn--countdown {
width: auto !important;
min-width: 3.2rem !important;
padding: 0 .65rem !important;
}
.endpoint-list_share .shopping-action-btn > *,
.endpoint-shared_list .shopping-action-btn > *,
.endpoint-view_list .shopping-action-btn > *,
.endpoint-list .shopping-action-btn > * {
line-height: 1 !important;
}
@media (max-width: 575.98px) {
.endpoint-list_share .shopping-action-btn,
.endpoint-shared_list .shopping-action-btn,
.endpoint-view_list .shopping-action-btn,
.endpoint-list .shopping-action-btn {
width: 2.15rem !important;
height: 2.15rem !important;
min-width: 2.15rem !important;
min-height: 2.15rem !important;
border-radius: .65rem !important;
}
.endpoint-list_share .shopping-action-btn--wide,
.endpoint-shared_list .shopping-action-btn--wide,
.endpoint-view_list .shopping-action-btn--wide,
.endpoint-list .shopping-action-btn--wide {
min-width: 5.4rem !important;
padding: 0 .72rem !important;
}
}
@media (max-width: 575.98px) {
.endpoint-list_share .shopping-action-btn--countdown,
.endpoint-shared_list .shopping-action-btn--countdown,
.endpoint-view_list .shopping-action-btn--countdown,
.endpoint-list .shopping-action-btn--countdown {
min-width: 3rem !important;
padding: 0 .55rem !important;
}
}
body:not(.sorting-active) .drag-handle {
display: none !important;
}
@@ -0,0 +1,492 @@
/* =========================================================
Consistent form controls + password toggle
========================================================= */
:root {
--ui-control-height: 42px;
--ui-control-radius: 14px;
--ui-control-focus-ring: 0 0 0 .25rem rgba(24, 64, 118, .18);
}
.ui-consistent-input {
min-height: var(--ui-control-height) !important;
border-radius: var(--ui-control-radius) !important;
}
.ui-password-group {
display: flex !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
width: 100% !important;
gap: 0 !important;
overflow: hidden !important;
border: 1px solid var(--ui-border-strong) !important;
border-radius: var(--ui-control-radius) !important;
background: rgba(255,255,255,0.04) !important;
background-image: none !important;
box-shadow: none !important;
}
.ui-password-group:focus-within {
border-color: rgba(25, 135, 84, 0.6) !important;
box-shadow: 0 0 0 0.2rem rgba(25, 135, 84, 0.16) !important;
}
.ui-password-group > .form-control {
flex: 1 1 auto !important;
width: 1% !important;
min-width: 0 !important;
min-height: var(--ui-control-height) !important;
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
background-clip: padding-box !important;
}
.ui-password-group > .form-control:focus {
box-shadow: none !important;
background: transparent !important;
background-color: transparent !important;
}
.ui-password-group > .ui-password-toggle {
appearance: none !important;
-webkit-appearance: none !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
flex: 0 0 46px !important;
width: 46px !important;
min-width: 46px !important;
min-height: var(--ui-control-height) !important;
padding: 0 !important;
margin: 0 !important;
cursor: pointer !important;
background: transparent !important;
background-color: transparent !important;
background-image: none !important;
color: rgba(255,255,255,0.72) !important;
border: 0 !important;
border-left: 1px solid rgba(255, 255, 255, 0.10) !important;
border-radius: 0 !important;
box-shadow: none !important;
line-height: 1 !important;
}
.ui-password-group > .ui-password-toggle:hover,
.ui-password-group > .ui-password-toggle:focus,
.ui-password-group > .ui-password-toggle:focus-visible {
background: rgba(255,255,255,0.03) !important;
background-color: rgba(255,255,255,0.03) !important;
color: #fff !important;
border-left-color: rgba(255,255,255,0.16) !important;
box-shadow: none !important;
outline: none !important;
}
.ui-password-group > .ui-password-toggle.is-active {
background: rgba(255,255,255,0.05) !important;
background-color: rgba(255,255,255,0.05) !important;
color: #fff !important;
}
.ui-password-group > .ui-password-toggle > * {
pointer-events: none !important;
}
.endpoint-login form .form-control:not(.form-control-sm),
.endpoint-system_auth form .form-control:not(.form-control-sm),
.endpoint-edit_my_list form .form-control:not(.form-control-sm):not(.form-control-plaintext),
.endpoint-edit_list form .form-control:not(.form-control-sm):not(.form-control-plaintext),
.endpoint-user_management form .form-control:not(.form-control-sm),
.endpoint-user_management .modal .form-control:not(.form-control-sm),
.endpoint-edit_my_list form .form-select,
.endpoint-edit_list form .form-select,
.endpoint-user_management form .form-select,
.endpoint-user_management .modal .form-select {
min-height: var(--ui-control-height) !important;
}
.endpoint-login form .form-control.ui-consistent-input,
.endpoint-system_auth form .form-control.ui-consistent-input,
.endpoint-edit_my_list form .ui-consistent-input,
.endpoint-edit_list form .ui-consistent-input,
.endpoint-user_management form .ui-consistent-input,
.endpoint-user_management .modal .ui-consistent-input {
border-radius: var(--ui-control-radius) !important;
}
/*
Password fields use only the generic ui-password-group rules above.
Keep endpoint-specific overrides out of login/system_auth/admin screens,
otherwise future changes start fighting through selector specificity.
*/
.ui-password-group > .form-control.ui-consistent-input {
border-radius: 0 !important;
}
.endpoint-edit_my_list .access-editor .access-input,
.endpoint-edit_list .input-group.input-group-sm .form-control {
min-height: var(--ui-control-height) !important;
}
@media (max-width: 575.98px) {
.ui-password-group > .ui-password-toggle {
flex-basis: 44px !important;
width: 44px !important;
min-width: 44px !important;
}
}
/* wyróżnienie pola dodawania produktu */
.endpoint-list .shopping-entry-card,
.endpoint-list_share .shopping-entry-card,
.endpoint-shared_list .shopping-entry-card,
.endpoint-view_list .shopping-entry-card {
background: linear-gradient(180deg, rgba(25, 135, 84, 0.16), rgba(13, 17, 23, 0.92));
border: 1px solid rgba(25, 135, 84, 0.42);
border-radius: 1rem;
padding: .9rem;
box-shadow: 0 .5rem 1.2rem rgba(0, 0, 0, 0.18);
}
.endpoint-list .shopping-entry-card__label,
.endpoint-list_share .shopping-entry-card__label,
.endpoint-shared_list .shopping-entry-card__label,
.endpoint-view_list .shopping-entry-card__label {
display: inline-flex;
align-items: center;
gap: .4rem;
margin-bottom: .2rem;
font-size: .95rem;
font-weight: 700;
color: #d1f7df;
}
.endpoint-list .shopping-entry-card__hint,
.endpoint-list_share .shopping-entry-card__hint,
.endpoint-shared_list .shopping-entry-card__hint,
.endpoint-view_list .shopping-entry-card__hint {
margin-bottom: .75rem;
color: rgba(255, 255, 255, 0.72);
font-size: .82rem;
line-height: 1.35;
}
.endpoint-list .shopping-entry-card .shopping-product-input-group,
.endpoint-list_share .shopping-entry-card .shopping-product-input-group,
.endpoint-shared_list .shopping-entry-card .shopping-product-input-group,
.endpoint-view_list .shopping-entry-card .shopping-product-input-group {
margin-bottom: 0 !important;
}
.endpoint-list .shopping-entry-card .shopping-product-input-group > .form-control,
.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .form-control,
.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .form-control,
.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .form-control {
border-color: rgba(25, 135, 84, 0.55) !important;
background: rgba(17, 24, 39, 0.95) !important;
}
.endpoint-list .shopping-entry-card .shopping-product-input-group > .form-control::placeholder,
.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .form-control::placeholder,
.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .form-control::placeholder,
.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .form-control::placeholder {
color: rgba(255, 255, 255, 0.62);
}
.endpoint-list .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus,
.endpoint-list_share .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus,
.endpoint-shared_list .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus,
.endpoint-view_list .shopping-entry-card .shopping-product-input-group > .shopping-product-name-input:focus {
box-shadow: inset 0 0 0 1px rgba(25, 135, 84, 0.25), 0 0 0 .2rem rgba(25, 135, 84, 0.18);
}
@media (max-width: 767.98px) {
.endpoint-list .shopping-entry-card,
.endpoint-list_share .shopping-entry-card,
.endpoint-shared_list .shopping-entry-card,
.endpoint-view_list .shopping-entry-card {
padding: .8rem;
border-radius: .95rem;
}
.endpoint-list .shopping-entry-card__label,
.endpoint-list_share .shopping-entry-card__label,
.endpoint-shared_list .shopping-entry-card__label,
.endpoint-view_list .shopping-entry-card__label {
font-size: .92rem;
}
.endpoint-list .shopping-entry-card__hint,
.endpoint-list_share .shopping-entry-card__hint,
.endpoint-shared_list .shopping-entry-card__hint,
.endpoint-view_list .shopping-entry-card__hint {
font-size: .78rem;
margin-bottom: .65rem;
}
}
/* ========== /expenses mobile fixes: separate range pickers + better wrapping ========== */
.endpoint-expenses .expenses-range-toolbar {
width: 100%;
}
.endpoint-expenses .expenses-range-group {
flex-wrap: wrap;
justify-content: center;
width: 100%;
}
.endpoint-expenses .expenses-range-group > .btn {
white-space: nowrap;
}
.endpoint-expenses .expenses-date-range {
align-items: stretch;
}
@media (max-width: 767.98px) {
.endpoint-expenses .expenses-range-toolbar {
justify-content: stretch !important;
overflow: visible;
padding-bottom: 0;
}
.endpoint-expenses .expenses-range-group {
display: grid !important;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
width: 100%;
min-width: 0;
}
.endpoint-expenses .expenses-table-toolbar .expenses-range-group {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.endpoint-expenses .expenses-range-group > .btn {
flex: initial !important;
width: 100%;
min-width: 0;
padding-inline: 0.55rem;
font-size: 0.95rem;
}
.endpoint-expenses .expenses-date-range {
display: grid !important;
grid-template-columns: 52px minmax(0, 1fr);
gap: 0.55rem;
width: 100%;
max-width: 100% !important;
flex-wrap: wrap !important;
}
.endpoint-expenses .expenses-date-range > .input-group-text,
.endpoint-expenses .expenses-date-range > .form-control,
.endpoint-expenses .expenses-date-range > .btn {
width: 100% !important;
min-width: 0 !important;
flex: initial !important;
border-radius: 0.85rem !important;
}
.endpoint-expenses .expenses-date-range > .btn {
grid-column: 1 / -1;
}
}
/* /share expense entry card aligned with product card */
.endpoint-list .shopping-entry-card--expense,
.endpoint-list_share .shopping-entry-card--expense,
.endpoint-shared_list .shopping-entry-card--expense,
.endpoint-view_list .shopping-entry-card--expense {
background: linear-gradient(180deg, rgba(13, 110, 253, 0.16), rgba(13, 17, 23, 0.92));
border-color: rgba(13, 110, 253, 0.42);
}
.endpoint-list .shopping-entry-card--expense .shopping-entry-card__label,
.endpoint-list_share .shopping-entry-card--expense .shopping-entry-card__label,
.endpoint-shared_list .shopping-entry-card--expense .shopping-entry-card__label,
.endpoint-view_list .shopping-entry-card--expense .shopping-entry-card__label {
color: #d7e9ff;
}
.endpoint-list .shopping-entry-card--expense .shopping-expense-input-group,
.endpoint-list_share .shopping-entry-card--expense .shopping-expense-input-group,
.endpoint-shared_list .shopping-entry-card--expense .shopping-expense-input-group,
.endpoint-view_list .shopping-entry-card--expense .shopping-expense-input-group {
margin-bottom: 0 !important;
}
.endpoint-list .shopping-entry-card--expense .shopping-expense-input-group > .form-control,
.endpoint-list_share .shopping-entry-card--expense .shopping-expense-input-group > .form-control,
.endpoint-shared_list .shopping-entry-card--expense .shopping-expense-input-group > .form-control,
.endpoint-view_list .shopping-entry-card--expense .shopping-expense-input-group > .form-control {
border-color: rgba(13, 110, 253, 0.55) !important;
background: rgba(17, 24, 39, 0.95) !important;
}
.endpoint-list .shopping-entry-card--expense .shopping-expense-input-group > .form-control::placeholder,
.endpoint-list_share .shopping-entry-card--expense .shopping-expense-input-group > .form-control::placeholder,
.endpoint-shared_list .shopping-entry-card--expense .shopping-expense-input-group > .form-control::placeholder,
.endpoint-view_list .shopping-entry-card--expense .shopping-expense-input-group > .form-control::placeholder {
color: rgba(255, 255, 255, 0.62);
}
.endpoint-list .shopping-entry-card--expense .shopping-expense-input-group > .shopping-expense-amount-input:focus,
.endpoint-list_share .shopping-entry-card--expense .shopping-expense-input-group > .shopping-expense-amount-input:focus,
.endpoint-shared_list .shopping-entry-card--expense .shopping-expense-input-group > .shopping-expense-amount-input:focus,
.endpoint-view_list .shopping-entry-card--expense .shopping-expense-input-group > .shopping-expense-amount-input:focus {
box-shadow: inset 0 0 0 1px rgba(13, 110, 253, 0.25), 0 0 0 .2rem rgba(13, 110, 253, 0.18);
}
.endpoint-list .share-submit-btn--expense,
.endpoint-list_share .share-submit-btn--expense,
.endpoint-shared_list .share-submit-btn--expense,
.endpoint-view_list .share-submit-btn--expense {
color: #8ec5ff;
border-color: rgba(13, 110, 253, 0.72) !important;
background: rgba(13, 110, 253, 0.12);
}
.endpoint-list .share-submit-btn--expense:hover,
.endpoint-list_share .share-submit-btn--expense:hover,
.endpoint-shared_list .share-submit-btn--expense:hover,
.endpoint-view_list .share-submit-btn--expense:hover,
.endpoint-list .share-submit-btn--expense:focus,
.endpoint-list_share .share-submit-btn--expense:focus,
.endpoint-shared_list .share-submit-btn--expense:focus,
.endpoint-view_list .share-submit-btn--expense:focus {
color: #fff;
border-color: rgba(13, 110, 253, 0.9) !important;
background: rgba(13, 110, 253, 0.22);
box-shadow: 0 0 0 .2rem rgba(13, 110, 253, 0.16);
}
/* UX polish 2026-03-19: list quick actions card */
.list-quick-actions {
display: grid;
gap: .9rem;
padding: 1rem;
border: 1px solid rgba(255,255,255,.08);
border-radius: 1rem;
background: linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02));
box-shadow: 0 .5rem 1.25rem rgba(0,0,0,.14);
}
.list-quick-actions__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: .75rem;
}
.list-quick-actions__eyebrow {
font-size: .72rem;
letter-spacing: .08em;
text-transform: uppercase;
color: rgba(255,255,255,.58);
margin-bottom: .15rem;
}
.list-quick-actions__title {
font-size: 1rem;
font-weight: 700;
color: #fff;
line-height: 1.2;
}
.list-quick-actions__hint {
font-size: .82rem;
color: rgba(255,255,255,.62);
text-align: right;
max-width: 18rem;
}
.list-quick-actions__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .75rem;
}
.list-quick-actions__form {
display: block;
}
.list-quick-actions__action.btn {
width: 100%;
min-height: 78px;
display: flex;
align-items: flex-start;
gap: .75rem;
padding: .9rem 1rem;
border-radius: .95rem;
text-align: left;
white-space: normal;
}
.list-quick-actions__action--primary.btn {
border-color: rgba(255,255,255,.2);
background: rgba(255,255,255,.03);
}
.list-quick-actions__action--secondary.btn {
border-color: rgba(13,110,253,.5);
background: rgba(13,110,253,.08);
}
.list-quick-actions__icon {
flex: 0 0 auto;
font-size: 1.05rem;
line-height: 1;
margin-top: .1rem;
}
.list-quick-actions__content {
display: grid;
gap: .2rem;
min-width: 0;
}
.list-quick-actions__label {
font-size: .95rem;
font-weight: 600;
color: #fff;
line-height: 1.25;
}
.list-quick-actions__desc {
font-size: .81rem;
color: rgba(255,255,255,.66);
line-height: 1.35;
}
@media (max-width: 767.98px) {
.list-quick-actions {
padding: .9rem;
gap: .75rem;
}
.list-quick-actions__header {
flex-direction: column;
gap: .35rem;
}
.list-quick-actions__hint {
max-width: none;
text-align: left;
}
.list-quick-actions__grid {
grid-template-columns: 1fr;
}
.list-quick-actions__action.btn {
min-height: 72px;
padding: .85rem .9rem;
}
}
+268
View File
@@ -0,0 +1,268 @@
/* =========================================================
Mobile navbar and user chip
========================================================= */
.app-navbar__meta--mobile {
display: none;
}
.app-user-chip--mobile {
max-width: min(46vw, 15rem);
min-width: 0;
padding-left: .6rem;
padding-right: .4rem;
}
.app-user-chip--mobile .badge {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 991.98px) {
.app-navbar .container-xxl {
grid-template-columns: minmax(0, 1fr) auto auto;
}
.app-navbar__meta--mobile {
display: flex !important;
width: auto;
justify-content: flex-end;
justify-self: end;
grid-column: 2;
min-width: 0;
max-width: min(42vw, 12rem);
}
.app-user-chip--mobile {
display: inline-flex;
}
.app-mobile-menu {
grid-column: 3;
justify-self: end;
}
}
@media (max-width: 575.98px) {
.app-brand__icon {
width: 2.25rem;
height: 2.25rem;
}
.app-user-chip--mobile {
gap: .35rem;
padding: .34rem .38rem .34rem .5rem;
}
.app-user-chip--mobile .app-user-chip__label {
font-size: .62rem;
letter-spacing: .05em;
}
.app-user-chip--mobile .badge {
font-size: .72rem;
max-width: 5.9rem;
}
}
@media (max-width: 430px) {
.app-navbar .container-xxl {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: .45rem;
}
.app-user-chip--mobile {
max-width: min(38vw, 8.5rem);
}
.app-user-chip--mobile .app-user-chip__label {
display: none;
}
}
/* --- Main page list progress consistency --- */
.endpoint-main_page .list-group-item {
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
}
.endpoint-main_page .main-list-progress-wrap {
display: block;
width: 100%;
flex: 0 0 100%;
margin-top: 0.8rem !important;
}
.endpoint-main_page .list-group-item > .main-list-row + .main-list-progress-wrap {
align-self: stretch;
}
.endpoint-main_page .main-list-progress {
width: 100%;
height: 16px;
margin-top: 0 !important;
border: 1px solid rgba(255, 255, 255, 0.08);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.02)),
var(--dark-700) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 4px 10px rgba(0, 0, 0, 0.18);
}
.endpoint-main_page .main-list-progress .progress-bar.bg-success {
background: linear-gradient(135deg, rgba(40, 199, 111, 0.98), rgba(22, 163, 74, 0.98)) !important;
}
.endpoint-main_page .main-list-progress .progress-bar.bg-warning {
background: linear-gradient(135deg, rgba(245, 189, 65, 0.98), rgba(217, 119, 6, 0.98)) !important;
}
.endpoint-main_page .main-list-progress .progress-bar.bg-transparent {
background: rgba(255, 255, 255, 0.08) !important;
}
.endpoint-main_page .main-list-progress__label {
max-width: calc(100% - 0.85rem);
padding: 0 0.45rem;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
letter-spacing: 0.01em;
}
@media (max-width: 575.98px) {
.endpoint-main_page .main-list-progress {
height: 15px;
}
.endpoint-main_page .main-list-progress__label {
font-size: 0.64rem;
}
}
.endpoint-main_page #mainStatsCollapse.collapsing,
.endpoint-main_page #mainStatsCollapse.show {
overflow: visible;
}
.endpoint-main_page .main-summary-card {
height: 100%;
padding: 1rem 1rem 1.05rem;
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02)), rgba(9, 16, 28, 0.88);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.2);
}
.endpoint-main_page .main-summary-card__eyebrow {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.65);
margin-bottom: 0.2rem;
}
.endpoint-main_page .main-summary-card__title {
font-size: 1.05rem;
}
.endpoint-main_page .main-summary-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem;
}
.endpoint-main_page .main-summary-stat {
padding: 0.65rem 0.75rem;
border-radius: 0.85rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.endpoint-main_page .main-summary-stat__label {
display: block;
font-size: 0.73rem;
color: rgba(255, 255, 255, 0.66);
margin-bottom: 0.15rem;
}
@media (max-width: 575.98px) {
.endpoint-main_page .main-summary-card {
padding: 0.9rem;
}
.endpoint-main_page .main-summary-stats {
grid-template-columns: 1fr;
}
}
@media (max-width: 575.98px) {
.endpoint-list_share .shopping-item-head,
.endpoint-shared_list .shopping-item-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
column-gap: .45rem;
}
.endpoint-list_share .shopping-item-actions,
.endpoint-shared_list .shopping-item-actions {
align-self: start;
margin-left: 0;
justify-self: end;
}
}
@media (max-width: 575.98px) {
.endpoint-list_share .shopping-item-main,
.endpoint-shared_list .shopping-item-main {
align-items: center;
}
.endpoint-list_share .shopping-item-text,
.endpoint-shared_list .shopping-item-text,
.endpoint-list_share .shopping-item-main > .large-checkbox,
.endpoint-shared_list .shopping-item-main > .large-checkbox,
.endpoint-list_share .shopping-item-actions,
.endpoint-shared_list .shopping-item-actions {
align-self: center;
}
.endpoint-list_share .shopping-item-actions,
.endpoint-shared_list .shopping-item-actions {
margin-left: auto;
justify-self: auto;
}
}
.endpoint-list #items,
.endpoint-view_list #items,
.endpoint-list_share #items,
.endpoint-shared_list #items {
display: flex;
flex-direction: column;
}
.endpoint-list #items > .list-group-item,
.endpoint-view_list #items > .list-group-item,
.endpoint-list_share #items > .list-group-item,
.endpoint-shared_list #items > .list-group-item {
margin: 0 !important;
border-width: 1px !important;
box-shadow: 0 4px 14px rgba(0,0,0,0.12) !important;
background-clip: padding-box;
}
.endpoint-list #items > .list-group-item + .list-group-item,
.endpoint-view_list #items > .list-group-item + .list-group-item,
.endpoint-list_share #items > .list-group-item + .list-group-item,
.endpoint-shared_list #items > .list-group-item + .list-group-item {
margin-top: 0 !important;
border-top-width: 1px !important;
}
@@ -0,0 +1,67 @@
/* =========================================================
Preview product list
========================================================= */
.preview-product-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.preview-product-summary {
padding: 0 0 0.85rem;
margin-bottom: 0.1rem;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.preview-product-section {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
.preview-product-section-title {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
}
.preview-modal-items {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
#productPreviewModal .preview-modal-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
min-width: 0;
padding: 0.9rem 1rem;
margin: 0 !important;
border-radius: 16px !important;
border: 1px solid rgba(255,255,255,0.08) !important;
background: linear-gradient(180deg, rgba(11,22,40,0.92) 0%, rgba(8,16,30,0.92) 100%) !important;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.03);
}
#productPreviewModal .preview-modal-list-item:first-child,
#productPreviewModal .preview-modal-list-item:last-child,
#productPreviewModal .list-group-flush > .list-group-item:first-child,
#productPreviewModal .list-group-flush > .list-group-item:last-child {
border-radius: 16px !important;
}
#productPreviewModal .preview-modal-list-item__name {
min-width: 0;
overflow-wrap: anywhere;
flex: 1 1 auto;
}
#productPreviewModal .preview-modal-list-item .badge {
flex-shrink: 0;
min-width: 2.5rem;
border-radius: 10px;
}
@@ -0,0 +1,111 @@
/* =========================================================
Receipt disclosure (share view)
========================================================= */
.receipt-disclosure {
width: 100%;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02));
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.receipt-disclosure:hover,
.receipt-disclosure:focus-visible {
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.18);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.24);
outline: none;
}
.receipt-disclosure.is-open {
border-color: rgba(24, 64, 118, 0.9);
background: linear-gradient(135deg, rgba(24, 64, 118, 0.22), rgba(255, 255, 255, 0.03));
}
.receipt-disclosure__content {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
}
.receipt-disclosure__icon {
display: grid;
place-items: center;
width: 48px;
height: 48px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.06);
font-size: 1.25rem;
flex-shrink: 0;
}
.receipt-disclosure__text {
min-width: 0;
flex: 1;
}
.receipt-disclosure__eyebrow {
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.52);
margin-bottom: 2px;
}
.receipt-disclosure__title {
font-size: 1rem;
font-weight: 600;
color: var(--text-strong);
}
.receipt-disclosure__meta {
display: flex;
align-items: center;
gap: 12px;
margin-left: auto;
flex-shrink: 0;
}
.receipt-disclosure__count {
min-width: 34px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--text-strong);
font-size: 0.875rem;
text-align: center;
}
.receipt-disclosure__chevron {
font-size: 1.15rem;
color: rgba(255, 255, 255, 0.7);
transition: transform 0.2s ease;
}
.receipt-disclosure.is-open .receipt-disclosure__chevron {
transform: rotate(180deg);
}
@media (max-width: 575.98px) {
.receipt-disclosure__content {
padding: 14px;
gap: 12px;
}
.receipt-disclosure__icon {
width: 42px;
height: 42px;
border-radius: 12px;
}
.receipt-disclosure__meta {
gap: 10px;
}
.receipt-disclosure__title {
font-size: 0.95rem;
}
}
+346
View File
@@ -0,0 +1,346 @@
/* =========================================================
form-control normalization
========================================================= */
.endpoint-edit_my_list .stack-form > .mb-3 > .ui-consistent-input,
.endpoint-edit_my_list .stack-form > .mb-4 > .ui-consistent-input,
.endpoint-edit_my_list .stack-form .row .ui-consistent-input,
.endpoint-edit_list form > .mb-3 > .ui-consistent-input,
.endpoint-edit_list form > .mb-4 > .ui-consistent-input,
.endpoint-edit_list form .row .ui-consistent-input,
.endpoint-user_management .row > [class*="col-"] > .ui-consistent-input,
.endpoint-user_management .modal .ui-consistent-input {
border-radius: var(--ui-control-radius) !important;
}
.endpoint-edit_my_list .ts-wrapper.single .ts-control,
.endpoint-edit_list .ts-wrapper.single .ts-control,
.endpoint-edit_my_list .ts-wrapper.multi .ts-control,
.endpoint-edit_list .ts-wrapper.multi .ts-control {
min-height: var(--ui-control-height) !important;
border-radius: var(--ui-control-radius) !important;
}
/* Share hub */
.share-hub {
border: 1px solid rgba(79, 142, 255, 0.18);
background: linear-gradient(180deg, rgba(11, 24, 43, 0.98), rgba(8, 17, 31, 0.96)) !important;
}
.share-hub .card-body {
padding: 1rem;
}
.share-hub__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.9rem;
margin-bottom: 0.85rem;
}
.share-hub__eyebrow,
.share-sheet__eyebrow {
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(186, 210, 240, 0.62);
margin-bottom: 0.35rem;
}
.share-hub__title {
font-size: 1.1rem;
font-weight: 700;
}
.share-hub__status,
.share-sheet__section-head {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
}
.share-state-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
min-height: 32px;
padding: 0.45rem 0.72rem;
font-size: 0.76rem;
font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.share-state-badge--public {
background: rgba(41, 209, 125, 0.16);
color: #dfffea;
}
.share-state-badge--private {
background: rgba(255, 255, 255, 0.06);
color: #edf5ff;
}
.share-state-badge--link {
background: rgba(79, 142, 255, 0.14);
color: #d7e7ff;
}
.share-state-badge--people {
background: rgba(255, 255, 255, 0.08);
color: #edf5ff;
}
.share-hub__note {
color: rgba(210, 224, 244, 0.74);
font-size: 0.92rem;
line-height: 1.45;
}
.share-hub__linkbox {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.035);
border-radius: 16px;
padding: 0.85rem 0.95rem;
}
.share-hub__linklabel {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.07em;
color: rgba(186, 210, 240, 0.58);
margin-bottom: 0.3rem;
}
.share-hub__linkvalue {
color: #f4f8ff;
font-size: 0.95rem;
line-height: 1.45;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-hub__actions {
display: grid;
grid-template-columns: 1fr;
gap: 0.65rem;
}
.share-hub__primary,
.share-hub__secondary,
.share-hub__manage,
.share-sheet__toggle,
.share-sheet__sticky-actions .btn,
.share-sheet__linkstack .btn,
.share-hub__manage {
white-space: nowrap;
}
.share-sheet {
height: auto !important;
max-height: min(90vh, 760px);
border-top-left-radius: 24px;
border-top-right-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: linear-gradient(180deg, rgba(8, 18, 33, 0.995), rgba(6, 13, 24, 0.99)) !important;
box-shadow: 0 -24px 60px rgba(0, 0, 0, 0.42);
}
.share-sheet__header {
align-items: flex-start;
padding: 0.85rem 1rem 0.6rem;
}
.share-sheet__body {
padding: 0 1rem calc(1rem + env(safe-area-inset-bottom));
overflow-y: auto;
}
.share-sheet__grabber {
width: 52px;
height: 5px;
border-radius: 999px;
margin: 0 auto 0.8rem;
background: rgba(255, 255, 255, 0.22);
}
.share-sheet__section {
border: 1px solid rgba(255, 255, 255, 0.07);
background: rgba(255, 255, 255, 0.035);
border-radius: 18px;
padding: 0.95rem;
margin-bottom: 0.9rem;
}
.share-sheet__section-head {
justify-content: space-between;
margin-bottom: 0.7rem;
font-weight: 600;
}
.share-sheet__linkstack,
.share-access-panel__input {
display: grid;
grid-template-columns: 1fr;
gap: 0.65rem;
}
.share-access-panel .tokens {
min-height: 2rem;
}
.share-access-panel .token {
background: rgba(255, 255, 255, 0.03);
}
.share-sheet__sticky-actions {
position: sticky;
bottom: 0;
padding-top: 0.3rem;
background: linear-gradient(180deg, rgba(6, 13, 24, 0), rgba(6, 13, 24, 0.96) 28%);
}
@media (min-width: 576px) {
.share-hub .card-body,
.share-sheet__header,
.share-sheet__body {
padding-left: 1.2rem;
padding-right: 1.2rem;
}
.share-sheet__linkstack,
.share-access-panel__input {
grid-template-columns: 1fr auto;
align-items: center;
}
}
@media (min-width: 768px) {
.share-hub .card-body {
padding: 1.15rem 1.2rem;
}
.share-hub__actions {
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
}
.share-sheet {
max-width: 760px;
margin: 0 auto;
left: 0;
right: 0;
}
}
/* v5.2 create-list unity + receipt collapse fix */
.endpoint-main_page .create-list-input-group {
display: flex;
flex-wrap: nowrap !important;
align-items: stretch;
overflow: hidden;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(7, 17, 31, 0.9);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
}
.endpoint-main_page .create-list-input-group > .create-list-title-input,
.endpoint-main_page .create-list-input-group > .form-control {
border: 0 !important;
border-right: 1px solid rgba(255, 255, 255, 0.08) !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.endpoint-main_page .create-list-input-group > .create-list-title-input:focus,
.endpoint-main_page .create-list-input-group > .form-control:focus {
background: rgba(255, 255, 255, 0.02) !important;
box-shadow: none !important;
}
.endpoint-main_page .create-list-input-group > .create-list-temp-toggle,
.endpoint-main_page .create-list-input-group > #tempToggle {
min-width: 9.5rem;
border: 0 !important;
border-radius: 0 !important;
background: rgba(255, 255, 255, 0.04) !important;
box-shadow: none !important;
}
.endpoint-main_page .create-list-input-group > .create-list-temp-toggle.is-active,
.endpoint-main_page .create-list-input-group > #tempToggle.is-active {
background: rgba(41, 209, 125, 0.18) !important;
}
.endpoint-main_page .create-list-temp-toggle__label {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 100%;
}
.endpoint-main_page .create-list-input-group:focus-within {
border-color: rgba(41, 209, 125, 0.55);
box-shadow: 0 0 0 0.18rem rgba(41, 209, 125, 0.12), 0 10px 28px rgba(0, 0, 0, 0.18);
}
.receipt-disclosure {
display: block;
padding: 0;
text-align: left;
}
.receipt-disclosure,
.receipt-disclosure:hover,
.receipt-disclosure:focus,
.receipt-disclosure:active {
width: 100%;
appearance: none;
-webkit-appearance: none;
}
.receipt-disclosure:focus-visible {
outline: none;
}
.receipt-section--restoring {
transition: none !important;
}
@media (max-width: 767.98px) {
.endpoint-main_page .create-list-input-group {
border-radius: 14px;
}
.endpoint-main_page .create-list-input-group > .create-list-temp-toggle,
.endpoint-main_page .create-list-input-group > #tempToggle {
min-width: 8.25rem;
padding-left: .8rem;
padding-right: .8rem;
font-size: .9rem;
}
}
@media (max-width: 575.98px) {
.endpoint-main_page .create-list-input-group > .create-list-title-input,
.endpoint-main_page .create-list-input-group > .form-control {
padding-left: .85rem;
padding-right: .7rem;
font-size: .95rem;
}
.endpoint-main_page .create-list-input-group > .create-list-temp-toggle,
.endpoint-main_page .create-list-input-group > #tempToggle {
min-width: 7.6rem;
font-size: .84rem;
}
.receipt-disclosure {
border-radius: 16px;
}
}
File diff suppressed because it is too large. Load diff
Binary file not shown.
@@ -19,6 +19,28 @@
tokensBox.appendChild(btn);
}
function pluralizePeople(count) {
if (count === 1) return 'osoba';
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) return 'osoby';
return 'osób';
}
function syncAccessCount(box) {
if (!box) return;
const count = $$('.token', box).length;
const sheetBadge = document.getElementById('shareSheetPeopleBadge');
const cardBadge = document.getElementById('sharePeopleBadge');
if (sheetBadge) sheetBadge.textContent = String(count);
if (cardBadge) {
cardBadge.textContent = `👥 ${count} ${pluralizePeople(count)}`;
cardBadge.classList.toggle('d-none', count === 0);
}
}
function wantsJSON() {
return {
'Accept': 'application/json',
@@ -29,7 +51,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 +83,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 +104,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 => `<option value="${u}">@${u}</option>`).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 => `<option value="${u}"></option>`).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 +139,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();
@@ -117,6 +149,7 @@
empty.textContent = 'Brak dodanych uprawnień.';
tokensBox.appendChild(empty);
}
syncAccessCount(box);
toast(`Odebrano dostęp: @${username}`, 'success');
} else {
btn.disabled = false; btn.classList.remove('disabled');
@@ -124,7 +157,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,13 +168,13 @@
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++;
syncAccessCount(box);
}
} else {
failCount++;
@@ -156,15 +188,14 @@
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);
}
}
addBtn?.addEventListener('click', addUsers);
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addUsers(); } });
syncAccessCount(box);
}
document.addEventListener('DOMContentLoaded', () => {
+114
View File
@@ -0,0 +1,114 @@
(function () {
const form = document.getElementById("settings-form");
const resetAllBtn = document.getElementById("reset-all");
if (!form) return;
function getCard(input) {
return input.closest(".settings-category-card");
}
function getAutoHex(input) {
const autoHex = (input.dataset.auto || "").trim();
return autoHex ? autoHex.toUpperCase() : "#000000";
}
function setOverrideState(input, enabled) {
const card = getCard(input);
const flag = card?.querySelector('.override-enabled');
const badge = card?.querySelector('[data-role="override-status"]');
input.dataset.hasOverride = enabled ? "1" : "0";
if (flag) flag.value = enabled ? "1" : "0";
if (badge) {
badge.textContent = enabled ? "Nadpisany" : "Domyślny";
badge.classList.toggle('text-bg-info', enabled);
badge.classList.toggle('text-bg-secondary', !enabled);
}
}
function updatePreview(input) {
const card = getCard(input);
if (!card) return;
const hexAutoEl = card.querySelector('.hex-auto');
const hexEffEl = card.querySelector('.hex-effective');
const barAuto = card.querySelector('.bar[data-kind="auto"]');
const barEff = card.querySelector('.bar[data-kind="effective"]');
const autoHex = getAutoHex(input);
const effectiveHex = ((input.value || autoHex).trim() || autoHex).toUpperCase();
const hasOverride = input.dataset.hasOverride === '1';
if (barAuto) barAuto.style.backgroundColor = autoHex;
if (hexAutoEl) hexAutoEl.textContent = autoHex;
if (barEff) barEff.style.backgroundColor = effectiveHex;
if (hexEffEl) hexEffEl.textContent = effectiveHex;
setOverrideState(input, hasOverride);
}
function applyDefaultVisual(input, keepOverride) {
input.value = getAutoHex(input);
setOverrideState(input, !!keepOverride);
updatePreview(input);
}
form.querySelectorAll('.use-default').forEach((btn) => {
btn.addEventListener('click', () => {
const input = form.querySelector(`#${btn.dataset.target}`);
if (!input) return;
applyDefaultVisual(input, true);
});
});
form.querySelectorAll('.reset-one').forEach((btn) => {
btn.addEventListener('click', () => {
const input = form.querySelector(`#${btn.dataset.target}`);
if (!input) return;
applyDefaultVisual(input, false);
});
});
resetAllBtn?.addEventListener('click', () => {
form.querySelectorAll('input[type="color"].category-color').forEach((input) => {
applyDefaultVisual(input, false);
});
});
form.querySelectorAll('input[type="color"].category-color').forEach((input) => {
updatePreview(input);
input.addEventListener('input', () => {
setOverrideState(input, true);
updatePreview(input);
});
input.addEventListener('change', () => {
setOverrideState(input, true);
updatePreview(input);
});
});
(function () {
const slider = document.getElementById('ocr_sensitivity');
const badge = document.getElementById('ocr_sens_badge');
const value = document.getElementById('ocr_sens_value');
if (!slider || !badge || !value) return;
function labelFor(v) {
v = Number(v);
if (v <= 3) return 'Niski';
if (v <= 7) return 'Średni';
return 'Wysoki';
}
function clsFor(v) {
v = Number(v);
if (v <= 3) return 'sens-low';
if (v <= 7) return 'sens-mid';
return 'sens-high';
}
function update() {
value.textContent = `(${slider.value})`;
badge.textContent = labelFor(slider.value);
badge.classList.remove('sens-low', 'sens-mid', 'sens-high');
badge.classList.add(clsFor(slider.value));
}
slider.addEventListener('input', update);
slider.addEventListener('change', update);
update();
})();
})();
+270
View File
@@ -0,0 +1,270 @@
document.addEventListener('DOMContentLoaded', function () {
enhancePasswordFields();
observePasswordFields();
enhanceSearchableTables();
wireCopyButtons();
wireUnsavedWarnings();
enhanceMobileTables();
wireAdminNavToggle();
initResponsiveCategoryBadges();
});
function initPasswordField(input) {
if (!input || input.dataset.uiPasswordReady === '1') return;
if (input.closest('[data-ui-skip-toggle="true"]')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'ui-password-toggle';
btn.setAttribute('aria-label', 'Pokaż lub ukryj hasło');
btn.setAttribute('aria-pressed', 'false');
btn.title = 'Pokaż hasło';
btn.innerHTML = '<span aria-hidden="true">👁</span>';
const syncState = function () {
const visible = input.type === 'text';
btn.innerHTML = visible ? '<span aria-hidden="true">🙈</span>' : '<span aria-hidden="true">👁</span>';
btn.classList.toggle('is-active', visible);
btn.setAttribute('aria-pressed', visible ? 'true' : 'false');
btn.title = visible ? 'Ukryj hasło' : 'Pokaż hasło';
};
btn.addEventListener('click', function () {
const selectionStart = input.selectionStart;
const selectionEnd = input.selectionEnd;
input.type = input.type === 'password' ? 'text' : 'password';
syncState();
input.focus({ preventScroll: true });
if (typeof selectionStart === 'number' && typeof selectionEnd === 'number') {
try {
input.setSelectionRange(selectionStart, selectionEnd);
} catch (err) {}
}
});
const parent = input.parentElement;
if (parent && parent.classList.contains('input-group')) {
parent.classList.add('ui-password-group');
if (!parent.querySelector(':scope > .ui-password-toggle')) {
parent.appendChild(btn);
}
} else {
const wrapper = document.createElement('div');
wrapper.className = 'input-group ui-password-group';
input.parentNode.insertBefore(wrapper, input);
wrapper.appendChild(input);
wrapper.appendChild(btn);
}
input.dataset.uiPasswordReady = '1';
syncState();
}
function enhancePasswordFields(root) {
const scope = root && root.querySelectorAll ? root : document;
if (scope.matches && scope.matches('input[type="password"]')) {
initPasswordField(scope);
}
scope.querySelectorAll('input[type="password"]').forEach(initPasswordField);
}
function observePasswordFields() {
if (window.__uiPasswordObserverReady) return;
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
mutation.addedNodes.forEach(function (node) {
if (!(node instanceof HTMLElement)) return;
enhancePasswordFields(node);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
window.__uiPasswordObserverReady = true;
}
function enhanceSearchableTables() {
if (document.getElementById('search-table')) return;
const tables = document.querySelectorAll('table.sortable, table[data-searchable="true"]');
tables.forEach(function (table, index) {
if (table.dataset.uiSearchReady === '1') return;
const tbody = table.tBodies[0];
if (!tbody) return;
const rows = Array.from(tbody.querySelectorAll('tr'));
if (rows.length < 6) return;
const toolbar = document.createElement('div');
toolbar.className = 'table-toolbar';
toolbar.innerHTML = [
'<div class="input-group input-group-sm table-toolbar__search">',
' <span class="input-group-text">🔎</span>',
' <input type="search" class="form-control" placeholder="Filtruj tabelę…" aria-label="Filtruj tabelę">',
' <button type="button" class="btn btn-outline-light">Wyczyść</button>',
'</div>',
'<div class="table-toolbar__meta text-secondary small">',
' <span class="table-toolbar__count"></span>',
'</div>'
].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();
}
@@ -0,0 +1,43 @@
(function () {
const $$ = (sel, ctx = document) => Array.from(ctx.querySelectorAll(sel));
const $ = (sel, ctx = document) => ctx.querySelector(sel);
const saveCategories = async (listId, ids, names, listTitle) => {
try {
const res = await fetch(`/admin/edit_categories/${listId}/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category_ids: ids })
});
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.ok) throw new Error(data.error || 'save_failed');
const cats = names.length ? names.join(', ') : 'brak';
showToast(`Zapisano kategorie [${cats}] dla listy <b>${listTitle}</b>`, 'success');
} catch (err) {
console.error('Autosave error:', err);
showToast(`Błąd zapisu kategorii dla listy <b>${listTitle}</b>`, 'danger');
}
};
const timers = new Map();
const debounce = (key, fn, delay = 300) => {
clearTimeout(timers.get(key));
timers.set(key, setTimeout(fn, delay));
};
$$('.form-select[name^="categories_"]').forEach(select => {
const listId = select.getAttribute('data-list-id') || select.name.replace('categories_', '');
const listTitle = select.closest('tr')?.querySelector('td a')?.textContent.trim() || `#${listId}`;
select.addEventListener('change', () => {
const selectedOptions = Array.from(select.options).filter(o => o.selected);
const ids = selectedOptions.map(o => o.value); // <-- ID
const names = selectedOptions.map(o => o.textContent.trim());
debounce(listId, () => saveCategories(listId, ids, names, listTitle));
});
});
const fallback = $('#fallback-save-btn');
if (fallback) fallback.classList.add('d-none');
})();
+18
View File
@@ -0,0 +1,18 @@
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('#categoriesModal .category-suggestion').forEach(btn => {
btn.addEventListener('click', () => {
const select = document.getElementById('category_id');
if (!select) return;
select.value = btn.dataset.catId || '';
const form = btn.closest('form');
if (form) {
if (typeof form.requestSubmit === 'function') {
form.requestSubmit();
} else {
form.submit();
}
}
});
});
});
@@ -76,7 +76,7 @@ document.addEventListener("DOMContentLoaded", function () {
}
// porzucenie zakresu
document.querySelectorAll("#chartTab .range-btn").forEach(b => b.classList.remove("active"));
document.querySelectorAll("#chartTab .chart-range-btn").forEach(b => b.classList.remove("active"));
reloadRespectingSplit();
});
@@ -90,9 +90,9 @@ document.addEventListener("DOMContentLoaded", function () {
});
// ——— Predefiniowane zakresy pod wykresem ———
document.querySelectorAll("#chartTab .range-btn").forEach((btn) => {
document.querySelectorAll("#chartTab .chart-range-btn").forEach((btn) => {
btn.addEventListener("click", function () {
document.querySelectorAll("#chartTab .range-btn").forEach((b) => b.classList.remove("active"));
document.querySelectorAll("#chartTab .chart-range-btn").forEach((b) => b.classList.remove("active"));
this.classList.add("active");
const r = this.getAttribute("data-range"); // last30days/currentmonth/monthly/quarterly/halfyearly/yearly
@@ -25,16 +25,15 @@ document.addEventListener("DOMContentLoaded", () => {
}
checkbox.disabled = true;
row.classList.add('opacity-50');
row.classList.add('opacity-50', 'is-pending');
// Dodaj spinner tylko jeśli nie ma
let existingSpinner = row.querySelector('.spinner-border');
let existingSpinner = row.querySelector('.shopping-item-spinner');
if (!existingSpinner) {
const spinner = document.createElement('span');
spinner.className = 'spinner-border spinner-border-sm ms-2';
spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm';
spinner.setAttribute('role', 'status');
spinner.setAttribute('aria-hidden', 'true');
checkbox.parentElement.appendChild(spinner);
row.appendChild(spinner);
}
});
});
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
document.addEventListener('DOMContentLoaded', () => {
const checkboxes = document.querySelectorAll('.list-checkbox');
const totalEl = document.getElementById('listsTotal');
const filterButtons = document.querySelectorAll('.range-btn');
const filterButtons = document.querySelectorAll('#listsTab .table-range-btn');
const rows = document.querySelectorAll('#listsTableBody tr');
const categoryButtons = document.querySelectorAll('.category-filter');
const applyCustomBtn = document.getElementById('applyCustomRange');
@@ -136,7 +136,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (initialLoad) {
filterByLast30Days();
} else {
const activeRange = document.querySelector('.range-btn.active');
const activeRange = document.querySelector('#listsTab .table-range-btn.active');
if (activeRange) filterByRange(activeRange.dataset.range);
}
applyExpenseFilter();
@@ -158,7 +158,7 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
initialLoad = false;
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('#listsTab .table-range-btn').forEach(b => b.classList.remove('active'));
filterByCustomRange(startStr, endStr);
applyExpenseFilter();
applyCategoryFilter();
+490
View File
@@ -0,0 +1,490 @@
function updateItemState(itemId, isChecked) {
const checkbox = document.querySelector(`#item-${itemId} input[type='checkbox']`);
if (checkbox) {
checkbox.checked = isChecked;
checkbox.disabled = false;
const li = checkbox.closest('li');
li.classList.remove('opacity-50', 'is-pending', 'bg-light', 'text-dark', 'bg-success', 'text-white', 'bg-warning', 'item-not-checked');
if (isChecked) {
li.classList.add('bg-success', 'text-white');
} else {
li.classList.add('item-not-checked');
}
li.querySelectorAll('.shopping-item-spinner, .spinner-border').forEach(sp => sp.remove());
}
updateProgressBar();
applyHidePurchased();
}
function updateProgressBar() {
const barPurchased = document.getElementById('progress-bar-purchased');
const barNotPurchased = document.getElementById('progress-bar-not-purchased');
const barRemaining = document.getElementById('progress-bar-remaining');
const progressLabel = document.getElementById('progress-label');
const percentValueEl = document.getElementById('percent-value');
if (!barPurchased || !barNotPurchased || !barRemaining || !progressLabel) {
return;
}
const items = document.querySelectorAll('#items li');
const total = items.length;
const purchased = Array.from(items).filter(li => li.classList.contains('bg-success')).length;
const notPurchased = Array.from(items).filter(li => li.classList.contains('bg-warning')).length;
const remaining = total - purchased - notPurchased;
const percentPurchased = total > 0 ? (purchased / total) * 100 : 0;
const percentNotPurchased = total > 0 ? (notPurchased / total) * 100 : 0;
const percentRemaining = total > 0 ? (remaining / total) * 100 : 0;
const percent = total > 0 ? Math.round((purchased / total) * 100) : 0;
barPurchased.style.width = `${percentPurchased}%`;
barNotPurchased.style.width = `${percentNotPurchased}%`;
barRemaining.style.width = `${percentRemaining}%`;
progressLabel.textContent = `${percent}%`;
progressLabel.classList.toggle('text-white', percent < 51);
progressLabel.classList.toggle('text-dark', percent >= 51);
const purchasedCountEl = document.getElementById('purchased-count');
const totalCountEl = document.getElementById('total-count');
if (purchasedCountEl) purchasedCountEl.textContent = purchased;
if (totalCountEl) totalCountEl.textContent = total;
if (percentValueEl) percentValueEl.textContent = percent;
}
function addItem(listId) {
const name = document.getElementById('newItem').value;
const quantityInput = document.getElementById('newQuantity');
let quantity = 1;
if (quantityInput) {
quantity = parseInt(quantityInput.value);
if (isNaN(quantity) || quantity < 1) {
quantity = 1;
}
}
if (name.trim() === '') return;
socket.emit('add_item', { list_id: listId, name: name, quantity: quantity });
document.getElementById('newItem').value = '';
if (quantityInput) quantityInput.value = 1;
document.getElementById('newItem').focus();
}
function deleteItem(id) {
if (confirm('Na pewno usunąć produkt?')) {
socket.emit('delete_item', { item_id: id });
}
}
function editItem(id, oldName, oldQuantity) {
const finalName = String(oldName ?? '').trim();
let newQuantity = parseInt(oldQuantity, 10);
if (!finalName) {
showToast('Nazwa produktu nie może być pusta.', 'warning');
return;
}
if (isNaN(newQuantity) || newQuantity < 1) {
newQuantity = 1;
}
socket.emit('edit_item', { item_id: id, new_name: finalName, new_quantity: newQuantity });
}
function openEditItemModal(event, id, oldName, oldQuantity) {
if (event && typeof event.stopPropagation === 'function') {
event.stopPropagation();
}
const modalEl = document.getElementById('editItemModal');
const idInput = document.getElementById('editItemId');
const nameInput = document.getElementById('editItemName');
const quantityInput = document.getElementById('editItemQuantity');
if (!modalEl || !idInput || !nameInput || !quantityInput || typeof bootstrap === 'undefined') {
editItem(id, oldName, oldQuantity);
return;
}
idInput.value = id;
nameInput.value = String(oldName ?? '').trim();
const parsedQuantity = parseInt(oldQuantity, 10);
quantityInput.value = !isNaN(parsedQuantity) && parsedQuantity > 0 ? parsedQuantity : 1;
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modal.show();
setTimeout(() => {
nameInput.focus();
nameInput.select();
}, 150);
}
function submitExpense(listId) {
const amountInput = document.getElementById('expenseAmount');
const amount = parseFloat(amountInput.value);
if (isNaN(amount) || amount <= 0) {
showToast('Podaj poprawną kwotę!');
return;
}
socket.emit('add_expense', {
list_id: listId,
amount: amount
});
amountInput.value = '';
}
function copyLink(link) {
if (navigator.share) {
navigator.share({
title: 'Udostępnij link',
text: 'Udostępniam link do listy:',
url: link
}).then(() => {
showToast('Link udostępniony!');
}).catch((err) => {
tryClipboard(link);
});
return;
}
tryClipboard(link);
}
function tryClipboard(link) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(link).then(() => {
showToast('Link skopiowany do schowka!');
}).catch((err) => {
console.error('Błąd clipboard API:', err);
fallbackCopyText(link);
});
} else {
fallbackCopyText(link);
}
}
function fallbackCopyText(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.top = 0;
textarea.style.left = 0;
textarea.style.opacity = 0;
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
showToast('Link skopiowany do schowka!');
} else {
showToast('Nie udało się skopiować linku', 'warning');
}
} catch (err) {
console.error('Fallback błąd kopiowania:', err);
showToast('Nie udało się skopiować linku', 'warning');
}
document.body.removeChild(textarea);
}
function openList(link) {
window.open(link, '_blank');
}
function applyHidePurchased(isInit = false) {
const toggle = document.getElementById('hidePurchasedToggle');
if (!toggle) return;
const hide = toggle.checked;
const items = document.querySelectorAll('#items li');
items.forEach(li => {
const isCheckedItem =
li.classList.contains('bg-success') || // kupione
li.classList.contains('bg-warning'); // niekupione
if (isCheckedItem) {
if (hide) {
if (isInit) {
// Jeśli inicjalizacja: od razu ukryj
li.classList.add('hide-purchased');
li.classList.remove('fade-out');
} else {
// Z animacją
li.classList.add('fade-out');
setTimeout(() => {
li.classList.add('hide-purchased');
}, 700);
}
} else {
// Odsłanianie
li.classList.remove('hide-purchased');
setTimeout(() => {
li.classList.remove('fade-out');
}, 10);
}
} else {
// Element nieoznaczony — zawsze pokazany
li.classList.remove('hide-purchased', 'fade-out');
}
});
}
function formatShareUrlPreview(url) {
return String(url || '').replace(/^https?:\/\//, '');
}
function setVisibilityBadgeState(el, isPublic, publicLabel = '🌍 Publiczna', privateLabel = '🔒 Prywatna') {
if (!el) return;
el.classList.remove('share-state-badge--public', 'share-state-badge--private');
el.classList.add(isPublic ? 'share-state-badge--public' : 'share-state-badge--private');
el.textContent = isPublic ? publicLabel : privateLabel;
}
function updateShareVisibilityUI(data) {
const shareUrl = data?.share_url || '';
const isPublic = !!data?.is_public;
const shareUrlInput = document.getElementById('shareUrlInput');
const shareUrlPreview = document.getElementById('shareUrlPreview');
const copyBtn = document.getElementById('copyBtn');
const toggleBtn = document.getElementById('toggleVisibilityBtn');
const mainNote = document.getElementById('shareVisibilityNote');
const sheetNote = document.getElementById('shareSheetVisibilityNote');
const mainOpenBtn = document.getElementById('openShareModeBtn');
const sheetOpenBtn = document.getElementById('openShareModeBtnSheet');
if (shareUrlInput) shareUrlInput.value = shareUrl;
if (shareUrlPreview) shareUrlPreview.textContent = formatShareUrlPreview(shareUrl);
if (copyBtn) copyBtn.disabled = false;
if (mainOpenBtn) mainOpenBtn.href = shareUrl;
if (sheetOpenBtn) sheetOpenBtn.href = shareUrl;
setVisibilityBadgeState(document.getElementById('shareVisibilityBadge'), isPublic);
setVisibilityBadgeState(document.getElementById('shareSheetVisibilityBadge'), isPublic, 'Publiczna', 'Prywatna');
if (mainNote) {
mainNote.textContent = isPublic
? 'Lista działa publicznie i przez link udostępniania.'
: 'Lista działa przez link udostępniania i dla zaproszonych osób.';
}
if (sheetNote) {
sheetNote.textContent = isPublic
? 'Lista jest widoczna publicznie i nadal działa przez link.'
: 'Lista nie jest publiczna, ale nadal działa przez link i dla zaproszonych osób.';
}
if (toggleBtn) {
toggleBtn.innerHTML = isPublic ? '🙈 Ustaw jako prywatną' : '🌍 Uczyń publiczną';
}
}
function toggleVisibility(listId) {
fetch('/toggle_visibility/' + listId, { method: 'POST' })
.then(response => response.json())
.then(data => {
updateShareVisibilityUI(data);
showToast(data.is_public ? 'Lista jest teraz publiczna.' : 'Lista jest teraz prywatna.', 'success');
})
.catch(() => {
showToast('Nie udało się zmienić widoczności listy.', 'danger');
});
}
function markNotPurchasedModal(e, id) {
e.stopPropagation();
const reason = prompt("Podaj powód oznaczenia jako niekupione:");
if (reason !== null) {
socket.emit('mark_not_purchased', { item_id: id, reason: reason });
}
}
function showToast(message, type = 'primary') {
const toastContainer = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = `toast align-items-center text-bg-${type} border-0 show`;
toast.setAttribute('role', 'alert');
toast.innerHTML = `<div class="d-flex"><div class="toast-body">${message}</div></div>`;
toastContainer.appendChild(toast);
setTimeout(() => { toast.remove(); }, 1750);
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function isListDifferent(oldItems, newItems) {
if (oldItems.length !== newItems.length) return true;
const oldIds = Array.from(oldItems).map(li => parseInt(li.id.replace('item-', ''), 10)).sort();
const newIds = newItems.map(i => i.id).sort();
for (let i = 0; i < newIds.length; i++) {
if (oldIds[i] !== newIds[i]) {
return true;
}
}
return false;
}
function renderItem(item, isShare = window.IS_SHARE, optionsOrShowEditOnly = false) {
const options = (typeof optionsOrShowEditOnly === 'object' && optionsOrShowEditOnly !== null)
? optionsOrShowEditOnly
: { showEditOnly: !!optionsOrShowEditOnly };
const showEditOnly = !!options.showEditOnly;
const temporaryShareUndo = !!options.temporaryShareUndo;
const countdownSeconds = Math.max(0, parseInt(options.countdownSeconds, 10) || 15);
const li = document.createElement('li');
li.id = `item-${item.id}`;
li.dataset.name = String(item.name || '').toLowerCase();
li.dataset.isShare = isShare ? 'true' : 'false';
li.className = `list-group-item shopping-item-row clickable-item ${item.purchased ? 'bg-success text-white'
: item.not_purchased ? 'bg-warning text-dark'
: 'item-not-checked'
}`;
const isOwner = window.IS_OWNER === true || window.IS_OWNER === 'true';
const isArchived = window.IS_ARCHIVED === true || window.IS_ARCHIVED === 'true';
const safeName = escapeHtml(item.name || '');
const nameForEdit = JSON.stringify(String(item.name || ''));
const quantity = Number.isInteger(item.quantity) ? item.quantity : parseInt(item.quantity, 10) || 1;
const quantityBadge = quantity > 1
? `<span class="badge rounded-pill bg-secondary">x${quantity}</span>`
: '';
const canEditListItem = !isShare;
const canShowShareActions = isShare && !showEditOnly && !temporaryShareUndo;
const canMarkNotPurchased = !item.not_purchased && !isArchived;
const checkboxHtml = `<input id="checkbox-${item.id}" class="large-checkbox" type="checkbox" ${item.purchased ? 'checked' : ''} ${(item.not_purchased || isArchived) ? 'disabled' : ''}>`;
const infoParts = [];
if (item.note) {
infoParts.push(`<span class="text-danger">[ <b>${escapeHtml(item.note)}</b> ]</span>`);
}
if (item.not_purchased_reason) {
infoParts.push(`<span class="text-dark">[ <b>Powód: ${escapeHtml(item.not_purchased_reason)}</b> ]</span>`);
}
const addedByDisplay = item.added_by_display || (isShare ? item.added_by : '');
const addedById = item.added_by_id != null ? Number(item.added_by_id) : null;
const ownerId = item.owner_id != null ? Number(item.owner_id) : null;
const shouldShowAddedBy = !!addedByDisplay && (addedById === null || ownerId === null || addedById !== ownerId);
if (shouldShowAddedBy) {
infoParts.push(`<span class="item-added-by-meta">· dodał/a: <b>${escapeHtml(addedByDisplay)}</b></span>`);
}
const infoHtml = infoParts.length
? `<span class="info-line small" id="info-${item.id}">${infoParts.join(' ')}</span>`
: '';
const iconBtn = 'btn btn-outline-light btn-sm shopping-action-btn';
const wideBtn = 'btn btn-outline-light btn-sm shopping-action-btn shopping-action-btn--wide';
let actionButtons = '';
if (canEditListItem) {
const dragHandleButton = window.isSorting
? `<button type="button" class="${iconBtn} drag-handle" title="Przesuń produkt" aria-label="Przesuń produkt" ${isArchived ? 'disabled' : ''}>☰</button>`
: '';
actionButtons += `
${dragHandleButton}
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick='openEditItemModal(event, ${item.id}, ${JSON.stringify(String(item.name || ''))}, ${quantity})'`}>✏️</button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="deleteItem(${item.id})"`}>🗑️</button>`;
}
if (item.not_purchased) {
actionButtons += `
<button type="button" class="${wideBtn}" ${isArchived ? 'disabled' : `onclick="unmarkNotPurchased(${item.id})"`}>Przywróć</button>`;
} else if (!isShare || canShowShareActions || isOwner) {
actionButtons += `
<button type="button" class="${iconBtn}" ${canMarkNotPurchased ? `onclick="markNotPurchasedModal(event, ${item.id})"` : 'disabled'}>⚠️</button>`;
}
if (temporaryShareUndo) {
actionButtons += `
<button type="button" class="${iconBtn} shopping-action-btn--countdown" disabled data-countdown-for="${item.id}">${countdownSeconds}s</button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick='openEditItemModal(event, ${item.id}, ${nameForEdit}, ${quantity})'`}>✏️</button>
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="deleteItem(${item.id})"`}>🗑️</button>`;
} else if (canShowShareActions || (!isShare && isOwner)) {
actionButtons += `
<button type="button" class="${iconBtn}" ${isArchived ? 'disabled' : `onclick="openNoteModal(event, ${item.id})"`}>📝</button>`;
}
li.innerHTML = `
<div class="shopping-item-main">
${checkboxHtml}
<div class="shopping-item-content">
<div class="shopping-item-head">
<div class="shopping-item-text">
<span id="name-${item.id}" class="shopping-item-name text-white">${safeName}</span>
${quantityBadge}
${infoHtml}
</div>
<div class="list-item-actions shopping-item-actions" role="group">
${actionButtons}
</div>
</div>
</div>
</div>`;
return li;
}
function updateListSmoothly(newItems) {
const itemsContainer = document.getElementById('items');
const existingItemsMap = new Map();
Array.from(itemsContainer.querySelectorAll('li')).forEach(li => {
const id = parseInt(li.id.replace('item-', ''), 10);
existingItemsMap.set(id, li);
});
const fragment = document.createDocumentFragment();
newItems.forEach(item => {
const li = renderItem(item);
fragment.appendChild(li);
});
itemsContainer.innerHTML = '';
itemsContainer.appendChild(fragment);
updateProgressBar();
toggleEmptyPlaceholder();
applyHidePurchased();
}
document.addEventListener("DOMContentLoaded", function () {
const toggle = document.getElementById('hidePurchasedToggle');
if (!toggle) return;
const savedState = localStorage.getItem('hidePurchasedToggle');
toggle.checked = savedState === 'true';
applyHidePurchased(true);
toggle.addEventListener('change', function () {
localStorage.setItem('hidePurchasedToggle', toggle.checked ? 'true' : 'false');
applyHidePurchased();
});
});
+22
View File
@@ -0,0 +1,22 @@
(function () {
const $=(s,r=document)=>r.querySelector(s); const $$=(s,r=document)=>Array.from(r.querySelectorAll(s));
const filterInput=$('#listFilter'),filterCount=$('#filterCount'),selectAll=$('#selectAll'),bulkTokens=$('#bulkTokens'),bulkInput=$('#bulkUsersInput'),bulkBtn=$('#bulkAddBtn');
const unique=arr=>Array.from(new Set(arr));
const parseUserText=txt=>unique((txt||'').split(/[\s,;]+/g).map(s=>s.trim().replace(/^@/,'').toLowerCase()).filter(Boolean));
const selectedListIds=()=>$$('.row-check:checked').map(ch=>ch.dataset.listId);
const visibleRows=()=>$$('#listsTable tbody tr').filter(r=>r.style.display!=='none');
function applyFilter(){const q=(filterInput?.value||'').trim().toLowerCase();let shown=0;$$('#listsTable tbody tr').forEach(tr=>{const hay=`${tr.dataset.id||''} ${tr.dataset.title||''} ${tr.dataset.owner||''}`;const ok=!q||hay.includes(q);tr.style.display=ok?'':'none';if(ok) shown++;});if(filterCount) filterCount.textContent=shown?`Widoczne: ${shown}`:'Brak wyników';}
filterInput?.addEventListener('input',applyFilter);applyFilter();
selectAll?.addEventListener('change',()=>{visibleRows().forEach(tr=>{const cb=tr.querySelector('.row-check'); if(cb) cb.checked=selectAll.checked;});});
$$('.copy-share').forEach(btn=>btn.addEventListener('click',async()=>{const url=btn.dataset.url;try{await navigator.clipboard.writeText(url);}catch{const ta=Object.assign(document.createElement('textarea'),{value:url});document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove();}showToast('Skopiowano link udostępnienia','success');}));
function addGlobalToken(username){if(!username) return;const exists=$(`.user-token[data-user="${username}"]`,bulkTokens);if(exists) return;const token=document.createElement('span');token.className='badge rounded-pill text-bg-secondary user-token';token.dataset.user=username;token.innerHTML=`@${username} <button type="button" class="btn btn-sm btn-link p-0 ms-1 text-white">✕</button>`;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=>`<option value="${u}"></option>`).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);
})();
@@ -13,7 +13,7 @@ function toggleEmptyPlaceholder() {
const li = document.createElement('li');
li.id = 'empty-placeholder';
li.className = 'list-group-item bg-dark text-secondary text-center w-100';
li.textContent = 'Brak produktów w tej liście.';
li.textContent = 'Brak produktów w tej liście.';
list.appendChild(li);
} else if (hasRealItems && placeholder) {
placeholder.remove();
@@ -88,15 +88,15 @@ function setupList(listId, username) {
}
e.target.disabled = true;
li.classList.add('opacity-50');
li.classList.add('opacity-50', 'is-pending');
let existingSpinner = li.querySelector('.spinner-border');
let existingSpinner = li.querySelector('.shopping-item-spinner');
if (!existingSpinner) {
const spinner = document.createElement('span');
spinner.className = 'spinner-border spinner-border-sm ms-2';
spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm';
spinner.setAttribute('role', 'status');
spinner.setAttribute('aria-hidden', 'true');
e.target.parentElement.appendChild(spinner);
li.appendChild(spinner);
}
}
}
@@ -139,45 +139,48 @@ function setupList(listId, username) {
note: ''
};
const li = renderItem(item, false, true); // ← tryb 15s
const isOwnFreshShareItem = Boolean(
window.IS_SHARE &&
data.added_by &&
window.CURRENT_LIST_USERNAME &&
String(data.added_by) === String(window.CURRENT_LIST_USERNAME)
);
const li = renderItem(
item,
window.IS_SHARE,
isOwnFreshShareItem ? { temporaryShareUndo: true, countdownSeconds: 15 } : false
);
document.getElementById('items').appendChild(li);
toggleEmptyPlaceholder();
updateProgressBar();
if (window.IS_SHARE) {
const countdownId = `countdown-${data.id}`;
const countdownBtn = document.createElement('button');
countdownBtn.type = 'button';
countdownBtn.className = 'btn btn-outline-warning';
countdownBtn.id = countdownId;
countdownBtn.disabled = true;
countdownBtn.textContent = '15s';
const btnGroup = li.querySelector('.btn-group');
if (btnGroup) {
btnGroup.prepend(countdownBtn);
}
if (isOwnFreshShareItem) {
let seconds = 15;
const intervalId = setInterval(() => {
const el = document.getElementById(countdownId);
if (el) {
seconds--;
el.textContent = `${seconds}s`;
if (seconds <= 0) {
el.remove();
clearInterval(intervalId);
}
} else {
const currentItem = document.getElementById(`item-${data.id}`);
const countdownEl = currentItem?.querySelector(`[data-countdown-for="${data.id}"]`);
if (!currentItem || !countdownEl) {
clearInterval(intervalId);
return;
}
seconds -= 1;
if (seconds <= 0) {
clearInterval(intervalId);
return;
}
countdownEl.textContent = `${seconds}s`;
}, 1000);
setTimeout(() => {
clearInterval(intervalId);
const existing = document.getElementById(`item-${data.id}`);
if (existing) {
const updated = renderItem(item, true);
existing.replaceWith(updated);
existing.replaceWith(renderItem(item, window.IS_SHARE));
}
}, 15000);
}
@@ -203,7 +206,7 @@ function setupList(listId, username) {
const progressTitle = document.getElementById('progress-title');
if (progressTitle) {
progressTitle.textContent = `📊 Postęp listy — ${data.purchased_count}/${data.total_count} kupionych (${Math.round(data.percent)}%)`;
progressTitle.textContent = `Postęp listy — ${data.purchased_count}/${data.total_count} kupionych (${Math.round(data.percent)}%)`;
}
});
@@ -218,7 +221,7 @@ function setupList(listId, username) {
window.currentItems[idx].name = data.new_name;
window.currentItems[idx].quantity = data.new_quantity;
const newItem = renderItem(window.currentItems[idx], true);
const newItem = renderItem(window.currentItems[idx], window.IS_SHARE);
const oldItem = document.getElementById(`item-${data.item_id}`);
if (oldItem && newItem) {
oldItem.replaceWith(newItem);
@@ -234,6 +237,7 @@ function setupList(listId, username) {
// --- WAŻNE: zapisz dane do reconnect ---
window.LIST_ID = listId;
window.usernameForReconnect = username;
window.CURRENT_LIST_USERNAME = username;
}
File renamed without changes.
File renamed without changes.
@@ -3,9 +3,9 @@ window.currentItemId = window.currentItemId ?? null;
window.openNoteModal = function (event, itemId) {
event.stopPropagation();
window.currentItemId = itemId;
const noteEl = document.querySelector(`#item-${itemId} small.text-danger`);
const noteEl = document.querySelector(`#info-${itemId} .text-danger b`);
document.getElementById('noteText').value = noteEl
? noteEl.innerText.replace(/\[|\]|Powód:/g, "").trim()
? noteEl.innerText.trim()
: "";
const modal = new bootstrap.Modal(document.getElementById('noteModal'));
modal.show();
@@ -0,0 +1,140 @@
document.addEventListener("DOMContentLoaded", function () {
const modalElement = document.getElementById("productPreviewModal");
if (!modalElement || typeof bootstrap === "undefined") return;
const modal = new bootstrap.Modal(modalElement);
const modalTitle = document.getElementById("previewModalLabel");
const productList = document.getElementById("product-list");
if (!modalTitle || !productList) return;
const renderState = (message, extraClass = "text-white") => {
productList.innerHTML = "";
const wrapper = document.createElement("div");
wrapper.className = "preview-modal-items";
const item = document.createElement("div");
item.className = `preview-modal-list-item ${extraClass}`.trim();
item.textContent = message;
wrapper.appendChild(item);
productList.appendChild(wrapper);
};
const createSection = (titleText) => {
const section = document.createElement("section");
section.className = "preview-product-section";
const title = document.createElement("h6");
title.className = "preview-product-section-title";
title.textContent = titleText;
const items = document.createElement("div");
items.className = "preview-modal-items";
section.appendChild(title);
section.appendChild(items);
return { section, items };
};
const createItem = (itemData) => {
const row = document.createElement("div");
row.className = "preview-modal-list-item";
const name = document.createElement("span");
name.className = "preview-modal-list-item__name";
name.textContent = itemData.name;
const badge = document.createElement("span");
badge.className = "badge";
if (itemData.purchased) {
badge.classList.add("bg-success");
} else if (itemData.not_purchased) {
badge.classList.add("bg-warning", "text-dark");
} else {
badge.classList.add("bg-secondary");
}
badge.textContent = `x${itemData.quantity}`;
row.appendChild(name);
row.appendChild(badge);
return row;
};
modalElement.addEventListener("hidden.bs.modal", function () {
document.querySelectorAll(".modal-backdrop").forEach((el) => el.remove());
document.body.classList.remove("modal-open");
document.body.style.overflow = "";
});
document.querySelectorAll(".preview-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
const listId = btn.dataset.listId;
modalTitle.textContent = "Ładowanie...";
renderState("⏳ Ładowanie produktów...");
modal.show();
try {
const res = await fetch(`/admin/list_items/${listId}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data = await res.json();
const totalCount = Number(data.total_count || 0);
const purchasedCount = Number(data.purchased_count || 0);
const totalExpense = Number(data.total_expense || 0);
const percent = totalCount > 0 ? Math.round((purchasedCount / totalCount) * 100) : 0;
modalTitle.textContent = `🛒 ${data.title}`;
productList.innerHTML = "";
const summary = document.createElement("div");
summary.className = "preview-product-summary";
summary.innerHTML = `
<p class="mb-1">📦 <strong>${totalCount}</strong> produktów</p>
<p class="mb-1">✅ Kupione: <strong>${purchasedCount}</strong> (${percent}%)</p>
<p class="mb-0">💸 Wydatek: <strong>${totalExpense.toFixed(2)} zł</strong></p>`;
productList.appendChild(summary);
const purchased = createSection("✔️ Kupione");
const pending = createSection("🚫 Niekupione / Nieoznaczone");
let hasPurchased = false;
let hasPending = false;
(data.items || []).forEach((item) => {
const row = createItem(item);
if (item.purchased) {
purchased.items.appendChild(row);
hasPurchased = true;
} else {
pending.items.appendChild(row);
hasPending = true;
}
});
if (hasPurchased) {
productList.appendChild(purchased.section);
}
if (hasPending) {
productList.appendChild(pending.section);
}
if (!hasPurchased && !hasPending) {
renderState("Brak produktów", "text-muted fst-italic");
}
} catch (error) {
modalTitle.textContent = "Błąd";
renderState("❌ Błąd podczas ładowania", "text-danger");
}
});
});
});
File renamed without changes.
File renamed without changes.
@@ -1,27 +1,45 @@
(function () {
const configs = (window.CROP_CONFIGS && Array.isArray(window.CROP_CONFIGS))
? window.CROP_CONFIGS
: (window.CROP_CONFIG ? [window.CROP_CONFIG] : []);
if (!configs.length) return;
document.addEventListener("DOMContentLoaded", function () {
const cropModal = document.getElementById("adminCropModal");
const cropImage = document.getElementById("adminCropImage");
const spinner = document.getElementById("adminCropLoading");
const saveButton = document.getElementById("adminSaveCrop");
configs.forEach((cfg) => initCropperSet(cfg));
});
function initCropperSet(cfg) {
const {
modalId,
imageId,
spinnerId,
saveBtnId,
endpoint
} = cfg || {};
const cropModal = document.getElementById(modalId);
const cropImage = document.getElementById(imageId);
const spinner = document.getElementById(spinnerId);
const saveButton = document.getElementById(saveBtnId);
if (!cropModal || !cropImage || !spinner || !saveButton) return;
let cropper;
let currentReceiptId;
const currentEndpoint = "/admin/crop_receipt";
const currentEndpoint = endpoint;
cropModal.addEventListener("shown.bs.modal", function (event) {
const button = event.relatedTarget;
const baseSrc = button.getAttribute("data-img-src") || "";
const ver = button.getAttribute("data-version") || Date.now();
const baseSrc = button?.getAttribute("data-img-src") || "";
const ver = button?.getAttribute("data-version") || Date.now();
const sep = baseSrc.includes("?") ? "&" : "?";
cropImage.src = baseSrc + sep + "cb=" + ver;
currentReceiptId = button.getAttribute("data-receipt-id");
currentReceiptId = button?.getAttribute("data-receipt-id");
document.querySelectorAll('.cropper-container').forEach(e => e.remove());
if (cropper) cropper.destroy();
if (cropper && cropper.destroy) cropper.destroy();
cropImage.onload = () => { cropper = cropUtils.initCropper(cropImage); };
});
@@ -35,5 +53,5 @@
spinner.classList.remove("d-none");
cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner);
});
});
}
})();
File renamed without changes.
+54
View File
@@ -0,0 +1,54 @@
document.addEventListener("DOMContentLoaded", function () {
const receiptSection = document.getElementById("receiptSection");
const toggleEl = document.getElementById("toggleReceiptBtn");
if (!receiptSection || !toggleEl || typeof bootstrap === "undefined") return;
if (receiptSection.dataset.receiptInit === "1") return;
receiptSection.dataset.receiptInit = "1";
const storageKey = receiptSection.dataset.receiptStorageKey || "receiptSectionOpen";
const collapse = bootstrap.Collapse.getOrCreateInstance(receiptSection, { toggle: false });
const titleEl = toggleEl.querySelector(".receipt-disclosure__title");
function isShown() {
return receiptSection.classList.contains("show");
}
function persist(state) {
localStorage.setItem(storageKey, state ? "true" : "false");
}
function updateUI() {
const shown = isShown();
toggleEl.classList.toggle("is-open", shown);
toggleEl.setAttribute("aria-expanded", shown ? "true" : "false");
if (titleEl) {
titleEl.textContent = shown ? "Ukryj sekcję paragonów" : "Pokaż sekcję paragonów";
}
}
toggleEl.addEventListener("click", function () {
collapse.toggle();
});
receiptSection.addEventListener("shown.bs.collapse", function () {
persist(true);
updateUI();
});
receiptSection.addEventListener("hidden.bs.collapse", function () {
persist(false);
updateUI();
});
if (localStorage.getItem(storageKey) === "true") {
receiptSection.classList.add("receipt-section--restoring");
collapse.show();
requestAnimationFrame(function () {
receiptSection.classList.remove("receipt-section--restoring");
});
}
updateUI();
});
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -126,6 +126,9 @@ socket.on('full_list', function (data) {
window.currentItems = data.items;
updateListSmoothly(data.items);
if (typeof window.syncSortModeUI === 'function') {
window.syncSortModeUI();
}
toggleEmptyPlaceholder();
if (didReceiveFirstFullList && isDifferent) {
@@ -1,32 +1,62 @@
let sortable = null;
let isSorting = false;
window.isSorting = false;
function syncSortModeUI() {
const active = !!window.isSorting;
const btn = document.getElementById('sort-toggle-btn');
const itemsContainer = document.getElementById('items');
document.body.classList.toggle('sorting-active', active);
if (btn) {
if (active) {
btn.textContent = '✔️ Zakończ sortowanie';
btn.classList.remove('btn-outline-warning');
btn.classList.add('btn-outline-success');
} else {
btn.textContent = '✳️ Zmień kolejność';
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-outline-warning');
}
}
if (itemsContainer && window.currentItems) {
updateListSmoothly(window.currentItems);
}
document.querySelectorAll('.drag-handle').forEach(handle => {
handle.hidden = !active;
handle.setAttribute('aria-hidden', active ? 'false' : 'true');
});
}
function enableSortMode() {
if (isSorting) return;
isSorting = true;
window.isSorting = true;
localStorage.setItem('sortModeEnabled', 'true');
if (window.isSorting) return;
const itemsContainer = document.getElementById('items');
const listId = window.LIST_ID;
if (!itemsContainer || !listId) return;
if (window.currentItems) {
updateListSmoothly(window.currentItems);
}
window.isSorting = true;
syncSortModeUI();
setTimeout(() => {
if (sortable) sortable.destroy();
if (!window.isSorting) return;
if (sortable) {
sortable.destroy();
sortable = null;
}
sortable = Sortable.create(itemsContainer, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'drag-ghost',
filter: 'input, button',
filter: 'input, button:not(.drag-handle)',
preventOnFilter: false,
onEnd: () => {
const order = Array.from(itemsContainer.children)
.map(li => parseInt(li.id.replace('item-', '')))
.map(li => parseInt(li.id.replace('item-', ''), 10))
.filter(id => !isNaN(id));
fetch('/reorder_items', {
@@ -37,16 +67,14 @@ function enableSortMode() {
showToast('Zapisano nową kolejność', 'success');
if (window.currentItems) {
window.currentItems = order.map(id =>
window.currentItems.find(item => item.id === id)
);
window.currentItems = order
.map(id => window.currentItems.find(item => item.id === id))
.filter(Boolean);
updateListSmoothly(window.currentItems);
}
});
}
});
updateSortButtonUI(true);
}, 50);
}
@@ -56,39 +84,22 @@ function disableSortMode() {
sortable = null;
}
isSorting = false;
localStorage.removeItem('sortModeEnabled');
window.isSorting = false;
if (window.currentItems) {
updateListSmoothly(window.currentItems);
}
updateSortButtonUI(false);
syncSortModeUI();
}
function toggleSortMode() {
isSorting ? disableSortMode() : enableSortMode();
}
function updateSortButtonUI(active) {
const btn = document.getElementById('sort-toggle-btn');
if (!btn) return;
if (active) {
btn.textContent = '✔️ Zakończ sortowanie';
btn.classList.remove('btn-outline-warning');
btn.classList.add('btn-outline-success');
if (window.isSorting) {
disableSortMode();
} else {
btn.textContent = '✳️ Zmień kolejność';
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-outline-warning');
}
}
document.addEventListener('DOMContentLoaded', () => {
const wasSorting = localStorage.getItem('sortModeEnabled') === 'true';
if (wasSorting) {
enableSortMode();
}
}
window.toggleSortMode = toggleSortMode;
window.syncSortModeUI = syncSortModeUI;
document.addEventListener('DOMContentLoaded', () => {
window.isSorting = false;
syncSortModeUI();
});
File renamed without changes.
File renamed without changes.
+30
View File
@@ -0,0 +1,30 @@
document.addEventListener("DOMContentLoaded", function () {
const toggleBtn = document.getElementById("tempToggle");
const hiddenInput = document.getElementById("temporaryHidden");
if (!toggleBtn || !hiddenInput) return;
if (typeof bootstrap !== "undefined") {
new bootstrap.Tooltip(toggleBtn);
}
function updateToggle(isActive) {
toggleBtn.classList.toggle("is-active", isActive);
toggleBtn.textContent = isActive ? "Tymczasowa ✔" : "Tymczasowa";
toggleBtn.setAttribute("aria-pressed", isActive ? "true" : "false");
toggleBtn.setAttribute("title", isActive
? "Lista tymczasowa będzie ważna przez 7 dni"
: "Po zaznaczeniu lista będzie ważna tylko 7 dni");
}
let active = toggleBtn.getAttribute("data-active") === "1";
hiddenInput.value = active ? "1" : "0";
updateToggle(active);
toggleBtn.addEventListener("click", function (event) {
event.preventDefault();
active = !active;
toggleBtn.setAttribute("data-active", active ? "1" : "0");
hiddenInput.value = active ? "1" : "0";
updateToggle(active);
});
});
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 280 B

File renamed without changes.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more