579 Commits

Author SHA1 Message Date
gru d07651ad67 Merge pull request 'split_css' (#15) from split_css into master
Reviewed-on: #15
2026-04-01 11:09:31 +02:00
Mateusz Gruszczyński 5fac84f052 typo in vc 2026-04-01 08:19:00 +02:00
Mateusz Gruszczyński 30f33e29cf changes in vc 2026-04-01 08:02:42 +02:00
Mateusz Gruszczyński f197efe2eb big changes in css 2026-04-01 07:55:56 +02:00
Mateusz Gruszczyński 2be058f3e5 wip 1 2026-04-01 06:39:18 +02:00
gru e3f7b14f01 Merge pull request 'new_share_hub' (#14) from new_share_hub into master
Reviewed-on: #14
2026-03-31 18:22:26 +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
Mateusz Gruszczyński 4d6be819e1 fix w cropie 2025-10-01 15:53:58 +02:00
Mateusz Gruszczyński d803f49713 rozszerzone uprawnienia 2025-10-01 10:56:32 +02:00
Mateusz Gruszczyński 01114b4ca9 rozszerzone uprawnienia 2025-10-01 10:51:52 +02:00
Mateusz Gruszczyński 873e81d95d poprawki ux 2025-09-30 22:10:52 +02:00
Mateusz Gruszczyński d809dcb361 poprawki ux 2025-09-30 22:07:13 +02:00
Mateusz Gruszczyński fa017ce290 nowe funkcje i fixy 2025-09-30 21:47:13 +02:00
Mateusz Gruszczyński c2cf310f89 fix 404 2025-09-30 14:26:35 +02:00
gru e1350d722c Update docker-compose.yml 2025-09-29 09:16:50 +02:00
gru af1019f01c Update docker-compose.yml 2025-09-29 09:13:00 +02:00
Mateusz Gruszczyński 3433d85471 jasne naglowki dla stron 2025-09-28 11:32:21 +02:00
Mateusz Gruszczyński a8b3a14044 poprawka zapytania 2025-09-27 22:16:55 +02:00
Mateusz Gruszczyński c944cadff3 poprawka zapytania 2025-09-27 22:08:37 +02:00
Mateusz Gruszczyński 0a5debe45a python 3.14, pgsql 17 2025-09-27 21:58:49 +02:00
Mateusz Gruszczyński dbead3d719 python 3.14, pgsql 17 2025-09-27 21:54:59 +02:00
Mateusz Gruszczyński 34065bc288 python 3.14, pgsql 17 2025-09-27 21:49:09 +02:00
Mateusz Gruszczyński 6236657d9a python 3.14, pgsql 18 2025-09-27 21:36:26 +02:00
Mateusz Gruszczyński 68a7e07c58 varnish reconfig 2025-09-25 10:28:55 +02:00
Mateusz Gruszczyński eca635a175 varnish reconfig 2025-09-25 10:18:39 +02:00
Mateusz Gruszczyński bcdbc49aa8 fix headerow 2025-09-25 10:04:26 +02:00
Mateusz Gruszczyński 419d01f74d fix headerow 2025-09-25 09:39:08 +02:00
Mateusz Gruszczyński 9b131824e8 varnish config 2025-09-25 09:22:47 +02:00
Mateusz Gruszczyński 0286ee351e varnish reconfig 2025-09-25 09:17:51 +02:00
Mateusz Gruszczyński ee59c3e561 varnish reconfig 2025-09-25 09:09:17 +02:00
Mateusz Gruszczyński b9c3204db0 varnish reconfig 2025-09-25 09:06:45 +02:00
Mateusz Gruszczyński 3324564160 varnish 2025-09-24 22:33:17 +02:00
Mateusz Gruszczyński 7821f25b61 varnish 2025-09-24 22:23:49 +02:00
Mateusz Gruszczyński 8e38576dbc varnish 2025-09-24 22:18:58 +02:00
Mateusz Gruszczyński e118ac533d version_app 2025-09-23 12:46:10 +02:00
Mateusz Gruszczyński 939f55d9aa version_app 2025-09-23 12:41:10 +02:00
Mateusz Gruszczyński c34aad68f1 versipn in css 2025-09-23 10:53:30 +02:00
Mateusz Gruszczyński c2c7adf950 version footer 2025-09-23 10:37:02 +02:00
Mateusz Gruszczyński a5bf017c30 zmiany1 2025-09-19 10:36:02 +02:00
Mateusz Gruszczyński a9f21dd4b9 zmiany1 2025-09-19 10:30:22 +02:00
Mateusz Gruszczyński 4663445fb8 zmiany1 2025-09-19 10:28:07 +02:00
Mateusz Gruszczyński 2d85991db0 zmiany1 2025-09-19 10:25:12 +02:00
Mateusz Gruszczyński 69ecc26236 zmiany1 2025-09-19 10:18:41 +02:00
Mateusz Gruszczyński 44c3f8eb5b lepszy ux przyciskow 2025-09-18 22:35:56 +02:00
Mateusz Gruszczyński da882a9a24 lepszy ux przyciskow 2025-09-18 22:34:05 +02:00
Mateusz Gruszczyński 06618b1e27 lepszy ux przyciskow 2025-09-18 22:31:07 +02:00
Mateusz Gruszczyński 5fe052648d lepszy ux przyciskow 2025-09-18 22:30:05 +02:00
Mateusz Gruszczyński fe213d4acd lepszy ux przyciskow 2025-09-18 22:29:02 +02:00
Mateusz Gruszczyński 3a99d1a936 lepszy ux przyciskow 2025-09-18 22:26:26 +02:00
Mateusz Gruszczyński 0f45ae94af lepszy ux przyciskow 2025-09-18 22:23:10 +02:00
Mateusz Gruszczyński 11f89307eb lepszy ux przyciskow 2025-09-18 22:21:39 +02:00
Mateusz Gruszczyński c9d5ab22c8 lepszy ux przyciskow 2025-09-18 22:20:32 +02:00
Mateusz Gruszczyński ce74879d15 zakresy z kubelkow w backendzie 2025-09-18 22:17:45 +02:00
Mateusz Gruszczyński 0120feff33 zakresy z kubelkow w backendzie 2025-09-18 22:16:06 +02:00
Mateusz Gruszczyński 7eb29b271a zmiany wizualne 2025-09-18 22:10:34 +02:00
Mateusz Gruszczyński 2015065af4 cofniecie zmian 2025-09-18 22:05:44 +02:00
Mateusz Gruszczyński e7f6389ca3 zmiana w js setCategorySplit 2025-09-18 22:04:03 +02:00
Mateusz Gruszczyński 767730831e fix1 2025-09-18 21:41:17 +02:00
Mateusz Gruszczyński 556b1fd4b9 fix1 2025-09-18 21:36:39 +02:00
Mateusz Gruszczyński 577ac3f463 fix1 2025-09-18 21:31:54 +02:00
Mateusz Gruszczyński f2e99821f7 fix1 2025-09-18 21:09:15 +02:00
Mateusz Gruszczyński 065f67c45e zmiany w js 2025-09-18 07:55:15 +02:00
Mateusz Gruszczyński e2761584a3 podzial dzienny 2025-09-17 22:01:13 +02:00
Mateusz Gruszczyński e4a33ad6aa podzial dzienny 2025-09-17 21:59:23 +02:00
Mateusz Gruszczyński cee5e31646 podzial dzienny 2025-09-17 21:56:04 +02:00
Mateusz Gruszczyński b386364cd6 podzial dzienny 2025-09-17 21:53:28 +02:00
Mateusz Gruszczyński 92bc3e59ae podzial dzienny 2025-09-17 21:49:07 +02:00
Mateusz Gruszczyński 174161b667 podzial dzienny 2025-09-17 21:44:56 +02:00
Mateusz Gruszczyński 4ec1d4405f podzial dzienny 2025-09-17 21:43:31 +02:00
Mateusz Gruszczyński f911fc2c10 podzial dzienny 2025-09-17 21:40:19 +02:00
Mateusz Gruszczyński 866f9ca2fd podzial dzienny 2025-09-17 21:36:13 +02:00
Mateusz Gruszczyński 1326d5b4ef podzial dzienny 2025-09-17 21:30:22 +02:00
Mateusz Gruszczyński ad219cdf4b podzial dzienny 2025-09-17 21:24:52 +02:00
Mateusz Gruszczyński d87a0aacfb podzial dzienny 2025-09-17 21:18:48 +02:00
Mateusz Gruszczyński 3f9011aac1 podzial dzienny 2025-09-17 21:12:51 +02:00
Mateusz Gruszczyński 74117ccf5b walidacja formularza 2025-09-14 21:57:23 +02:00
Mateusz Gruszczyński e992717c45 poprawki 2025-09-14 21:51:47 +02:00
Mateusz Gruszczyński 070c89b582 poprawki 2025-09-14 21:44:31 +02:00
Mateusz Gruszczyński 07913bbf61 warubek dla goscia 2025-09-14 19:28:30 +02:00
Mateusz Gruszczyński 3fcd1881a5 zabezpieczenie przed otwarciem paragonow z niestniejacej listy w panelu admina 2025-09-14 19:24:23 +02:00
Mateusz Gruszczyński b43d89cf94 zabezpieczenie przed otwarciem paragonow z niestniejacej listy w panelu admina 2025-09-14 19:23:07 +02:00
gru 7da8c1ae2f Merge pull request 'permissions' (#11) from permissions into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/11
2025-09-14 19:12:55 +02:00
Mateusz Gruszczyński eb9187a965 wizualne 2025-09-14 18:59:00 +02:00
Mateusz Gruszczyński 45302341e2 wizualne 2025-09-14 18:56:08 +02:00
Mateusz Gruszczyński c93194ba3e poprawki 2025-09-14 13:43:18 +02:00
Mateusz Gruszczyński f2dafd6fe8 poprawki 2025-09-14 13:41:37 +02:00
Mateusz Gruszczyński 8e96702d8e poprawki 2025-09-14 13:30:13 +02:00
Mateusz Gruszczyński 2a67217008 poprawki 2025-09-14 13:26:28 +02:00
Mateusz Gruszczyński 9bff1a43b3 poprawki 2025-09-14 13:03:13 +02:00
Mateusz Gruszczyński 016f9896b7 poprawki 2025-09-14 12:59:15 +02:00
Mateusz Gruszczyński 74b44dd8e8 poprawki 2025-09-14 12:52:40 +02:00
Mateusz Gruszczyński b709c8252c poprawki 2025-09-14 12:48:51 +02:00
Mateusz Gruszczyński 736b34231a poprawki 2025-09-14 12:46:30 +02:00
Mateusz Gruszczyński ec200a3819 poprawki 2025-09-14 12:41:49 +02:00
Mateusz Gruszczyński 554340dd64 poprawki 2025-09-14 12:23:02 +02:00
Mateusz Gruszczyński e860202af8 commit4 naprawa formularza 2025-09-13 23:19:34 +02:00
Mateusz Gruszczyński 50af5ce44d commit4 naprawa formularza 2025-09-13 23:14:32 +02:00
Mateusz Gruszczyński 86b104f007 commit3 wizaulne 2025-09-13 23:11:12 +02:00
Mateusz Gruszczyński 7496442276 commit3 wizaulne 2025-09-13 23:09:09 +02:00
Mateusz Gruszczyński 4c0df73e74 commit3 wizaulne 2025-09-13 23:07:37 +02:00
Mateusz Gruszczyński a69bf21fbb commit2 permissions 2025-09-13 23:04:25 +02:00
Mateusz Gruszczyński 3ade00fe08 commit2 permissions 2025-09-13 22:47:02 +02:00
Mateusz Gruszczyński 14c53aa856 commit2 permissions 2025-09-13 22:45:20 +02:00
Mateusz Gruszczyński 0e4375b561 commit1 permissions 2025-09-13 22:18:07 +02:00
Mateusz Gruszczyński 7bdd9239eb commit1 permissions 2025-09-13 18:53:29 +02:00
Mateusz Gruszczyński ce430f0f22 commit1 permissions 2025-09-13 18:32:54 +02:00
Mateusz Gruszczyński bf1c2e2a29 commit1 permissions 2025-09-13 18:14:23 +02:00
gru 5674b4acbf Update config.py 2025-09-05 11:26:21 +02:00
gru dd8a818aa9 Merge pull request 'sortowanie_w_mass_add' (#10) from sortowanie_w_mass_add into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/10
2025-09-02 17:08:55 +02:00
Mateusz Gruszczyński 40e76ad5a4 fix przy nie wybraniu kategorii 2025-09-02 17:07:36 +02:00
Mateusz Gruszczyński 824e5bde0d fix przy nie wybraniu kategorii 2025-09-02 17:06:45 +02:00
Mateusz Gruszczyński e449bc26ac fix przy nie wybraniu kategorii 2025-09-02 17:04:36 +02:00
Mateusz Gruszczyński e9504775d7 zmiany w req 2025-08-28 14:52:00 +02:00
Mateusz Gruszczyński 591b600b17 zmiany w endpoincie /uploads/ 2025-08-28 14:43:42 +02:00
Mateusz Gruszczyński ffc2f1c6ab zmiany w endpoincie /uploads/ 2025-08-28 14:40:42 +02:00
Mateusz Gruszczyński 7202fb7e5e zmiany w endpoincie /uploads/ 2025-08-28 14:33:12 +02:00
Mateusz Gruszczyński 4696b75133 zmiany w endpoincie /uploads/ 2025-08-28 14:31:46 +02:00
Mateusz Gruszczyński a7c2e6dc56 zmiany w endpoincie /uploads/ 2025-08-28 14:27:49 +02:00
Mateusz Gruszczyński 7527fb7967 kosmetyczna 2025-08-22 12:01:30 +02:00
Mateusz Gruszczyński 47bfc2927e zmiana, domyslnie current month 2025-08-22 11:54:17 +02:00
Mateusz Gruszczyński e7881fe532 zmiana, domyslnie current month 2025-08-22 11:53:30 +02:00
Mateusz Gruszczyński 372bd8eb20 zmiana, domyslnie current month 2025-08-22 11:45:17 +02:00
Mateusz Gruszczyński 5415e3435e fix w zakresie ostatnih 30 dni 2025-08-22 11:43:05 +02:00
Mateusz Gruszczyński 8685e65d22 kosmetyka 2025-08-20 21:22:59 +02:00
Mateusz Gruszczyński 8662d085f3 kosmetyka 2025-08-20 21:15:20 +02:00
Mateusz Gruszczyński bfc2841c34 remove table-striped 2025-08-20 21:09:24 +02:00
Mateusz Gruszczyński 7751e56a8c info icon w alertach 2025-08-20 21:04:04 +02:00
Mateusz Gruszczyński b0dea8d7db info icon w alertach 2025-08-20 21:03:07 +02:00
Mateusz Gruszczyński 861e272fad info icon w alertach 2025-08-20 20:56:29 +02:00
Mateusz Gruszczyński af6272cabf info icon w alertach 2025-08-20 20:55:14 +02:00
Mateusz Gruszczyński 50c18ec5d4 usuniecie kropek z alertow 2025-08-20 20:51:47 +02:00
Mateusz Gruszczyński 766e73d1c8 fix w js 2025-08-20 20:48:58 +02:00
Mateusz Gruszczyński ab63d25cdc float end na przyciskach 2025-08-20 00:17:32 +02:00
Mateusz Gruszczyński c0da0c3784 float end na przyciskach 2025-08-19 23:44:16 +02:00
Mateusz Gruszczyński 4342a6b817 float end na przyciskach 2025-08-19 23:42:56 +02:00
Mateusz Gruszczyński 20d91084f6 float end na przyciskach 2025-08-19 23:37:51 +02:00
Mateusz Gruszczyński b1e0c2d3cb float end na przyciskach 2025-08-19 23:28:12 +02:00
Mateusz Gruszczyński d8c187a63c float end na przyciskach 2025-08-19 23:26:25 +02:00
Mateusz Gruszczyński ea73e6a983 float end na przyciskach 2025-08-19 23:24:06 +02:00
Mateusz Gruszczyński 5de35babf6 float end na przyciskach 2025-08-19 23:21:54 +02:00
Mateusz Gruszczyński 14017f7b49 float end na przyciskach 2025-08-19 23:18:27 +02:00
Mateusz Gruszczyński 05e89ea490 float end na przyciskach 2025-08-19 23:16:19 +02:00
Mateusz Gruszczyński d3ad2a38bf zmiana kolejnosci css 2025-08-19 23:05:22 +02:00
Mateusz Gruszczyński 2b7f306dcf spojosc i poprawki 2025-08-19 22:59:19 +02:00
Mateusz Gruszczyński 6b070968c4 ocr wizualne 2025-08-18 23:36:07 +02:00
Mateusz Gruszczyński 2682844c26 ocr wizualne 2025-08-18 23:33:22 +02:00
Mateusz Gruszczyński addc2af505 ocr wizualne 2025-08-18 23:29:00 +02:00
Mateusz Gruszczyński f08f0dd98c fix w ocr (sumowanie) 2025-08-18 23:22:39 +02:00
Mateusz Gruszczyński 06e8fc05b3 fix ocr blok 2025-08-18 23:12:17 +02:00
Mateusz Gruszczyński 76239a9dea dla tabel bg-dark 2025-08-18 22:41:58 +02:00
Mateusz Gruszczyński a92d91c1dd zmiany w funkcja oraz UX 2025-08-18 22:35:13 +02:00
Mateusz Gruszczyński fc108bceb5 zmiany ux oraz nowe funkcje 2025-08-18 14:08:33 +02:00
Mateusz Gruszczyński 8b1057d824 poprawka wizualna 2025-08-18 10:28:26 +02:00
Mateusz Gruszczyński 3cddb79e4f fix typo 2025-08-18 10:26:12 +02:00
Mateusz Gruszczyński 899bb6eb3a zmniejszenie jakosci wgrywanych zjec 2025-08-18 10:18:40 +02:00
Mateusz Gruszczyński f9ffd083af poprawki wizualne 2025-08-18 00:53:50 +02:00
Mateusz Gruszczyński 92c257abfc sortowanie userow 2025-08-18 00:51:09 +02:00
Mateusz Gruszczyński 95cc506abf poprawka w suwaku 2025-08-18 00:48:16 +02:00
Mateusz Gruszczyński 7762cba541 poprawka w suwaku 2025-08-18 00:40:25 +02:00
Mateusz Gruszczyński 5d977c644b w wydatkach domyslnie tylko z wydatkami >0 2025-08-18 00:16:26 +02:00
Mateusz Gruszczyński 04995f4ab4 poprawka w warunku 2025-08-17 23:33:28 +02:00
Mateusz Gruszczyński 35d9982542 wyszukiwanie i dodawanie sugestii oraz poprawki 2025-08-17 22:58:04 +02:00
Mateusz Gruszczyński dd65230636 wyszukiwanie i dodawanie sugestii oraz poprawki 2025-08-17 22:56:25 +02:00
Mateusz Gruszczyński 268f8d2e85 poprawki wizualne 2025-08-17 18:25:49 +02:00
Mateusz Gruszczyński b4f1e43f5f poprawki wizualne 2025-08-17 18:24:38 +02:00
Mateusz Gruszczyński 87000bf90c poprawki wizualne 2025-08-17 18:23:23 +02:00
Mateusz Gruszczyński 32f491f978 poprawki wizualne 2025-08-17 18:20:48 +02:00
Mateusz Gruszczyński ee1a163395 poprawki wizualne 2025-08-17 18:19:18 +02:00
Mateusz Gruszczyński f4e10ef209 zmiany w /all_products, laczenie item i sugested 2025-08-17 18:14:31 +02:00
Mateusz Gruszczyński ff0f2a3601 zmiany w /all_products, laczenie item i sugested 2025-08-17 18:12:31 +02:00
Mateusz Gruszczyński a4f8275049 zmiany w /all_products, laczenie item i sugested 2025-08-17 18:09:42 +02:00
Mateusz Gruszczyński 8d0106c56d fix w /all_products 2025-08-17 18:01:00 +02:00
Mateusz Gruszczyński bfcc224a0f poprawki i optymalizacje kodu 2025-08-17 17:12:51 +02:00
Mateusz Gruszczyński 6a8305b640 poprawki i optymalizacje kodu 2025-08-17 17:07:43 +02:00
Mateusz Gruszczyński 8b9483952e poprawka wizualna 2025-08-16 23:32:42 +02:00
Mateusz Gruszczyński 0878b34047 poprawka wizualna 2025-08-16 23:29:02 +02:00
Mateusz Gruszczyński 7a2685771d poprawka wizualna 2025-08-16 23:22:00 +02:00
Mateusz Gruszczyński 16065df4c4 poprawka wizualna 2025-08-16 23:17:10 +02:00
Mateusz Gruszczyński 1e73d85600 poprawka wizualna 2025-08-16 23:16:10 +02:00
Mateusz Gruszczyński 27e14fdd1d poprawka wizualna 2025-08-16 23:15:19 +02:00
Mateusz Gruszczyński 5c90e020b6 poprawka wizualna 2025-08-16 23:14:15 +02:00
Mateusz Gruszczyński 25d1967fd8 fix dla mysql 2025-08-16 23:10:56 +02:00
Mateusz Gruszczyński 2d22fd2583 update .gitignore 2025-08-16 23:04:35 +02:00
Mateusz Gruszczyński 5c941ea955 sortowalna tabela userow 2025-08-16 22:55:40 +02:00
Mateusz Gruszczyński 946e0424fe sortowalna tabela userow 2025-08-16 22:48:21 +02:00
Mateusz Gruszczyński f5e65b9404 sortowalna tabela userow 2025-08-16 22:45:12 +02:00
Mateusz Gruszczyński 466dface63 ijenolicenei przyciskow 2025-08-16 22:43:33 +02:00
Mateusz Gruszczyński d526f392b8 dodatki i funckje 2025-08-16 22:34:45 +02:00
Mateusz Gruszczyński bf57b6b4e3 poprawki 2025-08-16 13:45:44 +02:00
Mateusz Gruszczyński c3c7a750ba poprawki 2025-08-16 13:40:33 +02:00
Mateusz Gruszczyński df8e446c42 poprawki 2025-08-16 13:37:46 +02:00
Mateusz Gruszczyński d15d83eea2 poprawki 2025-08-16 13:35:10 +02:00
Mateusz Gruszczyński 0187f1d654 poprawki 2025-08-16 13:33:53 +02:00
Mateusz Gruszczyński a3bf47ecc3 poprawki 2025-08-16 13:31:51 +02:00
Mateusz Gruszczyński 2edbd6475f poprawki 2025-08-16 13:28:09 +02:00
Mateusz Gruszczyński cd8d418371 poprawki 2025-08-16 13:23:29 +02:00
Mateusz Gruszczyński c78b5315bb poprawki 2025-08-16 13:14:45 +02:00
Mateusz Gruszczyński b6502fedfc poprawki 2025-08-16 13:10:21 +02:00
Mateusz Gruszczyński e3b180fba7 sortowanie_w_mass_add 2025-08-16 12:32:09 +02:00
Mateusz Gruszczyński 529130a622 sortowanie_w_mass_add 2025-08-16 12:22:22 +02:00
Mateusz Gruszczyński 68f235d605 fix w sugestiach i js 2025-08-15 23:29:13 +02:00
Mateusz Gruszczyński ea46dd43e1 fix w sugestiach 2025-08-15 23:03:26 +02:00
Mateusz Gruszczyński 4b99b109bd fix w sugestiach 2025-08-15 22:29:40 +02:00
Mateusz Gruszczyński 028ae3c26e fix w sugestiach 2025-08-15 22:25:22 +02:00
Mateusz Gruszczyński 71b14411e5 usuniecie zbednego kodu i poprawki 2025-08-15 15:54:40 +02:00
Mateusz Gruszczyński f1744fae99 usuniecie zbednego kodu i poprawki 2025-08-15 15:53:40 +02:00
Mateusz Gruszczyński 79c6f7d0b1 usuniecie zbednego kodu i poprawki 2025-08-15 15:52:49 +02:00
Mateusz Gruszczyński 80651bc3c7 usuniecie zbednego kodu i poprawki 2025-08-15 15:51:53 +02:00
Mateusz Gruszczyński 4602fb7749 usuniecie zbednego kodu i poprawki 2025-08-15 15:50:49 +02:00
Mateusz Gruszczyński 40381774b4 usuniecie zbednego kodu i poprawki 2025-08-15 15:48:43 +02:00
Mateusz Gruszczyński cc988d5934 usuniecie zbednego kodu i poprawki 2025-08-15 15:47:32 +02:00
Mateusz Gruszczyński 883562c532 usuniecie zbednego kodu 2025-08-15 15:41:02 +02:00
Mateusz Gruszczyński 5e01a735d3 paginacja i poprawki uxowe 2025-08-15 13:25:41 +02:00
Mateusz Gruszczyński 4988ad9a5f cofnięcie zmian z przesuwaniem listy 2025-08-15 13:23:34 +02:00
Mateusz Gruszczyński d321521ef1 cofnięcie zmian z przesuwaniem listy 2025-08-15 13:22:47 +02:00
Mateusz Gruszczyński ac88869f52 zmiany w edycji listy przez usera 2025-08-15 13:13:32 +02:00
Mateusz Gruszczyński 719735b6d7 zmiany w edycji listy przez usera 2025-08-15 13:12:40 +02:00
Mateusz Gruszczyński 1f2fc60683 zmiany w edycji listy przez usera 2025-08-15 13:07:10 +02:00
Mateusz Gruszczyński 977b8630fb zmiany w edycji listy przez usera 2025-08-15 13:01:00 +02:00
Mateusz Gruszczyński 5256e9d17b zmiany w edycji listy przez usera 2025-08-15 12:56:07 +02:00
Mateusz Gruszczyński e7c0dae7a1 zmiany w edycji listy przez usera 2025-08-15 12:51:23 +02:00
Mateusz Gruszczyński e2468c299d zmiany w edycji listy przez usera 2025-08-15 12:47:16 +02:00
Mateusz Gruszczyński feb2679d91 paginacja i poprawki uxowe 2025-08-15 10:23:27 +02:00
Mateusz Gruszczyński 4955516c93 paginacja i poprawki uxowe 2025-08-15 10:14:33 +02:00
Mateusz Gruszczyński b61c262179 paginacja i poprawki uxowe 2025-08-15 10:01:05 +02:00
Mateusz Gruszczyński 4f40bb06b3 duzo zmian ux w panelu 2025-08-14 23:55:58 +02:00
Mateusz Gruszczyński 97cebbdd49 poprawka w ladowaniu bibliotek 2025-08-14 16:25:21 +02:00
Mateusz Gruszczyński 840c466b0c modal w panelu admina 2025-08-14 16:19:11 +02:00
Mateusz Gruszczyński 9722e4fb7e modal w panelu admina 2025-08-14 16:16:32 +02:00
Mateusz Gruszczyński 012b99d7eb modal w panelu admina 2025-08-14 16:13:55 +02:00
Mateusz Gruszczyński 9d777f4fc5 modal w panelu admina 2025-08-14 16:08:07 +02:00
Mateusz Gruszczyński 1befc2f87d podlgad w kategoriach 2025-08-13 22:52:51 +02:00
Mateusz Gruszczyński 960715f5d7 usuniecie zbednego js 2025-08-13 22:46:44 +02:00
Mateusz Gruszczyński f138cabd53 jedna kategoria dla listy 2025-08-13 22:46:14 +02:00
Mateusz Gruszczyński 479e601de1 jedna kategoria dla listy 2025-08-13 22:39:28 +02:00
Mateusz Gruszczyński 82c84b5ce6 jedna kategoria dla listy 2025-08-13 22:36:16 +02:00
Mateusz Gruszczyński ee40ee101c zmiana month na m 2025-08-13 15:23:40 +02:00
Mateusz Gruszczyński 5188f80948 zmiana month na m 2025-08-13 15:19:51 +02:00
Mateusz Gruszczyński fe027a3bc7 zmiana month na m 2025-08-13 15:13:56 +02:00
Mateusz Gruszczyński 87d9a8228c zmiana month na m i poprawka w kolorach paginaci 2025-08-13 15:01:07 +02:00
Mateusz Gruszczyński c9f5a37e1f zmiana month na m i poprawka w kolorach paginaci 2025-08-13 14:56:34 +02:00
Mateusz Gruszczyński 4dfd1fa45f zmiana month na m i poprawka w kolorach paginaci 2025-08-13 14:49:33 +02:00
Mateusz Gruszczyński 01fa938a27 zmiana month na m i poprawka w kolorach paginaci 2025-08-13 14:46:46 +02:00
Mateusz Gruszczyński ea5f9a3f27 zmiana month na m i poprawka w kolorach paginaci 2025-08-13 14:21:31 +02:00
Mateusz Gruszczyński 5043a54bbb zmiana month na m i poprawka w kolorach paginaci 2025-08-13 14:16:42 +02:00
Mateusz Gruszczyński 29b7ccf02f fix mass add 2025-08-13 13:43:54 +02:00
Mateusz Gruszczyński a31683f08f paginacja paragonow 2025-08-12 23:22:57 +02:00
Mateusz Gruszczyński 93a0c32736 paginacja paragonow 2025-08-12 23:21:48 +02:00
Mateusz Gruszczyński 1e04039387 paginacja paragonow 2025-08-12 23:18:08 +02:00
Mateusz Gruszczyński a224ec1c2a paginacja paragonow 2025-08-12 23:08:07 +02:00
Mateusz Gruszczyński 740c02b42b dropbna poprawka w stringu 2025-08-12 22:55:52 +02:00
Mateusz Gruszczyński 8c627affe5 dropbna poprawka w stringu 2025-08-12 22:49:28 +02:00
Mateusz Gruszczyński cf9ac666b9 dropbna poprawka w stringu 2025-08-12 22:47:37 +02:00
Mateusz Gruszczyński a2950644c1 dropbna poprawka w stringu 2025-08-12 22:43:16 +02:00
Mateusz Gruszczyński 3dfc8c6be6 dropbna poprawka w stringu 2025-08-12 22:40:06 +02:00
Mateusz Gruszczyński 82ab7483e0 dropbna poprawka w stringu 2025-08-12 22:36:31 +02:00
Mateusz Gruszczyński 507ce1e5dc dropbna poprawka w stringu 2025-08-12 22:32:37 +02:00
Mateusz Gruszczyński ae2c3e66bf dropbna poprawka w stringu 2025-08-12 22:27:38 +02:00
Mateusz Gruszczyński 462570da48 new fuctions 2025-08-11 23:50:40 +02:00
Mateusz Gruszczyński b111e5b4df new fuctions 2025-08-11 23:48:46 +02:00
Mateusz Gruszczyński 9d5630bde3 new fuctions 2025-08-11 23:44:01 +02:00
Mateusz Gruszczyński dc8bfacdf6 poprawki wizualne 2025-08-06 23:17:32 +02:00
Mateusz Gruszczyński 4939d10165 wylacz talisman jak wszystko wylaczone w konfigu 2025-08-06 22:49:19 +02:00
Mateusz Gruszczyński dd05d6476f wylacz talisman jak wszystko wylaczone w konfigu 2025-08-06 22:48:30 +02:00
Mateusz Gruszczyński 629c24c06b wylacz talisman jak wszystko wylaczone w konfigu 2025-08-06 22:44:39 +02:00
Mateusz Gruszczyński da01bda9bc fix w js 2025-08-06 22:15:59 +02:00
Mateusz Gruszczyński 8590eba918 poprawki w jogice js, progressbar warstwowy i fix w notatkach 2025-08-06 13:44:18 +02:00
Mateusz Gruszczyński 3abad9e151 poprawki w jogice js, progressbar warstwowy i fix w notatkach 2025-08-06 13:42:20 +02:00
Mateusz Gruszczyński 6bb0c97c37 move to alpine 2025-08-04 22:36:24 +02:00
Mateusz Gruszczyński a5948e3e7e move to alpine 2025-08-04 22:24:18 +02:00
Mateusz Gruszczyński 8337be6469 obsluga pdf 2025-08-04 22:13:29 +02:00
Mateusz Gruszczyński 1cd4f62004 drobne poprawki 2025-08-02 18:57:29 +02:00
Mateusz Gruszczyński 9142dc1413 robots bez autoryzacji 2025-08-02 14:30:31 +02:00
Mateusz Gruszczyński a612d4c25c robots bez autoryzacji 2025-08-02 14:26:42 +02:00
Mateusz Gruszczyński 8cae4a3245 fix wybor miesiaca 2025-08-02 14:21:59 +02:00
gru 8473c8ee9f Update alters.txt 2025-08-02 00:47:16 +02:00
gru cb49d6190f Update config.py 2025-08-02 00:46:44 +02:00
gru 6b8cb894c8 Update _tools/wait_for_db.py 2025-08-02 00:40:47 +02:00
Mateusz Gruszczyński 511e38cd3e fix skrypt 2025-08-02 00:40:07 +02:00
Mateusz Gruszczyński c2b6f38c47 vercel setuo 2025-08-01 22:51:00 +02:00
Mateusz Gruszczyński 27589c2b7c badge kategorii 2025-08-01 14:26:32 +02:00
Mateusz Gruszczyński 3f67007f2f badge kategorii 2025-08-01 14:24:25 +02:00
Mateusz Gruszczyński beed40868d ukryawanie 0 na wykresie 2025-08-01 12:23:08 +02:00
Mateusz Gruszczyński 76194e2f57 modyfikacja funckji zaznaczanie wszystkiego 2025-08-01 11:56:13 +02:00
Mateusz Gruszczyński 79ba2068ec modyfikacja funckji zaznaczanie wszystkiego 2025-08-01 11:54:42 +02:00
Mateusz Gruszczyński cfae8571de rozbudowa wykresow o kategorie i usuniecie dupliakcji kodu z apnelu admina 2025-08-01 11:31:17 +02:00
Mateusz Gruszczyński 2df64bbe2e charts, legend bottom 2025-07-31 23:22:46 +02:00
Mateusz Gruszczyński 0c1b9aebf5 charts, legend bottom 2025-07-31 23:21:49 +02:00
Mateusz Gruszczyński 1049a69cb8 charts, legend bottom 2025-07-31 23:15:42 +02:00
Mateusz Gruszczyński 085743c7fb charts, legend bottom 2025-07-31 23:14:29 +02:00
Mateusz Gruszczyński c28e6f394d charts, legend bottom 2025-07-31 23:10:38 +02:00
Mateusz Gruszczyński 9bbf32f84e fix legend in charts 2025-07-31 23:08:30 +02:00
Mateusz Gruszczyński c92f45fb7f logowanie 304 2025-07-31 23:01:09 +02:00
Mateusz Gruszczyński 933084da4f update .env.example 2025-07-31 22:56:39 +02:00
Mateusz Gruszczyński f7bad7804b logowanie off - not work 2025-07-31 22:46:57 +02:00
Mateusz Gruszczyński 71f528f974 logowanie 304 2025-07-31 22:43:31 +02:00
Mateusz Gruszczyński 77bb4594a4 env.example, ukrycie loga o nieaktualizacji kategorii 2025-07-31 22:35:29 +02:00
Mateusz Gruszczyński ef108950b2 odkrycie etag 2025-07-31 22:24:50 +02:00
Mateusz Gruszczyński 048ed158a1 odkrycie etag dla lib 2025-07-31 22:23:53 +02:00
Mateusz Gruszczyński ce7a5406a5 miany w tooltipie 2025-07-31 22:17:11 +02:00
Mateusz Gruszczyński b46cc7d295 miany w tooltipie 2025-07-31 22:17:04 +02:00
Mateusz Gruszczyński bdee9cd3aa fix w js 2025-07-31 22:08:06 +02:00
Mateusz Gruszczyński c3c865f074 fix w js 2025-07-31 22:06:19 +02:00
Mateusz Gruszczyński 1af4e4d040 legenda bez 0 w wykresach 2025-07-31 22:03:49 +02:00
Mateusz Gruszczyński 2b33701e35 legenda bez 0 w wykresach 2025-07-31 22:01:25 +02:00
Mateusz Gruszczyński 5ddbd2b1ed legenda bez 0 w wykresach 2025-07-31 21:57:39 +02:00
Mateusz Gruszczyński 1ab52556f1 zmiana kolorow wykresow 2025-07-31 21:50:25 +02:00
Mateusz Gruszczyński 969a0565fa zmiana kolorow wykresow 2025-07-31 21:47:15 +02:00
Mateusz Gruszczyński c97f419b20 wywalenie error handlerow db 2025-07-31 14:04:17 +02:00
Mateusz Gruszczyński 962f4e7011 error handler 2025-07-31 13:59:18 +02:00
Mateusz Gruszczyński c1ebeabe0a kategorie w listach 2025-07-31 13:22:29 +02:00
Mateusz Gruszczyński 1208088de5 poprawa logiki liczenia w panelu 2025-07-31 13:10:48 +02:00
gru ebc3f8f5a7 Merge pull request 'poprawa logiki liczenia w panelu' (#9) from optymalizacje_kodu into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/9
2025-07-31 13:07:10 +02:00
Mateusz Gruszczyński 84ca3aee73 poprawa logiki liczenia w panelu 2025-07-31 13:06:38 +02:00
gru 5777e25622 Merge pull request 'optymalizacje_kodu' (#8) from optymalizacje_kodu into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/8
2025-07-31 10:55:39 +02:00
Mateusz Gruszczyński 0a44753eb2 poprawki w panelu, kategorie na wykresach i inne 2025-07-31 10:37:44 +02:00
Mateusz Gruszczyński 29ccd252b8 kategoria pieczywo 2025-07-30 23:58:33 +02:00
Mateusz Gruszczyński 50de359838 masowa edycja kategorii, crop dla usera i poprawki w zapytaniach 2025-07-30 23:57:22 +02:00
Mateusz Gruszczyński f4523d0c95 kategorie list i wykresy 2025-07-30 23:24:55 +02:00
Mateusz Gruszczyński 978bcbe051 kategorie list i wykresy 2025-07-30 23:20:03 +02:00
Mateusz Gruszczyński 437f7a26e3 fixy z cookie 2025-07-30 11:15:25 +02:00
Mateusz Gruszczyński b75200b487 fixy z cookie 2025-07-30 10:36:20 +02:00
Mateusz Gruszczyński 0b277fef7b fixy z cookie 2025-07-30 10:32:43 +02:00
Mateusz Gruszczyński de0f825988 cookie session secure 2025-07-30 10:27:06 +02:00
Mateusz Gruszczyński 4be1578568 brakujacy nc zastapiony pythonem 2025-07-30 10:00:22 +02:00
Mateusz Gruszczyński 5dc6c947d1 brakujacy nc zastapiony pythonem 2025-07-30 09:58:54 +02:00
Mateusz Gruszczyński 79c8fa916b brakujacy nc 2025-07-30 09:55:45 +02:00
Mateusz Gruszczyński 247e06bad5 error jak baza 2025-07-30 09:54:05 +02:00
Mateusz Gruszczyński e25ea1e4fb fix tworzenia baz 2025-07-30 09:48:28 +02:00
Mateusz Gruszczyński b8fe02c96f sesje baza i inne hashowanie 2025-07-29 23:55:19 +02:00
Mateusz Gruszczyński 4f8c5b27d1 sesje baza i inne hashowanie 2025-07-29 23:44:04 +02:00
Mateusz Gruszczyński abca2e505d sesje baza i inne hashowanie 2025-07-29 23:42:07 +02:00
Mateusz Gruszczyński 132c04215e sesje baza i inne hashowanie 2025-07-29 23:39:49 +02:00
Mateusz Gruszczyński 54fe9fd7a7 sesje baza i inne hashowanie 2025-07-29 23:37:55 +02:00
Mateusz Gruszczyński 22c146b313 sesje baza i inne hashowanie 2025-07-29 23:35:55 +02:00
Mateusz Gruszczyński a1fee7caaf poprawnie zliczanie rekordow w bazie 2025-07-29 12:26:36 +02:00
Mateusz Gruszczyński 8f6669cb41 poprawnie zliczanie rekordow w bazie 2025-07-29 12:19:36 +02:00
Mateusz Gruszczyński 35396afecb app.py - optymalizacje 2025-07-29 11:40:28 +02:00
gru 67d4fd0024 Merge pull request 'wersja 0.0.4' (#7) from zliczanie_wydatkow_i_poprawki_w_js into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/7
2025-07-28 22:17:13 +02:00
gru e1d1ec67c3 Update docker-compose.yml 2025-07-28 22:17:06 +02:00
gru a81737b2ce Update .env.example 2025-07-28 22:16:54 +02:00
Mateusz Gruszczyński 40a3d60da0 wylaczenie crop dla usera 2025-07-28 22:14:37 +02:00
Mateusz Gruszczyński 9a844fc539 zapytanie sql 2025-07-28 14:48:02 +02:00
Mateusz Gruszczyński 396a56e773 zapytanie sql 2025-07-28 14:44:03 +02:00
Mateusz Gruszczyński c6b089472a zapytanie sql 2025-07-28 14:42:32 +02:00
Mateusz Gruszczyński 1de3171183 zapytanie sql 2025-07-28 14:37:10 +02:00
Mateusz Gruszczyński 18e2d376c2 spojne info na liscie 2025-07-28 14:28:58 +02:00
Mateusz Gruszczyński 159b52099e crop zmiany 2025-07-28 13:50:58 +02:00
Mateusz Gruszczyński 643757e45e crop dla userów i przeniesienie listy na inny miesiac 2025-07-28 13:20:23 +02:00
Mateusz Gruszczyński 9e3068a722 fix paragony 2025-07-28 00:08:38 +02:00
Mateusz Gruszczyński b9b91ff82b duzo poprawek ux i logicznych 2025-07-28 00:04:12 +02:00
Mateusz Gruszczyński a5025b94ff poprawki w ux, poprawki w rotowaniu i jakosci zdjęć 2025-07-27 23:03:09 +02:00
Mateusz Gruszczyński 5c6e2f6540 porzucobe paragony 2025-07-27 20:26:13 +02:00
Mateusz Gruszczyński f913aeac60 sortable w tabelach 2025-07-27 20:15:55 +02:00
Mateusz Gruszczyński 359b5fb61b sortable w tabelach 2025-07-27 20:07:26 +02:00
Mateusz Gruszczyński 5519f7eef5 fix filtrowania 2025-07-27 20:00:42 +02:00
Mateusz Gruszczyński 4b76df795b fix w wykresach 2025-07-27 11:12:01 +02:00
Mateusz Gruszczyński 81985f7f84 fix dla xiastek not secure 2025-07-26 23:47:54 +02:00
Mateusz Gruszczyński 50d67d5b1a fix dla xiastek not secure 2025-07-26 23:45:38 +02:00
Mateusz Gruszczyński e5e498a5a9 fix dla xiastek not secure 2025-07-26 23:41:26 +02:00
Mateusz Gruszczyński 4cea094465 fix dla xiastek not secure 2025-07-26 23:35:42 +02:00
Mateusz Gruszczyński b7b6453b42 fix dla xiastek not secure 2025-07-26 23:29:05 +02:00
Mateusz Gruszczyński 7e69610981 fix dla xiastek not secure 2025-07-26 23:22:33 +02:00
Mateusz Gruszczyński bc6f64e546 logi 2025-07-26 22:50:50 +02:00
Mateusz Gruszczyński e5ef1309e7 logi 2025-07-26 22:48:28 +02:00
Mateusz Gruszczyński 6b2469778f logi 2025-07-26 22:45:04 +02:00
Mateusz Gruszczyński 07d06ded60 logi 2025-07-26 22:40:28 +02:00
Mateusz Gruszczyński a2c333014e ustawinia do env 2025-07-26 22:22:34 +02:00
Mateusz Gruszczyński 04c187d3d3 ustawinia do env 2025-07-26 22:19:07 +02:00
Mateusz Gruszczyński 8db5cd82ac fix js, html 2025-07-26 12:30:29 +02:00
Mateusz Gruszczyński f2811148f1 comment logging 2025-07-25 21:32:40 +02:00
Mateusz Gruszczyński c8a5db6715 talisman skip_if=csp_exempt 2025-07-25 21:25:44 +02:00
Mateusz Gruszczyński e806976453 talisman skip_if=csp_exempt 2025-07-25 21:19:22 +02:00
Mateusz Gruszczyński d8d786aed8 talisman skip_if=csp_exempt 2025-07-25 21:17:05 +02:00
Mateusz Gruszczyński b17a12b9fd debug mode 2025-07-25 21:14:21 +02:00
Mateusz Gruszczyński 1a98b7165d debug mode 2025-07-25 21:07:56 +02:00
Mateusz Gruszczyński 0357a63dcf permission policy 2025-07-25 20:24:38 +02:00
Mateusz Gruszczyński ddbd224e06 fix ukrytego bloku ocr 2025-07-25 20:11:21 +02:00
Mateusz Gruszczyński a417889810 poprawki w naglowkach w trybie lokalnym, poprawka progressbaru 2025-07-25 19:58:05 +02:00
Mateusz Gruszczyński d42d973ffd poprawki w naglowkach w trybie lokalnym, poprawka progressbaru 2025-07-25 19:55:53 +02:00
Mateusz Gruszczyński 7dc49fe160 flask-talisman + naglowki 2025-07-25 19:06:19 +02:00
Mateusz Gruszczyński 5e782ba170 flask-talisman + naglowki 2025-07-25 19:01:52 +02:00
Mateusz Gruszczyński be986fc8f5 poprawki w compose 2025-07-25 18:33:16 +02:00
Mateusz Gruszczyński cd06fc3ca4 nowe funkcje i fixy 2025-07-25 18:29:32 +02:00
Mateusz Gruszczyński e4322f2bc6 nowe funkcje i foxy 2025-07-25 18:27:58 +02:00
Mateusz Gruszczyński bb667a2cbd poprawki w user_expenses 2025-07-25 10:53:50 +02:00
Mateusz Gruszczyński 0d5b170cac zmiany w sablonach i poprawki w ocr 2025-07-25 10:42:07 +02:00
Mateusz Gruszczyński 34205f0e65 commit #1 2025-07-24 23:30:51 +02:00
root 452f2271cd poprawki w compose i .env.example 2025-07-24 16:45:42 +02:00
gru 7812209818 Merge pull request 'drobne i readme' (#6) from tornado_web into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/6
2025-07-24 15:59:16 +02:00
Mateusz Gruszczyński 04bc3773e1 drobne i readme 2025-07-24 15:57:27 +02:00
gru 1d583ad801 Merge pull request 'drobne i readme' (#5) from tornado_web into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/5
2025-07-24 15:52:08 +02:00
Mateusz Gruszczyński c9ef1c488b drobne i readme 2025-07-24 15:51:30 +02:00
gru c63995d750 Delete .app.py.swp 2025-07-24 10:11:40 +02:00
gru 7f68b1647e Merge pull request 'pgsql_mysql_docker' (#4) from pgsql_mysql_docker into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/4
2025-07-24 10:03:32 +02:00
Mateusz Gruszczyński 6f7d0069cc poprawki w compose i kodzie 2025-07-24 09:56:30 +02:00
Mateusz Gruszczyński a68aa031bb poprawki w compose i kodzie 2025-07-24 09:54:18 +02:00
root 730330cba9 remove firebird 2025-07-23 23:50:06 +02:00
Mateusz Gruszczyński 5a898c5b7a usprawnienia w panelu 2025-07-23 13:50:22 +02:00
Mateusz Gruszczyński 74ae7642e5 usprawnienia w panelu 2025-07-23 13:46:57 +02:00
root 111a63d3af wsparie dla mysql/pgsql/firebird/sqlite 2025-07-23 10:57:13 +02:00
Mateusz Gruszczyński 57a3866ec8 inne bazy z opcjach 2025-07-23 09:30:27 +02:00
gru 48f1841649 Merge pull request 'ocr' (#3) from ocr into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/3
2025-07-23 08:34:44 +02:00
gru 0d9e56dfa1 Update templates/admin/receipts.html 2025-07-23 08:34:35 +02:00
Mateusz Gruszczyński d899672a2b przeliczenie wielkosci plikow 2025-07-22 22:21:10 +02:00
Mateusz Gruszczyński 03d4370c8a przeliczenie wielkosci plikow 2025-07-22 22:17:17 +02:00
Mateusz Gruszczyński f30cd0f2fe stopka 2025-07-22 22:10:53 +02:00
Mateusz Gruszczyński 4ec33569a0 stopka 2025-07-22 22:04:17 +02:00
Mateusz Gruszczyński 1ab1b36811 usprawnieni i funkcje oraz zabezpieczenia 2025-07-22 21:56:37 +02:00
Mateusz Gruszczyński dea0309cfd croper do paragonów 2025-07-22 15:15:03 +02:00
Mateusz Gruszczyński 22bc8bd01d user moze edytowac paragony 2025-07-22 14:36:06 +02:00
Mateusz Gruszczyński 78fcdce327 obracanie zdjęcia fix 2025-07-22 14:19:24 +02:00
Mateusz Gruszczyński 258d111133 start kontenera z systemem 2025-07-22 12:35:34 +02:00
Mateusz Gruszczyński cc1dad0d7d ocr usprawnienia 2025-07-22 11:29:20 +02:00
Mateusz Gruszczyński db6f70349e ocr usprawnienia 2025-07-22 11:28:11 +02:00
Mateusz Gruszczyński a44a61c718 ocr usprawnienia 2025-07-22 11:23:00 +02:00
Mateusz Gruszczyński aa865baf3b restore analiza 2025-07-21 15:54:28 +02:00
Mateusz Gruszczyński a84b130822 uprawnienia ocr i uploadu 2025-07-21 15:50:46 +02:00
Mateusz Gruszczyński 983114575d uprawnienia ocr i uploadu 2025-07-21 15:50:35 +02:00
Mateusz Gruszczyński 955196dd92 uprawnienia ocr i uploadu 2025-07-21 14:12:50 +02:00
Mateusz Gruszczyński 8ae9068ffa OCR 2025-07-21 12:08:01 +02:00
gru a3d47eb368 Update templates/admin/edit_list.html 2025-07-20 23:05:38 +02:00
gru b0095c3b97 Update templates/admin/receipts.html 2025-07-20 23:05:10 +02:00
Mateusz Gruszczyński 98f22e0bd1 nowe opcje w paragonacch 2025-07-20 22:08:55 +02:00
Mateusz Gruszczyński 62939a9e9a nowe opcje w paragonacch 2025-07-20 22:08:25 +02:00
Mateusz Gruszczyński ae89f55446 webp support 2025-07-20 17:34:53 +02:00
Mateusz Gruszczyński 3ebb364322 webp support 2025-07-20 17:34:21 +02:00
Mateusz Gruszczyński 470cd32745 webp support 2025-07-20 16:50:26 +02:00
Mateusz Gruszczyński 1f609b6dba dropbne poprawki w js 2025-07-20 10:36:58 +02:00
Mateusz Gruszczyński f71697b6db python libheif 2025-07-19 23:04:11 +02:00
Mateusz Gruszczyński 6dc712f76e python libheif 2025-07-19 22:59:17 +02:00
Mateusz Gruszczyński 69b1e9495f python libheif 2025-07-19 22:56:38 +02:00
Mateusz Gruszczyński 114bf5c047 upload z zjec z galerii + prettycode 2025-07-19 22:53:49 +02:00
Mateusz Gruszczyński d8233cb6e5 upload z zjec z galerii + prettycode 2025-07-19 22:19:51 +02:00
Mateusz Gruszczyński 7a9042ffb2 upload z zjec z galerii + prettycode 2025-07-19 22:18:23 +02:00
Mateusz Gruszczyński 1df8e44e4d upload z zjec z galerii + prettycode 2025-07-19 22:16:21 +02:00
Mateusz Gruszczyński c09edd04b0 upload z zjec z galerii + prettycode 2025-07-19 22:07:58 +02:00
Mateusz Gruszczyński 115d15a055 uxowe zmiany 2025-07-18 22:32:00 +02:00
gru 65a09b2305 Merge pull request 'funkcja_niekupione' (#2) from funkcja_niekupione into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/2
2025-07-18 22:07:28 +02:00
gru d48654f5b6 Merge branch 'master' into funkcja_niekupione 2025-07-18 22:07:06 +02:00
Mateusz Gruszczyński 1c88e5c00b usuniecie funckji masowego usuwania produktow z bazy 2025-07-18 12:30:18 +02:00
Mateusz Gruszczyński 69f1b4d1c8 dropbny fix 2025-07-18 12:12:43 +02:00
Mateusz Gruszczyński 8c9f0f1a6a nowa funckcja zmiana kolejnosci produktów 2025-07-18 12:09:21 +02:00
Mateusz Gruszczyński 804b80bbf5 nowa funckcja i male zmiany w js 2025-07-18 10:45:51 +02:00
Mateusz Gruszczyński 45290a6147 nowe funkcje i zmiany ux 2025-07-17 13:48:46 +02:00
Mateusz Gruszczyński 377e592f90 nowe funkcje i zmiany ux 2025-07-17 13:35:21 +02:00
Mateusz Gruszczyński 133b91073d nowe funkcja statystyk i poprawki 2025-07-16 23:07:58 +02:00
Mateusz Gruszczyński 6431393baf porządkowanie kodu i poprawki js 2025-07-16 16:13:54 +02:00
Mateusz Gruszczyński d3e50305a7 poprawki w js 2025-07-16 09:04:01 +02:00
Mateusz Gruszczyński 53394469de poprawki w js 2025-07-15 23:55:50 +02:00
Mateusz Gruszczyński 9dcd144b34 funckja niekupione - poprawki w szablonie i backendzie 2025-07-15 23:27:54 +02:00
Mateusz Gruszczyński 4ef183e2a9 funckja niekupione 2025-07-15 23:05:21 +02:00
Mateusz Gruszczyński 3b94f93892 funckja niekupione 2025-07-15 22:48:25 +02:00
gru 1bc96a1979 Merge pull request 'ukrycie_zaznaczonych' (#1) from ukrycie_zaznaczonych into master
Reviewed-on: https://gitea.linuxiarz.pl/gru/lista_zakupowa_live/pulls/1
2025-07-12 23:39:35 +02:00
Mateusz Gruszczyński 2c6887095d healthcheck w docker-compose 2025-07-12 23:25:42 +02:00
Mateusz Gruszczyński 94eceb76ab healthcheck w docker-compose 2025-07-12 23:21:32 +02:00
Mateusz Gruszczyński bd0f6003f5 healthcheck w docker-compose 2025-07-12 23:18:53 +02:00
Mateusz Gruszczyński 58e0929a4c healthcheck w docker-compose 2025-07-12 23:13:13 +02:00
Mateusz Gruszczyński 95c11589e2 zmiany w panelu 2025-07-12 23:06:55 +02:00
Mateusz Gruszczyński b590ebc6b6 poprawka w progressbarze 2025-07-12 15:31:04 +02:00
Mateusz Gruszczyński d1c8970108 fixy w js 2025-07-12 15:21:16 +02:00
Mateusz Gruszczyński eaa5fde7a5 Funkcja: suwak z ukryciem zaznaczonych prodktów 2025-07-11 23:47:59 +02:00
149 changed files with 21210 additions and 3905 deletions
+191 -13
View File
@@ -1,20 +1,198 @@
# Domyślny port aplikacji
# 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
APP_PORT=8000
# Klucz bezpieczeństwa Flask
SECRET_KEY=supersekretnyklucz123
# SECRET_KEY:
# Klucz używany przez Flask do zabezpieczenia sesji, tokenów i formularzy
# Powinien być długi i trudny do odgadnięcia
# Może zawierać znaki specjalne
SECRET_KEY="supersekretnyklucz123"
# Hasło główne do systemu
SYSTEM_PASSWORD=admin
# SYSTEM_PASSWORD:
# Hasło główne administratora systemowego, używane np. przy inicjalizacji
# Domyślnie: admin
# Może zawierać znaki specjalne
SYSTEM_PASSWORD="admin"
# Domyślny admin (login i hasło)
DEFAULT_ADMIN_USERNAME=admin
DEFAULT_ADMIN_PASSWORD=admin123
# DEFAULT_ADMIN_USERNAME:
# Domyślna nazwa użytkownika administratora (tworzona przy starcie)
# Domyślnie: admin
DEFAULT_ADMIN_USERNAME="admin"
# Katalog wgrywanych plików
UPLOAD_FOLDER=uploads
# DEFAULT_ADMIN_PASSWORD:
# Domyślne hasło administratora
# Domyślnie: admin123
# Może zawierać znaki specjalne
DEFAULT_ADMIN_PASSWORD="admin123"
AUTHORIZED_COOKIE_VALUE=twoj_wlasny_hash
# UPLOAD_FOLDER:
# Ścieżka (względna) do katalogu, gdzie zapisywane są wgrywane pliki
# Domyślnie: uploads
UPLOAD_FOLDER="uploads"
# czas zycia cookie
AUTH_COOKIE_MAX_AGE=86400
# SESSION_TIMEOUT_MINUTES:
# Czas bezczynności użytkownika (w minutach), po którym sesja wygasa
# Domyślnie: 10080 (7 dni)
SESSION_TIMEOUT_MINUTES=10080
# AUTH_COOKIE_MAX_AGE:
# Czas życia ciasteczka autoryzacyjnego (w sekundach)
# Domyślnie: 86400 (1 dzień)
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 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".
# Wymusza, by przeglądarka przesyłała je tylko przez HTTPS.
# W środowisku deweloperskim (HTTP) ustaw na 0, by uniknąć błędu "secure cookie over insecure connection".
# Zalecane: 1 w produkcji (HTTPS), 0 w dev.
SESSION_COOKIE_SECURE=0
# BCRYPT_PEPPER:
# Dodatkowy „sekretny klucz” (pepper) dodawany do hasła przed zahashowaniem
# Zwiększa bezpieczeństwo przechowywanych haseł
# Może zawierać znaki specjalne
BCRYPT_PEPPER="sekretnyKluczbcrypt"
# HEALTHCHECK_TOKEN:
# Token wykorzystywany do sprawdzania stanu aplikacji (np. w Docker Compose)
# Domyślnie: alamapsaikota123
# Może zawierać znaki specjalne
HEALTHCHECK_TOKEN="alamapsaikota123"
# Rodzaj bazy: sqlite, pgsql, mysql
# Możliwe wartości: sqlite / pgsql / mysql
DB_ENGINE="sqlite"
# --- Konfiguracja dla 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
# Domyślny port PostgreSQL to 5432
# Wymaga działającego serwera PostgreSQL (np. kontener `postgres`)
# --- Konfiguracja dla mysql ---
# Ustaw DB_ENGINE=mysql
# Domyślny port MySQL to 3306
# Wymaga kontenera z MySQL i użytkownika z dostępem do bazy
# Wspólne zmienne (dla pgsql, mysql)
# DB_HOST = pgsql lub mysql zgodnie z deployem (profil w docker-compose.yml)
DB_HOST="pgsql"
DB_PORT=5432
# 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
# ========================
# ENABLE_HSTS:
# Wymusza HTTPS poprzez ustawienie nagłówka Strict-Transport-Security.
# Zalecane (1) jeśli aplikacja działa za HTTPS. Ustaw 0, jeśli korzystasz z HTTP lokalnie.
ENABLE_HSTS=1
# ENABLE_XFO:
# Ustawia nagłówek X-Frame-Options: DENY, który blokuje osadzanie strony w <iframe>.
# Chroni przed atakami typu clickjacking. Ustaw 0, jeśli celowo korzystasz z osadzania.
ENABLE_XFO=1
# ENABLE_XCTO:
# Ustawia nagłówek X-Content-Type-Options: nosniff, który zapobiega sniffowaniu MIME przez przeglądarkę.
# Chroni przed błędną interpretacją typów plików (np. skrypt JS jako obraz). Zalecane: 1.
ENABLE_XCTO=1
# ENABLE_CSP:
# Ustawia podstawową politykę Content-Security-Policy (CSP), która ogranicza wczytywanie zasobów tylko z własnej domeny.
# Zalecane: 1. Ustaw 0, jeśli używasz zewnętrznych skryptów lub masz problemy z WebSocketami (w CSP: connect-src 'self').
ENABLE_CSP=1
# REFERRER_POLICY:
# Ustawia nagłówek Referrer-Policy, który kontroluje, ile informacji o źródle (refererze)
# jest przekazywane podczas nawigacji lub zapytań sieciowych.
# Domyślnie: strict-origin-when-cross-origin — pełny URL tylko w obrębie tej samej domeny,
# a przy przejściach między domenami tylko origin (np. https://example.com).
# Zalecane ustawienie dla dobrej równowagi między prywatnością a funkcjonalnością.
# Inne możliwe wartości: no-referrer, same-origin, origin, strict-origin, unsafe-url itd.
REFERRER_POLICY="strict-origin-when-cross-origin"
# DEBUG_MODE:
# Czy uruchomić aplikację w trybie debugowania (z konsolą błędów i autoreloaderem)
# Domyślnie: 1
DEBUG_MODE=1
# DISABLE_ROBOTS:
# Czy zablokować indeksowanie przez roboty (serwuje robots.txt z Disallow: /)
# Domyślnie: 0
DISABLE_ROBOTS=0
# ========================
# Nagłówki cache
# ========================
# JS_CACHE_CONTROL:
# Nagłówki Cache-Control dla plików JS (/static/js/)
# Domyślnie: "no-cache"
JS_CACHE_CONTROL="no-cache"
# CSS_CACHE_CONTROL:
# Nagłówki Cache-Control dla plików CSS (/static/css/)
# Domyślnie: "no-cache"
CSS_CACHE_CONTROL="no-cache"
# LIB_JS_CACHE_CONTROL:
# Nagłówki Cache-Control dla bibliotek JS (/static/lib/js/)
# Domyślnie: "max-age=86400"
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=3600"
# UPLOADS_CACHE_CONTROL:
# Nagłówki Cache-Control dla wgrywanych plików (/uploads/)
# Domyślnie: "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ę
# 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"
+3
View File
@@ -0,0 +1,3 @@
*.py text working-tree-encoding=UTF-8
*.env.example text working-tree-encoding=UTF-8
.env text working-tree-encoding=UTF-8
+7 -2
View File
@@ -3,6 +3,11 @@ venv
env
*.db
__pycache__
instance/
uploads/
.DS_Store
.DS_Store
db/mysql/*
db/pgsql/*
db/shopping.db
*.swp
version.txt
deploy/varnish/default.vcl
+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.
-24
View File
@@ -1,24 +0,0 @@
# Używamy lekkiego obrazu Pythona
FROM python:3.13-slim
# Ustawiamy katalog roboczy
WORKDIR /app
# Kopiujemy wymagania
COPY requirements.txt requirements.txt
# Instalujemy zależności
RUN pip install --no-cache-dir -r requirements.txt
# Kopiujemy resztę aplikacji
COPY . .
# Kopiujemy entrypoint i ustawiamy uprawnienia
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Otwieramy port
EXPOSE 8000
# Ustawiamy entrypoint
ENTRYPOINT ["/entrypoint.sh"]
+69 -37
View File
@@ -1,59 +1,91 @@
# Live Lista Zakupów
# Aplikacja List Zakupów
Aplikacja webowa do współdzielonych list zakupów z obsługą wielu użytkowników, trybem współpracy w czasie rzeczywistym, panelami administracyjnymi oraz możliwością załączania paragonów.
## Funkcje
- Tworzenie, edycja i archiwizacja list zakupów
- Dodawanie, edycja, usuwanie produktów i oznaczanie ich jako kupione
- Udostępnianie list przez link (token)
- Wgrywanie zdjęć paragonów do listy zakupów
- Wyszukiwarka produktów i podpowiedzi
- Komentarze do produktów
- Panel administracyjny (zarządzanie użytkownikami, listami, paragonami)
- Obsługa w czasie rzeczywistym (Socket.IO)
- Logowanie i autoryzacja użytkowników
- Systemowe hasło dostępu do aplikacji
Aplikacja webowa do zarządzania listami zakupów z obsługą użytkowników, OCR paragonów, statystykami i trybem współdzielenia.
## Wymagania
- Docker
- Docker Compose
## Sposób uruchomienia z Docker Compose
## Instalacja
1. **Przygotuj plik `.env` w katalogu głównym projektu** (przykład):
1. Sklonuj repozytorium:
`APP_PORT=8000`
```bash
git pull https://git.linuxiarz.pl/gru/lista_zakupowa_live.git
cd lista_zakupowa_live
```
`SECRET_KEY=twoj_super_tajny_klucz`
1. Skonfiguruj `.env` z pliku `.env.example`
`SYSTEM_PASSWORD=haslo_do_aplikacji`
2.1 Uruchom: (pgsql)
`DEFAULT_ADMIN_USERNAME=admin`
```bash
bash deploy_docker.sh pgsql
```
`DEFAULT_ADMIN_PASSWORD=admin123`
2.2 Uruchom: (mysql)
2. **Uruchom aplikację:**
```bash
bash deploy_docker.sh mysql
```
Domyślnie aplikacja będzie dostępna pod adresem:
**http://localhost:8000**
2.3 Uruchom: (sqlite)
3. **Pierwsze logowanie:**
- Po wejściu na stronę zostaniesz poproszony o podanie hasła systemowego (`SYSTEM_PASSWORD`).
- Przy pierwszym uruchomieniu zostanie automatycznie utworzone konto administratora na podstawie zmiennych `DEFAULT_ADMIN_USERNAME` i `DEFAULT_ADMIN_PASSWORD`.
```bash
bash deploy_docker.sh sqlite
```
## Domyślne dane logowania
2.3 Restart:
```bash
bash deploy_docker.sh pgsql restart
lub
bash deploy_docker.sh sqlite restart
```
- **Login administratora:** `admin` (lub wartość z `DEFAULT_ADMIN_USERNAME`)
- **Hasło administratora:** `admin123` (lub wartość z `DEFAULT_ADMIN_PASSWORD`)
Aplikacja będzie dostępna pod `http://localhost:8000`.
4. **Aby uruchomić aplikację w Dockerze, wykonaj następujące kroki:**
## Domyślne dane logowania - konfigurowane z pliku `.env`
* Przygotuj plik .env w katalogu projektu z wymaganymi zmiennymi środowiskowymi
* Uruchom aplikację poleceniem:
docker compose up --build
- Główne hasło systemowe: `admin`
- Admin: `admin` / `admin123`
---
## Konfiguracja bazy danych
Obsługiwane silniki: `sqlite`, `pgsql`, `mysql`.
Ustaw `DB_ENGINE` oraz odpowiednie zmienne w `.env`:
Przykład dla PostgreSQL:
```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"'
```
+270
View File
@@ -0,0 +1,270 @@
import urllib.request
import json
from app import db, SuggestedProduct, app
CATEGORIES = {
"Przyprawa": [
"przyprawa",
"pieprz",
"sól",
"bazylia",
"oregano",
"papryka",
"majeranek",
"czosnek",
"tymianek",
"rozmaryn",
"kolendra",
"curry",
"imbir",
"goździki",
"chili",
"koper",
"kminek",
"liść laurowy",
"ziele angielskie",
"kurkuma",
"musztarda",
"chrzan",
],
"Mięso": [
"kurczak",
"piersi z kurczaka",
"udka z kurczaka",
"wołowina",
"mielona wołowina",
"wieprzowina",
"schab",
"łopatka",
"szynka",
"boczek",
"indyk",
"filet z indyka",
"gulasz",
"pasztet",
"karkówka",
"żeberka",
"kiełbasa",
"parówki",
"salami",
"kabanos",
],
"Ryba i owoce morza": [
"łosoś",
"dorsz",
"mintaj",
"makrela",
"pstrąg",
"karp",
"śledź",
"tuńczyk",
"morszczuk",
"sardynka",
"szproty",
"anchois",
"tilapia",
"sandacz",
"halibut",
"sum",
"flądra",
"ostrobok",
"paluszki rybne",
"konserwa rybna",
],
"Nabiał": [
"mleko",
"jogurt",
"ser żółty",
"ser biały",
"twaróg",
"śmietana",
"masło",
"kefir",
"maślanka",
"serek wiejski",
"serek topiony",
"mozzarella",
"feta",
"parmezan",
"gouda",
"emmental",
"ser pleśniowy",
"ser homogenizowany",
"serek mascarpone",
"ser ricotta",
],
"Warzywo": [
"pomidor",
"ogórek",
"marchew",
"cebula",
"sałata",
"papryka",
"ziemniak",
"kapusta",
"brokuł",
"kalafior",
"cukinia",
"bakłażan",
"szpinak",
"rukola",
"seler",
"por",
"burak",
"dynia",
"rzodkiewka",
"fasola",
],
"Owoc": [
"jabłko",
"banan",
"gruszka",
"truskawka",
"winogrono",
"malina",
"borówka",
"czereśnia",
"wiśnia",
"brzoskwinia",
"nektaryna",
"śliwka",
"ananas",
"mango",
"kiwi",
"cytryna",
"limonka",
"pomarańcza",
"mandarynka",
"grejpfrut",
],
"Pieczywo i zboża": [
"chleb",
"bułka",
"bagietka",
"kajzerka",
"pumpernikiel",
"chleb razowy",
"chleb żytni",
"tost",
"grahamka",
"croissant",
"tortilla",
"pizza",
"pierogi",
"ryż",
"makaron",
"kasza jaglana",
"kasza gryczana",
"owsianka",
"płatki kukurydziane",
"musli",
],
"Słodycze i przekąski": [
"czekolada",
"baton",
"ciastko",
"wafel",
"lody",
"cukierek",
"żelki",
"herbatnik",
"paluszki",
"chipsy",
"orzeszki",
"popcorn",
"krakersy",
"ciasto",
"muffin",
"pączek",
"drożdżówka",
"babeczka",
"piernik",
"beza",
],
"Napoje": [
"woda",
"sok jabłkowy",
"sok pomarańczowy",
"sok multiwitamina",
"cola",
"pepsi",
"napój gazowany",
"kawa",
"herbata",
"piwo",
"wino czerwone",
"wino białe",
"tonik",
"lemoniada",
"napój izotoniczny",
"kompot",
"napój mleczny",
"maślanka pitna",
"koktajl owocowy",
"nektar",
],
"Tłuszcze i oleje": [
"oliwa",
"olej rzepakowy",
"olej słonecznikowy",
"masło klarowane",
"margaryna",
"smalec",
"masło orzechowe",
"tłuszcz kokosowy",
"olej lniany",
"olej z pestek winogron",
"olej sezamowy",
"olej ryżowy",
"olej z awokado",
"olej kukurydziany",
"olej arachidowy",
"olej palmowy",
"olej konopny",
"olej sojowy",
"olej dyniowy",
"olej z orzechów włoskich",
],
"Dania gotowe": [
"pizza",
"hamburger",
"hot dog",
"zupa",
"gulasz",
"pierogi ruskie",
"pierogi z mięsem",
"lasagne",
"sałatka warzywna",
"kanapka",
"wrap",
"tortilla",
"zapiekanka",
"sushi",
"falafel",
"kebab",
"pyzy",
"kluski śląskie",
"kotlet schabowy",
"gołąbki",
],
}
produkty = []
for category, names in CATEGORIES.items():
for name in names:
produkty.append((category, name.lower().strip()))
print(f"Przygotowano {len(produkty)} produktów do dodania.")
with app.app_context():
dodane = 0
for category, name in produkty:
full_name = f"{category}: {name}"
if not SuggestedProduct.query.filter_by(name=full_name).first():
prod = SuggestedProduct(name=full_name)
db.session.add(prod)
dodane += 1
db.session.commit()
print(f"Dodano {dodane} produktów do bazy.")
+47
View File
@@ -0,0 +1,47 @@
import os
from datetime import datetime
from app import db, app, Receipt
def extract_list_id(filename):
if filename.startswith("list_"):
parts = filename.split("_", 2)
if len(parts) >= 2 and parts[1].isdigit():
return int(parts[1])
return None
def migrate_missing_receipts():
with app.app_context():
folder = app.config["UPLOAD_FOLDER"]
files = os.listdir(folder)
added = 0
skipped = 0
for file in files:
if not file.endswith(".webp"):
continue
list_id = extract_list_id(file)
if list_id is None:
print(f"Pominięto (brak list_id): {file}")
continue
exists = Receipt.query.filter_by(list_id=list_id, filename=file).first()
if exists:
skipped += 1
continue
new_receipt = Receipt(
list_id=list_id, filename=file, uploaded_at=datetime.utcnow()
)
db.session.add(new_receipt)
added += 1
print(f"📄 {file} dodany do Receipt (list_id={list_id})")
db.session.commit()
print(f"\n✅ Dodano: {added}, pominięto (już były): {skipped}")
if __name__ == "__main__":
migrate_missing_receipts()
+38
View File
@@ -0,0 +1,38 @@
python3 -m venv venv_migrate
source venv_migrate/bin/activate
pip install sqlalchemy psycopg2-binary dotenv
docker compose --profile pgsql up -d --build
PYTHONPATH=. python3 _tools/db/migrate_sqlite_to_pgsql.py
rm -rf venv_migrate
# reset wszystkich sekwencji w pgsql
docker exec -it pgsql-db psql -U lista -d lista
DO $$
DECLARE
r RECORD;
BEGIN
FOR r IN
SELECT
c.relname AS seq_name,
t.relname AS table_name,
a.attname AS column_name
FROM
pg_class c
JOIN
pg_depend d ON d.objid = c.oid
JOIN
pg_class t ON d.refobjid = t.oid
JOIN
pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
WHERE
c.relkind = 'S'
AND d.deptype = 'a'
LOOP
EXECUTE format(
'SELECT setval(%L, COALESCE((SELECT MAX(%I) FROM %I), 1), true)',
r.seq_name, r.column_name, r.table_name
);
END LOOP;
END$$;
+61
View File
@@ -0,0 +1,61 @@
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../")))
from sqlalchemy import create_engine, MetaData
from sqlalchemy.orm import sessionmaker
from config import Config
from dotenv import load_dotenv
load_dotenv()
# Źródło: SQLite
sqlite_engine = create_engine("sqlite:///instance/shopping.db")
sqlite_meta = MetaData()
sqlite_meta.reflect(bind=sqlite_engine)
# Cel: PostgreSQL
pg_engine = create_engine(Config.SQLALCHEMY_DATABASE_URI)
pg_meta = MetaData()
pg_meta.reflect(bind=pg_engine)
# Sesje
SQLiteSession = sessionmaker(bind=sqlite_engine)
PGSession = sessionmaker(bind=pg_engine)
sqlite_session = SQLiteSession()
pg_session = PGSession()
def migrate_table(table_name):
print("➡️ Używana baza docelowa:", Config.SQLALCHEMY_DATABASE_URI)
print(f"\n➡️ Migruję tabelę: {table_name}")
source_table = sqlite_meta.tables.get(table_name)
target_table = pg_meta.tables.get(table_name)
if source_table is None or target_table is None:
print(f"⚠️ Pominięto: {table_name} (brak w jednej z baz)")
return
rows = sqlite_session.execute(source_table.select()).fetchall()
if not rows:
print("️ Brak danych do migracji.")
return
insert_data = [dict(row._mapping) for row in rows]
try:
with pg_engine.begin() as conn:
conn.execute(target_table.delete())
conn.execute(target_table.insert(), insert_data)
print(f"✅ Przeniesiono: {len(rows)} rekordów")
except Exception as e:
print(f"❌ Błąd przy migracji {table_name}: {e}")
def main():
tables = ["user", "shopping_list", "item", "expense", "receipt", "suggested_product"]
for table in tables:
migrate_table(table)
print("\n🎉 Migracja zakończona pomyślnie.")
if __name__ == "__main__":
main()
+83
View File
@@ -0,0 +1,83 @@
import os
from datetime import datetime
from PIL import Image
from app import db, app, Receipt
ALLOWED_EXTS = ("jpg", "jpeg", "png", "gif", "heic")
UPLOAD_FOLDER = None
def convert_to_webp(input_path, output_path):
try:
image = Image.open(input_path).convert("RGB")
image.save(output_path, "WEBP", quality=85)
return True
except Exception as e:
print(f"Błąd konwersji {input_path}: {e}")
return False
def extract_list_id(filename):
if filename.startswith("list_"):
parts = filename.split("_", 2)
if len(parts) >= 2 and parts[1].isdigit():
return int(parts[1])
return None
def migrate():
global UPLOAD_FOLDER
with app.app_context():
UPLOAD_FOLDER = app.config["UPLOAD_FOLDER"]
files = os.listdir(UPLOAD_FOLDER)
created = 0
skipped = 0
existing = 0
for file in files:
ext = file.rsplit(".", 1)[-1].lower()
if ext not in ALLOWED_EXTS:
continue
list_id = extract_list_id(file)
if list_id is None:
print(f"Pominięto (brak list_id): {file}")
continue
src_path = os.path.join(UPLOAD_FOLDER, file)
base = os.path.splitext(file)[0]
webp_filename = base + ".webp"
dst_path = os.path.join(UPLOAD_FOLDER, webp_filename)
if os.path.exists(dst_path):
print(f"Pominięto (webp istnieje): {webp_filename}")
skipped += 1
continue
if convert_to_webp(src_path, dst_path):
os.remove(src_path)
r = Receipt.query.filter_by(
list_id=list_id, filename=webp_filename
).first()
if r:
print(f"Już istnieje w Receipt: {webp_filename}")
existing += 1
continue
new_receipt = Receipt(
list_id=list_id,
filename=webp_filename,
uploaded_at=datetime.utcnow(),
)
db.session.add(new_receipt)
created += 1
print(f"{file}{webp_filename} + zapis do Receipt")
db.session.commit()
print(f"\nNowe wpisy: {created}")
print(f"Pominięte (webp istniało): {skipped}")
print(f"Duplikaty w bazie: {existing}")
if __name__ == "__main__":
migrate()
+44
View File
@@ -0,0 +1,44 @@
import os
from datetime import datetime
from app import app, db, Receipt
def update_missing_receipt_fields():
with app.app_context():
folder = app.config["UPLOAD_FOLDER"]
updated = 0
receipts = Receipt.query.filter(
(Receipt.filesize == None)
| (Receipt.filesize == 0)
| (Receipt.uploaded_at == None)
).all()
for r in receipts:
path = os.path.join(folder, r.filename)
if not os.path.exists(path):
print(f"Brak pliku: {r.filename}")
continue
changed = False
if not r.filesize:
r.filesize = os.path.getsize(path)
changed = True
print(f"{r.filename} → filesize: {r.filesize} B")
if not r.uploaded_at:
timestamp = os.path.getmtime(path)
r.uploaded_at = datetime.fromtimestamp(timestamp)
changed = True
print(f"{r.filename} → uploaded_at: {r.uploaded_at}")
if changed:
updated += 1
db.session.commit()
print(f"\nZaktualizowano {updated} rekordów.")
if __name__ == "__main__":
update_missing_receipt_fields()
+23
View File
@@ -0,0 +1,23 @@
import os
import socket
import time
import sys
db_engine = os.environ.get("DB_ENGINE", "mysql").lower()
if db_engine == "sqlite":
print("SQLite - koncze oczekiwanie na baze..")
sys.exit(0)
host = os.environ.get("DB_HOST", "mysql")
port = int(os.environ.get("DB_PORT", 3306))
print(f"Czekam na bazę danych {host}:{port}...")
while True:
try:
with socket.create_connection((host, port), timeout=5):
print("Baza danych jest dostępna.")
break
except OSError:
print("Baza jeszcze nie odpowiada, czekam...")
time.sleep(2)
-87
View File
@@ -1,87 +0,0 @@
import urllib.request
import json
from app import db, SuggestedProduct, app
CATEGORIES = {
"Przyprawa": [
"przyprawa", "pieprz", "sól", "bazylia", "oregano", "papryka", "majeranek", "czosnek",
"tymianek", "rozmaryn", "kolendra", "curry", "imbir", "goździki", "chili", "koper",
"kminek", "liść laurowy", "ziele angielskie", "kurkuma", "musztarda", "chrzan"
],
"Mięso": [
"kurczak", "piersi z kurczaka", "udka z kurczaka", "wołowina", "mielona wołowina",
"wieprzowina", "schab", "łopatka", "szynka", "boczek", "indyk", "filet z indyka",
"gulasz", "pasztet", "karkówka", "żeberka", "kiełbasa", "parówki", "salami", "kabanos"
],
"Ryba i owoce morza": [
"łosoś", "dorsz", "mintaj", "makrela", "pstrąg", "karp", "śledź", "tuńczyk",
"morszczuk", "sardynka", "szproty", "anchois", "tilapia", "sandacz", "halibut",
"sum", "flądra", "ostrobok", "paluszki rybne", "konserwa rybna"
],
"Nabiał": [
"mleko", "jogurt", "ser żółty", "ser biały", "twaróg", "śmietana", "masło",
"kefir", "maślanka", "serek wiejski", "serek topiony", "mozzarella", "feta",
"parmezan", "gouda", "emmental", "ser pleśniowy", "ser homogenizowany",
"serek mascarpone", "ser ricotta"
],
"Warzywo": [
"pomidor", "ogórek", "marchew", "cebula", "sałata", "papryka", "ziemniak",
"kapusta", "brokuł", "kalafior", "cukinia", "bakłażan", "szpinak", "rukola",
"seler", "por", "burak", "dynia", "rzodkiewka", "fasola"
],
"Owoc": [
"jabłko", "banan", "gruszka", "truskawka", "winogrono", "malina", "borówka",
"czereśnia", "wiśnia", "brzoskwinia", "nektaryna", "śliwka", "ananas",
"mango", "kiwi", "cytryna", "limonka", "pomarańcza", "mandarynka", "grejpfrut"
],
"Pieczywo i zboża": [
"chleb", "bułka", "bagietka", "kajzerka", "pumpernikiel", "chleb razowy",
"chleb żytni", "tost", "grahamka", "croissant", "tortilla", "pizza",
"pierogi", "ryż", "makaron", "kasza jaglana", "kasza gryczana", "owsianka",
"płatki kukurydziane", "musli"
],
"Słodycze i przekąski": [
"czekolada", "baton", "ciastko", "wafel", "lody", "cukierek", "żelki",
"herbatnik", "paluszki", "chipsy", "orzeszki", "popcorn", "krakersy",
"ciasto", "muffin", "pączek", "drożdżówka", "babeczka", "piernik", "beza"
],
"Napoje": [
"woda", "sok jabłkowy", "sok pomarańczowy", "sok multiwitamina", "cola",
"pepsi", "napój gazowany", "kawa", "herbata", "piwo", "wino czerwone",
"wino białe", "tonik", "lemoniada", "napój izotoniczny", "kompot",
"napój mleczny", "maślanka pitna", "koktajl owocowy", "nektar"
],
"Tłuszcze i oleje": [
"oliwa", "olej rzepakowy", "olej słonecznikowy", "masło klarowane",
"margaryna", "smalec", "masło orzechowe", "tłuszcz kokosowy",
"olej lniany", "olej z pestek winogron", "olej sezamowy",
"olej ryżowy", "olej z awokado", "olej kukurydziany", "olej arachidowy",
"olej palmowy", "olej konopny", "olej sojowy", "olej dyniowy", "olej z orzechów włoskich"
],
"Dania gotowe": [
"pizza", "hamburger", "hot dog", "zupa", "gulasz", "pierogi ruskie",
"pierogi z mięsem", "lasagne", "sałatka warzywna", "kanapka",
"wrap", "tortilla", "zapiekanka", "sushi", "falafel", "kebab",
"pyzy", "kluski śląskie", "kotlet schabowy", "gołąbki"
]
}
produkty = []
for category, names in CATEGORIES.items():
for name in names:
produkty.append((category, name.lower().strip()))
print(f"Przygotowano {len(produkty)} produktów do dodania.")
with app.app_context():
dodane = 0
for category, name in produkty:
full_name = f"{category}: {name}"
if not SuggestedProduct.query.filter_by(name=full_name).first():
prod = SuggestedProduct(name=full_name)
db.session.add(prod)
dodane += 1
db.session.commit()
print(f"Dodano {dodane} produktów do bazy.")
-33
View File
@@ -1,33 +0,0 @@
# SUGEROWANE PRODUKTY
CREATE TABLE IF NOT EXISTS suggested_product (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL
);
# NOTATKI
ALTER TABLE item
ADD COLUMN note TEXT;
# NOWE FUNKCJE ADMINA
ALTER TABLE shopping_list ADD COLUMN is_archived BOOLEAN DEFAULT FALSE;
# FUNKCJA WYDATKOW
CREATE TABLE expense (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER,
amount FLOAT NOT NULL,
added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
receipt_filename VARCHAR(255),
FOREIGN KEY(list_id) REFERENCES shopping_list(id)
);
# FUNKCJA UKRYCIA PUBLICZNIE LISTY
ALTER TABLE shopping_list ADD COLUMN is_public BOOLEAN NOT NULL DEFAULT 1;
# ilośc produktów
ALTER TABLE item ADD COLUMN quantity INTEGER DEFAULT 1;
#licznik najczesciej kupowanych reczy
ALTER TABLE suggested_product ADD COLUMN usage_count INTEGER DEFAULT 0;
+7 -1344
View File
File diff suppressed because it is too large Load Diff
+109 -8
View File
@@ -1,12 +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:
SECRET_KEY = os.environ.get('SECRET_KEY', 'D8pceNZ8q%YR7^7F&9wAC2')
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///shopping.db')
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
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()
if DB_ENGINE == "sqlite":
SQLALCHEMY_DATABASE_URI = (
f"sqlite:///{os.path.join(basedir, 'db', 'shopping.db')}"
)
elif DB_ENGINE == "pgsql":
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":
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')
AUTH_COOKIE_MAX_AGE = int(os.environ.get('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")
AUTH_COOKIE_MAX_AGE = env_int("AUTH_COOKIE_MAX_AGE", 86400)
SESSION_TIMEOUT_MINUTES = env_int("SESSION_TIMEOUT_MINUTES", 10080)
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)
REFERRER_POLICY = env_str("REFERRER_POLICY") or None
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 env_str(
"DEFAULT_CATEGORIES",
"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,Dzieci",
).split(",")
if c.strip()
]
+34
View File
@@ -0,0 +1,34 @@
FROM python:3.14-trixie
#FROM python:3.13-slim
WORKDIR /app
# Zależności systemowe do OCR, obrazów, tesseract i języka PL
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/*
# Kopiujemy wymagania
COPY requirements.txt requirements.txt
# Instalujemy zależności
RUN pip install --no-cache-dir -r requirements.txt
# Kopiujemy resztę aplikacji
COPY . .
# Kopiujemy entrypoint i ustawiamy uprawnienia
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Otwieramy port
#EXPOSE 8000
# Ustawiamy entrypoint
ENTRYPOINT ["/entrypoint.sh"]
+269
View File
@@ -0,0 +1,269 @@
vcl 4.1;
import vsthrottle;
import std;
# ===== Backend =====
backend app {
.host = "app";
.port = "${APP_PORT}";
}
# ===== ACL =====
acl purge {
"127.0.0.1";
"::1";
}
# ===== RECV =====
sub vcl_recv {
# RATE LIMIT: 200 żądań / 10s, blokada 60s
if (vsthrottle.is_denied(client.identity, 200, 10s, 60s)) {
return (synth(429, "Too Many Requests"));
}
# PURGE tylko lokalnie
if (req.method == "PURGE") {
if (!client.ip ~ purge) { return (synth(405, "Not allowed")); }
return (purge);
}
# omijamy cache dla healthchecków / wewnętrznych nagłówków
if (req.url == "/healthcheck" || req.http.X-Internal-Check) { return (pass); }
# Specjalna obsługa WebSocket i socket.io
if (req.http.Upgrade ~ "(?i)websocket" || req.url ~ "^/socket.io/") {
return (pipe);
}
# metody inne niż GET/HEAD bez cache
if (req.method != "GET" && req.method != "HEAD") { return (pass); }
# Żądania z Authorization nie są buforowane
if (req.http.Authorization) { return (pass); }
# ---- Normalizacja Accept-Encoding (kolejność: zstd > br > gzip) ----
if (req.http.Accept-Encoding) {
if (req.http.Accept-Encoding ~ "zstd") {
set req.http.Accept-Encoding = "zstd";
} else if (req.http.Accept-Encoding ~ "br") {
set req.http.Accept-Encoding = "br";
} else if (req.http.Accept-Encoding ~ "gzip") {
set req.http.Accept-Encoding = "gzip";
} else {
set req.http.Accept-Encoding = "identity";
}
}
# ---- (Opcjonalnie) Normalizacja Accept dla obrazów generowanych wariantowo ----
# if (req.url ~ "\.(png|jpe?g|gif|bmp)$") {
# if (req.http.Accept ~ "image/webp") {
# set req.http.X-Accept-Image = "modern"; # webp
# } else {
# set req.http.X-Accept-Image = "legacy"; # jpg/png
# }
# }
# ---- STATYCZNE agresywny cache + ignorujemy sesję ----
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);
}
if (!req.http.X-Forwarded-Proto) {
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);
}
# ===== PIPE (WebSocket passthrough) =====
sub vcl_pipe {
if (req.http.Upgrade) {
set bereq.http.Upgrade = req.http.Upgrade;
set bereq.http.Connection = req.http.Connection;
}
}
# ===== HASH =====
sub vcl_hash {
hash_data(req.url);
if (req.http.host) { hash_data(req.http.host); } else { hash_data(server.ip); }
# Cookie: zostają dla dynamicznych (dla statyków wyczyszczone wcześniej)
if (req.http.Cookie) { hash_data(req.http.Cookie); }
# Accept-Encoding: już znormalizowany do zstd/br/gzip/identity
if (req.http.Accept-Encoding) { hash_data(req.http.Accept-Encoding); }
# (Opcjonalnie) sygnał obrazów z negocjacją po Accept
if (req.http.X-Accept-Image) { hash_data(req.http.X-Accept-Image); }
}
# ===== BACKEND_RESPONSE =====
sub vcl_backend_response {
# Zakaz cache respektujemy
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);
}
# NIE cache'uj redirectów do loginu (HTML) z backendu
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 ~ "^/uploads/" || bereq.url ~ "\.(css|js|png|jpe?g|webp|svg|ico|woff2?)($|\?)") {
if (beresp.status != 200) {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
return (deliver);
}
}
# Jeśli pod .js przychodzi text/html — też nie cache'uj (to zwykle redirect/login)
if (bereq.url ~ "\.js(\?.*)?$" && beresp.http.Content-Type ~ "(?i)text/html") {
set beresp.uncacheable = true;
set beresp.ttl = 0s;
return (deliver);
}
# Wymuś poprawny Content-Type dla .js/.css, gdy backend zwróci HTML
if (bereq.url ~ "\.js(\?.*)?$") {
if (!beresp.http.Content-Type || beresp.http.Content-Type ~ "(?i)text/html") {
set beresp.http.Content-Type = "application/javascript; charset=utf-8";
}
}
if (bereq.url ~ "\.css(\?.*)?$") {
if (!beresp.http.Content-Type || beresp.http.Content-Type ~ "(?i)text/html") {
set beresp.http.Content-Type = "text/css; charset=utf-8";
}
}
# ---- STATYCZNE: zdejmij Set-Cookie i Vary: Cookie, zapewnij TTL ----
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)
if (beresp.http.Vary) {
set beresp.http.Vary = regsuball(beresp.http.Vary, "(?i)(^|,)[[:space:]]*Cookie[[:space:]]*(,|$)", "\1");
set beresp.http.Vary = regsuball(beresp.http.Vary, ",[[:space:]]*,", ",");
set beresp.http.Vary = regsub(beresp.http.Vary, "^[[:space:]]*,[[:space:]]*", "");
set beresp.http.Vary = regsub(beresp.http.Vary, "[[:space:]]*,[[:space:]]*$", "");
if (beresp.http.Vary ~ "^[[:space:]]*$") { unset beresp.http.Vary; }
}
# Jeśli brak kontroli czasu życia ustawiamy twarde wartości
if (!(beresp.http.Cache-Control ~ "(?i)(s-maxage|max-age)")) {
set beresp.ttl = 24h;
set beresp.http.Cache-Control = "public, max-age=86400, immutable";
}
set beresp.grace = 1h;
set beresp.keep = 24h;
}
# ---- Ogólne TTL z nagłówków ----
if (beresp.http.Cache-Control ~ "(?i)s-maxage=([0-9]+)") {
set beresp.ttl = std.duration(regsub(beresp.http.Cache-Control, "(?i).*s-maxage=([0-9]+).*", "\1") + "s", 0s);
} else if (beresp.http.Cache-Control ~ "(?i)max-age=([0-9]+)") {
set beresp.ttl = std.duration(regsub(beresp.http.Cache-Control, "(?i).*max-age=([0-9]+).*", "\1") + "s", 0s);
} else if (beresp.http.Expires) {
set beresp.ttl = std.time(beresp.http.Expires, now) - now;
if (beresp.ttl < 0s) { set beresp.ttl = 0s; }
} else {
if (beresp.ttl <= 0s) { set beresp.ttl = 60s; }
}
# Immutable => dłuższe grace/keep
if (beresp.http.Cache-Control ~ "(?i)immutable") {
set beresp.grace = 1h;
set beresp.keep = 24h;
}
# Kompresja po stronie Varnisha wyłącznie dla klientów akceptujących gzip
# i tylko jeśli backend nie dostarczył już Content-Encoding.
if (!beresp.http.Content-Encoding && bereq.http.Accept-Encoding ~ "gzip") {
# Kompresujemy tylko „tekstowe” typy; wykluczamy WASM
if (beresp.http.Content-Type ~ "(?i)text/|application/(javascript|json|xml)") {
set beresp.do_gzip = true;
}
}
# Duże odpowiedzi streamujemy
if (beresp.http.Content-Length && std.integer(beresp.http.Content-Length, 0) > 1048576) {
set beresp.do_stream = true;
}
}
# (Opcjonalnie) Serwuj „stale” przy błędach backendu, jeśli jest obiekt w grace
sub vcl_backend_error {
return (deliver);
}
# ===== DELIVER =====
sub vcl_deliver {
if (obj.uncacheable) {
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";
} else {
set resp.http.X-Cache = "MISS";
unset resp.http.Age;
}
unset resp.http.Via;
unset resp.http.X-Varnish;
unset resp.http.Server;
unset resp.http.Content-Disposition;
}
sub vcl_synth {
set resp.http.Cache-Control = "private, no-store, no-cache";
set resp.http.X-Cache = "SYNTH";
unset resp.http.Via;
unset resp.http.X-Varnish;
unset resp.http.Server;
unset resp.http.Content-Disposition;
}
# ===== PURGE HANDLER =====
sub vcl_purge {
return (synth(200, "Purged"));
}
+100 -6
View File
@@ -1,13 +1,107 @@
#!/bin/bash
set -e
set -euo pipefail
echo "Zatrzymuję i usuwam stare kontenery..."
docker compose down --rmi all
COMPOSE_FILE="docker/compose.yml"
if [[ -f .env ]]; then
set -a
source .env
set +a
fi
APP_PORT="${APP_PORT:-8080}"
DEFAULT_ENGINE="${DB_ENGINE:-sqlite}"
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"
}
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 "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
echo "Zapisuję hash commita do version.txt..."
git rev-parse --short HEAD > version.txt
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 "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 "Buduję obrazy i uruchamiam kontenery..."
docker compose up -d --build
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!"
echo "Gotowe! Wersja aplikacji: $(cat version.txt)"
-18
View File
@@ -1,18 +0,0 @@
services:
app:
build: .
container_name: live-lista-zakupow
ports:
- "${APP_PORT:-8000}:8000"
environment:
- FLASK_APP=app.py
- FLASK_ENV=production
- SECRET_KEY=${SECRET_KEY}
- SYSTEM_PASSWORD=${SYSTEM_PASSWORD}
- DEFAULT_ADMIN_USERNAME=${DEFAULT_ADMIN_USERNAME}
- DEFAULT_ADMIN_PASSWORD=${DEFAULT_ADMIN_PASSWORD}
- UPLOAD_FOLDER=${UPLOAD_FOLDER}
- AUTHORIZED_COOKIE_VALUE=${AUTHORIZED_COOKIE_VALUE}
- AUTH_COOKIE_MAX_AGE=${AUTH_COOKIE_MAX_AGE}
volumes:
- .:/app
+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"]
+79
View File
@@ -0,0 +1,79 @@
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile.debian-stable-slim
container_name: lista-zakupow-app
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).getcode() == 200 else sys.exit(1)",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
env_file:
- ../.env
volumes:
- ../:/app
- ../uploads:/app/uploads
- ../instance:/app/instance
networks:
- lista-zakupow_network
restart: unless-stopped
varnish:
image: varnish:latest
container_name: lista-zakupow-varnish
depends_on:
app:
condition: service_healthy
ports:
- "${APP_PORT:-8000}:80"
volumes:
- ../deploy/varnish/default.vcl:/etc/varnish/default.vcl:ro
environment:
- VARNISH_SIZE=256m
networks:
- lista-zakupow_network
restart: unless-stopped
mysql:
image: mysql:8
container_name: lista-zakupow-mysql-db
environment:
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASSWORD}
MYSQL_ROOT_PASSWORD: 89o38kUX5T4C
volumes:
- ../db/mysql:/var/lib/mysql
restart: unless-stopped
networks:
- lista-zakupow_network
profiles: ["mysql"]
pgsql:
image: postgres:18
container_name: lista-zakupow-pgsql
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
PGDATA: /var/lib/postgresql
volumes:
- ../db/pgsql:/var/lib/postgresql
networks:
- lista-zakupow_network
restart: unless-stopped
profiles: ["pgsql"]
networks:
lista-zakupow_network:
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
+21
View File
@@ -0,0 +1,21 @@
Flask
Flask-SQLAlchemy
Flask-Login
Flask-SocketIO
Flask-Compress
#eventlet
gevent-websocket
Werkzeug
Pillow
psutil
pillow-heif
pytesseract
opencv-python-headless
psycopg2-binary # pgsql
pymysql # mysql
cryptography # mysql8
flask-talisman # nagłówki
bcrypt
Flask-Session
pdf2image
+14 -1
View File
@@ -1,3 +1,16 @@
#!/bin/sh
flask db upgrade 2>/dev/null || flask create_db
# Jeśli nie przekazano zmiennej środowiskowej DB_ENGINE, ustaw na sqlite
DB_ENGINE=${DB_ENGINE:-sqlite}
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
exec python app.py
-9
View File
@@ -1,9 +0,0 @@
Flask
Flask-SQLAlchemy
Flask-Login
Flask-SocketIO
Flask-Compress
eventlet
Werkzeug
Pillow
psutil
+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")
+195
View File
@@ -0,0 +1,195 @@
.btn {
min-height: 40px;
padding: 0.52rem 0.8rem;
border-radius: 10px;
font-weight: 600;
letter-spacing: 0.01em;
box-shadow: none;
transition: transform .18s ease, box-shadow .18s ease, background-color .18s ease, border-color .18s ease;
}
.btn-sm {
min-height: 34px;
padding: 0.4rem 0.64rem;
border-radius: 9px;
}
.btn:focus,
.btn:focus-visible {
border-color: rgba(25, 135, 84, 0.6) !important;
box-shadow: 0 0 0 0.2rem rgba(25, 135, 84, 0.16) !important;
}
.btn:hover,
.btn:focus-visible {
transform: translateY(-1px);
}
.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;
}
.btn-success {
background: linear-gradient(135deg, #29d17d, #1ea860) !important;
border-color: rgba(41, 209, 125, 0.9) !important;
color: #fff !important;
box-shadow: 0 10px 24px rgba(0,0,0,0.16);
}
.btn-success:hover,
.btn-success:focus,
.btn-success:active {
color: #fff !important;
}
.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;
}
.btn-outline-light,
.btn-outline-secondary,
.btn-outline-warning,
.btn-outline-danger,
.btn-outline-primary,
.btn-outline-success,
.btn-outline-info {
background: rgba(255,255,255,0.03);
}
.btn-outline-light {
color: #f8f9fa !important;
border-color: #f8f9fa !important;
}
.btn-outline-light:hover,
.btn-outline-light:focus,
.btn-outline-light:focus-visible {
background-color: rgba(255,255,255,0.1) !important;
color: #fff !important;
border-color: #6c757d !important;
box-shadow: 0 10px 24px rgba(0,0,0,0.16);
}
.btn-outline-light:active,
.btn-outline-light.active,
.show > .btn-outline-light.dropdown-toggle {
background-color: #5a6268 !important;
color: #fff !important;
border-color: #545b62 !important;
}
.btn-outline-secondary:hover,
.btn-outline-secondary:focus,
.btn-outline-secondary:focus-visible {
background: rgba(108, 117, 125, 0.18) !important;
box-shadow: 0 10px 24px rgba(0,0,0,0.16);
}
.btn-outline-success {
color: var(--success) !important;
border-color: var(--success) !important;
}
.btn-outline-success:hover,
.btn-outline-success:focus,
.btn-outline-success:active,
.btn-outline-success:focus-visible {
background: var(--ui-success-soft) !important;
border-color: var(--success-border) !important;
color: #fff !important;
box-shadow: 0 10px 24px rgba(0,0,0,0.16);
}
.btn-outline-warning {
color: #d9c97a !important;
border-color: var(--warning) !important;
}
.btn-outline-warning:hover,
.btn-outline-warning:focus,
.btn-outline-warning:active,
.btn-outline-warning:focus-visible {
background: var(--ui-warning-soft) !important;
border-color: var(--warning-border) !important;
color: var(--warning-text) !important;
}
.btn-outline-danger:hover,
.btn-outline-danger:focus,
.btn-outline-danger:focus-visible {
background: rgba(220, 53, 69, 0.16) !important;
}
.btn-outline-info {
color: var(--info) !important;
border-color: var(--info) !important;
}
.btn-outline-info:hover,
.btn-outline-info:focus,
.btn-outline-info:focus-visible {
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;
}
#items .btn-group {
gap: 0.4rem;
}
#items .btn-group .btn {
border-radius: 12px !important;
}
.btn-group-compact,
.admin-shortcuts .d-flex,
.stack-form,
.page-actions {
gap: 0.4rem;
}
.btn-group-compact .btn {
padding: 0.3rem 0.55rem;
font-size: 0.82rem;
}
.btn-group-compact .btn-text {
font-size: 0.78rem;
}
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;
}
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
.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;
}
.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;
}
.form-control::placeholder,
.bg-dark .form-control::placeholder {
color: #aaa !important;
opacity: 1 !important;
}
.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;
}
.large-checkbox,
input[type="checkbox"].large-checkbox {
width: 1.2rem;
height: 1.2rem;
}
.large-checkbox {
accent-color: #29d17d;
transform: none;
transform-origin: center;
}
input[type="checkbox"].large-checkbox {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
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;
}
#createListTempToggle,
.create-list-temp-toggle,
#tempToggle {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
File diff suppressed because it is too large Load Diff
+725
View File
@@ -0,0 +1,725 @@
.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;
}
.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;
}
}
.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 {
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;
}
}
.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;
}
}
.form-check-spaced {
gap: .35rem;
}
.form-check-spaced .form-check-input,
input[type="checkbox"].form-check-input,
.table-select-checkbox {
width: .95rem !important;
height: .95rem !important;
min-width: .95rem !important;
min-height: .95rem !important;
margin-top: .18rem;
}
.form-switch-compact .form-check-input {
width: 1.8rem !important;
height: .95rem !important;
}
.switch-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: .8rem;
}
.form-check.form-switch.app-switch {
display: inline-flex;
align-items: center;
gap: .75rem;
min-height: 2.75rem;
margin: 0;
padding: .58rem .82rem;
background: rgba(255,255,255,.04);
border: 1px solid var(--ui-border);
border-radius: 16px;
}
.form-check.form-switch.app-switch .form-check-input {
float: none;
flex: 0 0 auto;
width: 2.45em !important;
height: 1.3em !important;
margin: 0 !important;
cursor: pointer;
background-color: var(--dark-400) !important;
border-color: var(--dark-300) !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 {
background-color: var(--primary) !important;
border-color: var(--primary-border) !important;
background-position: right center !important;
}
.form-check.form-switch.app-switch .form-check-input:focus {
box-shadow: 0 0 0 .18rem rgba(24, 64, 118, .22) !important;
}
.form-check.form-switch.app-switch .form-check-label {
display: inline-flex;
align-items: center;
min-height: 1.3rem;
margin: 0 !important;
line-height: 1.35;
cursor: pointer;
}
.form-check.form-switch.app-switch.form-switch-compact {
width: 100%;
justify-content: flex-start;
}
.form-check.form-switch.app-switch.form-switch-compact .form-check-input {
width: 2.45em !important;
height: 1.3em !important;
}
.hide-purchased-switch.form-check,
.hide-purchased-switch.form-check.app-switch {
display: inline-flex;
align-items: center;
gap: .7rem;
width: auto;
max-width: 100%;
padding: .5rem .82rem;
border-radius: 14px;
background: rgba(255,255,255,.04);
border: 1px solid var(--ui-border);
}
.hide-purchased-switch .form-check-input {
flex: 0 0 auto;
float: none;
width: 2.45em !important;
height: 1.3em !important;
margin: 0 !important;
cursor: pointer;
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;
}
.hide-purchased-switch .form-check-input:checked {
background-position: right center !important;
}
.hide-purchased-switch .form-check-label {
margin: 0 !important;
white-space: nowrap;
cursor: pointer;
}
.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;
font-weight: 600;
white-space: nowrap;
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;
}
.endpoint-edit_my_list .switch-grid {
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
}
.endpoint-edit_my_list .switch-grid .app-switch,
.endpoint-admin_edit_list .switch-grid .app-switch {
width: 100%;
min-height: 3.35rem;
}
@media (max-width: 767.98px) {
.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;
}
.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;
}
}
@@ -0,0 +1,895 @@
.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-header {
padding-top: .55rem;
}
.app-navbar .container-xxl {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: .6rem;
border-radius: 26px;
padding-top: .8rem;
padding-bottom: .8rem;
}
.app-navbar__meta {
display: none;
}
.app-brand {
overflow: hidden;
}
.app-brand > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-brand__title,
.app-brand__accent {
font-size: 1rem;
}
.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;
}
}
.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;
}
@media (max-width: 767.98px){
.endpoint-list_share #items .list-group-item,
.endpoint-shared_list #items .list-group-item {
align-items: flex-start;
}
.endpoint-list_share .list-item-actions,
.endpoint-shared_list .list-item-actions {
width: 100%;
margin-left: 0;
justify-content: flex-start;
}
}
@media (max-width: 767.98px){
.share-page-toolbar {
justify-content: stretch;
}
.share-page-toolbar__spacer {
display: none;
}
.switch-grid,
.endpoint-edit_my_list .switch-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 991.98px){
.endpoint-admin_templates .table-responsive > table.table {
width: max-content !important;
min-width: 900px !important;
}
}
@media (max-width: 576px){
.mobile-list-heading {
display: inline-flex;
max-width: 100%;
}
.mobile-list-heading__title {
max-width: 100%;
}
}
@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%;
}
}
@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;
}
}
@media (max-width: 575.98px){
.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;
}
}
@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;
}
}
@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;
}
}
@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;
}
}
@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;
}
}
@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;
}
}
@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;
}
}
@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;
}
}
@media (max-width: 575.98px) {
.ui-password-group > .ui-password-toggle {
flex-basis: 44px !important;
width: 44px !important;
min-width: 44px !important;
}
}
@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;
}
}
@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;
}
}
@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;
}
}
Binary file not shown.
+80
View File
@@ -0,0 +1,80 @@
:root {
--primary: #184076;
--primary-border: #153866;
--primary-text: #e6f0ff;
--info: var(--primary);
--info-border: var(--primary-border);
--info-text: var(--primary-text);
--success: #1c6930;
--success-border: #165024;
--success-text: #eaffea;
--warning: #665c1e;
--warning-border: #4d4415;
--warning-text: #fffbe5;
--danger: #6e1a1e;
--danger-border: #531417;
--danger-text: #ffeaea;
--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;
--progress-default: #3d7bd6;
}
.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;
}
}
.bg-success {
background-color: var(--success) !important;
}
.bg-warning {
background-color: var(--warning) !important;
}
Binary file not shown.
Binary file not shown.
+207
View File
@@ -0,0 +1,207 @@
(function () {
const $ = (s, root = document) => root.querySelector(s);
const $$ = (s, root = document) => Array.from(root.querySelectorAll(s));
const toast = (m, t = 'info') => (window.showToast ? window.showToast(m, t) : console.log(`[${t}]`, m));
function appendToken(box, user) {
const tokensBox = $('.tokens', box);
if (!tokensBox || !user?.id || !user?.username) return;
const empty = $('.no-perms', box);
if (empty) empty.remove();
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-sm btn-outline-secondary rounded-pill token';
btn.dataset.userId = user.id;
btn.dataset.username = user.username;
btn.title = 'Kliknij, aby odebrać dostęp';
btn.innerHTML = `@${user.username} <span aria-hidden="true">×</span>`;
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',
'X-Requested-With': 'fetch'
};
}
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);
try {
const res = await fetch(postUrl, {
method: 'POST',
body: form,
credentials: 'same-origin',
headers: wantsJSON()
});
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const data = await res.json().catch(() => ({}));
return { ok: !!data?.ok, data, status: res.status };
}
return { ok: res.ok, data: null, status: res.status };
} catch (e) {
console.error('POST failed', e);
return { ok: false, data: null, status: 0 };
}
}
function initEditor(box) {
if (!box || !box.classList?.contains('access-editor')) return;
if (box.dataset._accessEditorInit === '1') return;
box.dataset._accessEditorInit = '1';
const postUrl = box.dataset.postUrl || location.pathname;
const nextPath = box.dataset.next || location.pathname;
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);
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';
document.body.appendChild(datalist);
}
input?.setAttribute('list', datalist.id);
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 debounce = (fn, ms = 200) => { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; };
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 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(initialOptions);
}
}, 200);
input?.addEventListener('focus', () => fetchHints(input.value));
input?.addEventListener('input', () => fetchHints(input.value));
box.addEventListener('click', async (e) => {
const btn = e.target.closest('.token');
if (!btn || !box.contains(btn)) return;
const userId = btn.dataset.userId;
const username = btn.dataset.username;
if (!userId) return toast('Brak identyfikatora użytkownika.', 'danger');
btn.disabled = true; btn.classList.add('disabled');
const res = await postAction(postUrl, nextPath, { action: 'revoke', target_list_id: listId, [revokeField]: userId });
if (res.ok) {
btn.remove();
if (!$$('.token', box).length && tokensBox) {
const empty = document.createElement('span');
empty.className = 'no-perms text-warning small';
empty.textContent = 'Brak dodanych uprawnień.';
tokensBox.appendChild(empty);
}
syncAccessCount(box);
toast(`Odebrano dostęp: @${username}`, 'success');
} else {
btn.disabled = false; btn.classList.remove('disabled');
toast(`Nie udało się odebrać dostępu @${username}`, 'danger');
}
});
async function addUsers() {
const users = parseUserText(input?.value);
if (!users?.length) return toast('Podaj co najmniej jednego użytkownika', 'warning');
addBtn.disabled = true;
const prevText = addBtn.textContent;
addBtn.textContent = 'Dodaję…';
let okCount = 0, failCount = 0, appended = 0;
for (const u of users) {
const res = await postAction(postUrl, nextPath, { action: grantAction, target_list_id: listId, grant_username: u });
if (res.ok) {
okCount++;
if (res.data?.user) {
appendToken(box, res.data.user);
appended++;
syncAccessCount(box);
}
} else {
failCount++;
}
}
addBtn.disabled = false;
addBtn.textContent = prevText;
if (input) input.value = '';
if (okCount) toast(`Dodano dostęp: ${okCount} użytkownika`, 'success');
if (failCount) toast(`Błędy przy dodawaniu: ${failCount}`, 'danger');
if (okCount && appended === 0) {
setTimeout(() => location.reload(), 400);
}
}
addBtn?.addEventListener('click', addUsers);
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addUsers(); } });
syncAccessCount(box);
}
document.addEventListener('DOMContentLoaded', () => {
$$('.access-editor').forEach(initEditor);
});
document.addEventListener('shown.bs.modal', (ev) => {
$$('.access-editor', ev.target).forEach(initEditor);
});
})();
+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');
})();
@@ -0,0 +1,11 @@
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll("select.tom-dark").forEach(function (el) {
new TomSelect(el, {
plugins: ['remove_button'],
persist: false,
create: false,
hidePlaceholder: true,
dropdownParent: 'body'
});
});
});
+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();
}
}
});
});
});
+179
View File
@@ -0,0 +1,179 @@
// chart_controls.js
// Logika UI: wybór zakresu, przełączanie dzienny/miesięczny, kategorie, show_all.
// Współpracuje z window.loadExpenses (z expense_chart.js).
document.addEventListener("DOMContentLoaded", function () {
const toggleMonthlySplit = document.getElementById("toggleMonthlySplit");
const toggleDailySplit = document.getElementById("toggleDailySplit");
const toggleCategory = document.getElementById("toggleCategorySplit");
const startDateInput = document.getElementById("startDate");
const endDateInput = document.getElementById("endDate");
const customRangeBtn = document.getElementById("customRangeBtn");
const showAllCheckbox = document.getElementById("showAllLists");
// pomocnicze
const iso = (d) => d.toISOString().split("T")[0];
const today = () => new Date();
const daysAgo = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return d; };
function setActiveTimeSplit(active) {
const on = (btn) => { btn.classList.add("btn-primary"); btn.classList.remove("btn-outline-light"); btn.setAttribute("aria-pressed", "true"); };
const off = (btn) => { btn.classList.remove("btn-primary"); btn.classList.add("btn-outline-light"); btn.setAttribute("aria-pressed", "false"); };
if (active === "monthly") { on(toggleMonthlySplit); off(toggleDailySplit); }
else { on(toggleDailySplit); off(toggleMonthlySplit); }
}
function isDailyActive() { return toggleDailySplit?.classList.contains("btn-primary"); }
// ——— KLUCZOWE: jedno miejsce, które przeładowuje wykres zgodnie z aktualnym trybem ———
function reloadRespectingSplit(preferredRange = null) {
// preferredRange używamy dla przycisków typu monthly/quarterly/halfyearly/yearly
const sd = startDateInput?.value || null;
const ed = endDateInput?.value || null;
if (isDailyActive()) {
// Dzienny ZAWSZE z datami (fallback: ostatnie 30 dni), bo inaczej backend spadnie na monthly
const _sd = sd && ed ? sd : iso(daysAgo(30));
const _ed = sd && ed ? ed : iso(today());
window.loadExpenses("daily", _sd, _ed);
return;
}
// Miesięczny
if (sd && ed) {
window.loadExpenses("monthly", sd, ed);
} else if (preferredRange) {
window.loadExpenses(preferredRange);
} else {
window.loadExpenses("monthly");
}
}
// ——— Przełączniki czasu ———
toggleMonthlySplit?.addEventListener("click", () => {
setActiveTimeSplit("monthly");
reloadRespectingSplit("monthly");
});
toggleDailySplit?.addEventListener("click", () => {
setActiveTimeSplit("daily");
reloadRespectingSplit();
});
// ——— Podział na kategorie ———
toggleCategory?.addEventListener("click", function () {
const active = this.classList.contains("btn-primary");
if (active) {
this.classList.remove("btn-primary");
this.classList.add("btn-outline-light");
this.setAttribute("aria-pressed", "false");
this.textContent = "Przełącz na kategorie";
window.setCategorySplit(false);
} else {
this.classList.add("btn-primary");
this.classList.remove("btn-outline-light");
this.setAttribute("aria-pressed", "true");
this.textContent = "Przełącz na sumy";
window.setCategorySplit(true);
}
// porzucenie zakresu
document.querySelectorAll("#chartTab .chart-range-btn").forEach(b => b.classList.remove("active"));
reloadRespectingSplit();
});
// ——— Własny zakres ———
customRangeBtn?.addEventListener("click", function () {
const sd = startDateInput?.value;
const ed = endDateInput?.value;
if (!(sd && ed)) return alert("Proszę wybrać obie daty!");
reloadRespectingSplit();
});
// ——— Predefiniowane zakresy pod wykresem ———
document.querySelectorAll("#chartTab .chart-range-btn").forEach((btn) => {
btn.addEventListener("click", function () {
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
// Zakresy kubełkowane bez start/end, bez "daily"
if (["monthly", "quarterly", "halfyearly", "yearly"].includes(r)) {
if (startDateInput) startDateInput.value = "";
if (endDateInput) endDateInput.value = "";
window.loadExpenses(r); // => /expenses_data?range=monthly|quarterly|halfyearly|yearly
return;
}
if (r === "currentmonth") {
const t = today();
const first = new Date(t.getFullYear(), t.getMonth(), 1);
if (isDailyActive()) {
window.loadExpenses("daily", iso(first), iso(t));
} else {
window.loadExpenses("monthly", iso(first), iso(t));
}
return;
}
if (r === "last30days") {
if (isDailyActive()) {
window.loadExpenses("daily", iso(daysAgo(30)), iso(today()));
} else {
window.loadExpenses("last30days");
}
return;
}
// reset pickera
if (startDateInput) startDateInput.value = "";
if (endDateInput) endDateInput.value = "";
reloadRespectingSplit(r);
});
});
// ——— KATEGORIE (🌐 Wszystkie + pojedyncze) ———
document.querySelectorAll(".category-filter").forEach((btn) => {
btn.addEventListener("click", function () {
// UI: podmień podświetlenie
document.querySelectorAll(".category-filter").forEach(b => {
b.classList.remove("btn-success");
b.classList.add("btn-outline-light");
});
this.classList.add("btn-success");
this.classList.remove("btn-outline-light");
// Zapisz filtr kategorii do globalnej zmiennej, którą odczytuje expense_chart.js
const cid = this.getAttribute("data-category-id") || "";
window.selectedCategoryId = cid;
// I ważne: przeładuj zgodnie z aktualnym trybem (to naprawia Twój przypadek #1)
reloadRespectingSplit();
});
});
// ——— SHOW ALL (Uwzględnij listy udostępnione/publiczne) ———
showAllCheckbox?.addEventListener("change", () => {
reloadRespectingSplit();
});
// ——— Inicjalizacja ———
// Podpowiedź dat do inputów
//if (startDateInput && endDateInput) {
// startDateInput.value = iso(daysAgo(7));
// endDateInput.value = iso(today());
//}
if (startDateInput && endDateInput) {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
startDateInput.value = iso(startOfMonth);
endDateInput.value = iso(now);
}
setActiveTimeSplit("daily");
reloadRespectingSplit();
});
+39
View File
@@ -0,0 +1,39 @@
document.addEventListener("DOMContentLoaded", () => {
const itemsContainer = document.getElementById('items');
if (!itemsContainer) return;
itemsContainer.addEventListener('click', function (e) {
const row = e.target.closest('.clickable-item');
if (!row || !itemsContainer.contains(row)) return;
if (e.target.closest('button') || e.target.tagName.toLowerCase() === 'input') {
return;
}
const checkbox = row.querySelector('input[type="checkbox"]');
if (!checkbox || checkbox.disabled) {
return;
}
const itemId = parseInt(row.id.replace('item-', ''), 10);
if (isNaN(itemId)) return;
if (checkbox.checked) {
socket.emit('uncheck_item', { item_id: itemId });
} else {
socket.emit('check_item', { item_id: itemId });
}
checkbox.disabled = true;
row.classList.add('opacity-50', 'is-pending');
let existingSpinner = row.querySelector('.shopping-item-spinner');
if (!existingSpinner) {
const spinner = document.createElement('span');
spinner.className = 'shopping-item-spinner spinner-border spinner-border-sm';
spinner.setAttribute('role', 'status');
spinner.setAttribute('aria-hidden', 'true');
row.appendChild(spinner);
}
});
});
+20
View File
@@ -0,0 +1,20 @@
document.addEventListener("DOMContentLoaded", function () {
const input = document.getElementById('confirm-delete-input');
const button = document.getElementById('confirm-delete-btn');
let timer = null;
input.addEventListener('input', function () {
button.disabled = true;
if (timer) clearTimeout(timer);
if (input.value.trim().toLowerCase() === 'usuń') {
timer = setTimeout(() => {
button.disabled = false;
}, 2000);
}
});
button.addEventListener('click', function () {
document.getElementById('delete-form').submit();
});
});
+67
View File
@@ -0,0 +1,67 @@
// download_chart.js — eksport PNG z ciemnym tłem (tymczasowo), bez wielokrotnego bindowania
document.addEventListener("DOMContentLoaded", () => {
const dlBtn = document.getElementById("downloadMainChartBtn");
if (!dlBtn) return;
// helper: bezpieczna nazwa pliku
const sanitize = (s) =>
(s || "")
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9-_]+/g, "_")
.replace(/_+/g, "_").replace(/^_+|_+$/g, "");
// helper: eksport z tymczasowym tłem
const exportChartPNG = (chart, bgColor = "#1e1e1e") => {
const canvas = chart.canvas;
const ctx = canvas.getContext("2d");
// 1) zapisz obraz
const snapshot = ctx.getImageData(0, 0, canvas.width, canvas.height);
// 2) podłóż tło pod istniejący rysunek
ctx.save();
ctx.globalCompositeOperation = "destination-over";
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.restore();
// 3) wygeneruj PNG
const dataUrl = chart.toBase64Image("image/png", 1.0);
// 4) przywróć pierwotny obraz (transparentny)
ctx.putImageData(snapshot, 0, 0);
return dataUrl;
};
// jednorazowe bindowanie click
if (!dlBtn.dataset.bound) {
dlBtn.addEventListener("click", () => {
const chart = window.expensesChart || Chart.getChart(document.getElementById("expensesChart"));
if (!chart) return;
// nazwa: zakres + timestamp
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}_${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
const rangeLabel = document.getElementById("chartRangeLabel")?.textContent || "";
const filename = `wydatki-${sanitize(rangeLabel)}-${stamp}.png`;
// (opcjonalnie) upewnij się, że layout jest świeży
chart.resize();
chart.update("none");
const a = document.createElement("a");
a.href = exportChartPNG(chart, "#1e1e1e"); // tu ustawiasz kolor tła eksportu
a.download = filename;
a.click();
});
dlBtn.dataset.bound = "1";
}
// aktywuj przycisk, gdy wykres istnieje
const enableIfReady = () => { dlBtn.disabled = !window.expensesChart; };
document.addEventListener("expensesChart:ready", enableIfReady);
enableIfReady();
});
+150
View File
@@ -0,0 +1,150 @@
// expense_chart.js
// Czyste generowanie wykresu + publiczne API: window.loadExpenses, window.setCategorySplit
// Współpracuje z backendem /expenses_data (range_type, start/end, by_category) patrz app.py :contentReference[oaicite:3]{index=3}
document.addEventListener("DOMContentLoaded", function () {
let expensesChart = null;
let categorySplit = false; // domyślnie wykres całościowy; przycisk w HTML startuje z aria-pressed="false"
const rangeLabel = document.getElementById("chartRangeLabel");
const showAllCheckbox = document.getElementById("showAllLists");
const ctx = document.getElementById("expensesChart")?.getContext("2d");
// Pomocnicze
const iso = (d) => d.toISOString().split("T")[0];
const today = () => new Date();
const daysAgo = (n) => { const d = new Date(); d.setDate(d.getDate() - n); return d; };
// Jeśli ktoś nie wstrzyknął globalnie selectedCategoryId (np. przez inny widok),
// zapewniamy istnienie zmiennej:
if (typeof window.selectedCategoryId === "undefined") {
window.selectedCategoryId = "";
}
// Ustawia tryb podziału na kategorie, bez odświeżania (kontroler zadzwoni potem w loadExpenses)
function setCategorySplit(on) {
categorySplit = !!on;
}
// Budowa URL dla /expenses_data zgodnie z backendem (range/start/end/show_all/category_id/by_category) :contentReference[oaicite:4]{index=4}
function buildUrl(range, startDate, endDate) {
let url = `/expenses_data?range=${encodeURIComponent(range)}`;
// show_all
if (showAllCheckbox) {
url += showAllCheckbox.checked ? "&show_all=true" : "&show_all=false";
} else {
url += "&show_all=true";
}
// daty (dodaj tylko, gdy kompletne)
if (startDate && endDate) {
url += `&start_date=${encodeURIComponent(startDate)}&end_date=${encodeURIComponent(endDate)}`;
}
// filtr kategorii list (z listy, nie "podziału na kategorie" na wykresie)
if (window.selectedCategoryId) {
url += `&category_id=${encodeURIComponent(window.selectedCategoryId)}`;
}
// podział na kategorie na wykresie
if (categorySplit) {
url += "&by_category=true";
}
return url;
}
// Label dla UI
function applyRangeLabel(range, startDate, endDate) {
if (startDate && endDate) {
rangeLabel.textContent = `Widok: własny zakres (${startDate}${endDate})`;
return;
}
const map = {
last30days: "Widok: ostatnie 30 dni",
currentmonth: "Widok: bieżący miesiąc",
monthly: "Widok: miesięczne",
quarterly: "Widok: kwartalne",
halfyearly: "Widok: półroczne",
yearly: "Widok: roczne",
daily: "Widok: dzienne",
};
rangeLabel.textContent = map[range] || "Widok: miesięczne";
}
// Publiczne API kontroler zawsze woła nas z odpowiednim 'range' i (dla daily) z datami.
function loadExpenses(range = "monthly", startDate = null, endDate = null) {
// Naprawa: daily bez dat => ostatnie 30 dni
if (range === "daily" && !(startDate && endDate)) {
startDate = iso(daysAgo(30));
endDate = iso(today());
}
const url = buildUrl(range, startDate, endDate);
fetch(url, { cache: "no-store" })
.then((r) => r.json())
.then((data) => {
if (!ctx) return;
if (expensesChart) { expensesChart.destroy(); window.expensesChart = null; }
//if (expensesChart) expensesChart.destroy();
const tooltipOptions = {
mode: "index",
intersect: false,
callbacks: {
label: function (context) {
if (context.parsed.y === 0) return "";
return (context.dataset.label || "Suma") + ": " + context.parsed.y;
},
},
};
if (categorySplit) {
// Stacked per-kategoria backend zwraca datasets z labelami kategorii :contentReference[oaicite:6]{index=6}
expensesChart = new Chart(ctx, {
type: "bar",
data: { labels: data.labels || [], datasets: data.datasets || [] },
options: {
responsive: true,
plugins: { tooltip: tooltipOptions, legend: { position: "top" } },
scales: { x: { stacked: true }, y: { stacked: true, beginAtZero: true } },
},
});
} else {
// Całościowo backend zwraca labels + expenses (sumy) :contentReference[oaicite:7]{index=7}
expensesChart = new Chart(ctx, {
type: "bar",
data: {
labels: data.labels || [],
datasets: [{
label: "Suma wydatków [PLN]",
data: data.expenses || [],
}],
},
options: {
responsive: true,
plugins: { tooltip: tooltipOptions },
scales: { y: { beginAtZero: true } },
},
});
}
// na potrzeby otwarciu w modalu
window.expensesChart = expensesChart;
document.dispatchEvent(new Event('expensesChart:ready'));
applyRangeLabel(range, startDate, endDate);
})
.catch((e) => console.error("Błąd pobierania danych:", e));
}
// Eksport publiczny dla kontrolerów
window.loadExpenses = loadExpenses;
window.setCategorySplit = setCategorySplit;
});
+11
View File
@@ -0,0 +1,11 @@
document.addEventListener("DOMContentLoaded", function () {
// Sprawdzamy, czy hash w URL to #chartTab
if (window.location.hash === "#chartTab") {
const chartTabTrigger = document.querySelector('#chart-tab');
if (chartTabTrigger) {
// Wymuszenie aktywacji zakładki Bootstrap
const tab = new bootstrap.Tab(chartTabTrigger);
tab.show();
}
}
});
+173
View File
@@ -0,0 +1,173 @@
document.addEventListener('DOMContentLoaded', () => {
const checkboxes = document.querySelectorAll('.list-checkbox');
const totalEl = document.getElementById('listsTotal');
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');
const customStartInput = document.getElementById('customStart');
const customEndInput = document.getElementById('customEnd');
if (customStartInput && customEndInput) {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
customStartInput.value = `${y}-${m}-01`;
customEndInput.value = `${y}-${m}-${d}`;
}
window.selectedCategoryId = "";
let initialLoad = true;
function updateTotal() {
let total = 0;
checkboxes.forEach(cb => {
const row = cb.closest('tr');
if (cb.checked && row.style.display !== 'none') {
total += parseFloat(cb.dataset.amount);
}
});
totalEl.textContent = total.toFixed(2) + ' PLN';
}
function getISOWeek(date) {
const target = new Date(date.valueOf());
const dayNr = (date.getDay() + 6) % 7;
target.setDate(target.getDate() - dayNr + 3);
const firstThursday = new Date(target.getFullYear(), 0, 4);
const dayDiff = (target - firstThursday) / 86400000;
return 1 + Math.floor(dayDiff / 7);
}
function filterByRange(range) {
const now = new Date();
const todayStr = now.toISOString().slice(0, 10);
const year = now.getFullYear();
const month = now.toISOString().slice(0, 7);
const week = `${year}-${String(getISOWeek(now)).padStart(2, '0')}`;
let startDate = null;
let endDate = null;
if (range === 'last30days') {
endDate = now;
startDate = new Date();
startDate.setDate(endDate.getDate() - 29);
}
if (range === 'currentmonth') {
startDate = new Date(year, now.getMonth(), 1);
endDate = now;
}
rows.forEach(row => {
const rDate = row.dataset.date;
const rMonth = row.dataset.month;
const rWeek = row.dataset.week;
const rYear = row.dataset.year;
const rowDateObj = new Date(rDate);
let show = true;
if (range === 'day') show = rDate === todayStr;
else if (range === 'month') show = rMonth === month;
else if (range === 'week') show = rWeek === week;
else if (range === 'year') show = rYear === String(year);
else if (range === 'all') show = true;
else if (range === 'last30days') show = rowDateObj >= startDate && rowDateObj <= endDate;
else if (range === 'currentmonth') show = rowDateObj >= startDate && rowDateObj <= endDate;
row.style.display = show ? '' : 'none';
});
}
function filterByLast30Days() {
filterByRange('last30days');
}
function applyExpenseFilter() {
rows.forEach(row => {
const amt = parseFloat(row.querySelector('.list-checkbox').dataset.amount || 0);
if (amt <= 0) row.style.display = 'none';
});
}
function applyCategoryFilter() {
if (!window.selectedCategoryId) return;
rows.forEach(row => {
const categoriesStr = row.dataset.categories || "";
const categories = categoriesStr ? categoriesStr.split(",") : [];
if (window.selectedCategoryId === "none") {
if (categoriesStr.trim() !== "") row.style.display = 'none';
} else {
if (!categories.includes(String(window.selectedCategoryId))) row.style.display = 'none';
}
});
}
function filterByCustomRange(startStr, endStr) {
const start = new Date(startStr);
const end = new Date(endStr);
if (isNaN(start) || isNaN(end)) return;
end.setHours(23, 59, 59, 999);
rows.forEach(row => {
const rowDateObj = new Date(row.dataset.date);
const show = rowDateObj >= start && rowDateObj <= end;
row.style.display = show ? '' : 'none';
});
}
checkboxes.forEach(cb => cb.addEventListener('change', updateTotal));
filterButtons.forEach(btn => {
btn.addEventListener('click', () => {
initialLoad = false;
filterButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const range = btn.dataset.range;
filterByRange(range);
applyExpenseFilter();
applyCategoryFilter();
updateTotal();
});
});
categoryButtons.forEach(btn => {
btn.addEventListener('click', () => {
categoryButtons.forEach(b => b.classList.remove('btn-success', 'active'));
categoryButtons.forEach(b => b.classList.add('btn-outline-light'));
btn.classList.remove('btn-outline-light');
btn.classList.add('btn-success', 'active');
window.selectedCategoryId = btn.dataset.categoryId || "";
if (initialLoad) {
filterByLast30Days();
} else {
const activeRange = document.querySelector('#listsTab .table-range-btn.active');
if (activeRange) filterByRange(activeRange.dataset.range);
}
applyExpenseFilter();
applyCategoryFilter();
updateTotal();
const chartTab = document.querySelector('#chart-tab');
if (chartTab && chartTab.classList.contains('active') && typeof window.loadExpenses === 'function') {
window.loadExpenses();
}
});
});
if (applyCustomBtn) {
applyCustomBtn.addEventListener('click', () => {
const startStr = customStartInput?.value;
const endStr = customEndInput?.value;
if (!startStr || !endStr) {
alert('Proszę wybrać obie daty!');
return;
}
initialLoad = false;
document.querySelectorAll('#listsTab .table-range-btn').forEach(b => b.classList.remove('active'));
filterByCustomRange(startStr, endStr);
applyExpenseFilter();
applyCategoryFilter();
updateTotal();
});
}
filterByLast30Days();
applyExpenseFilter();
applyCategoryFilter();
updateTotal();
});
+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);
})();
@@ -7,13 +7,13 @@ function toggleEmptyPlaceholder() {
// prawdziwe <li> to te z dataname lub id="item‑…"
const hasRealItems = list.querySelector('li[data-name], li[id^="item-"]') !== null;
const placeholder = document.getElementById('empty-placeholder');
const placeholder = document.getElementById('empty-placeholder');
if (!hasRealItems && !placeholder) {
const li = document.createElement('li');
li.id = 'empty-placeholder';
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);
}
}
}
@@ -124,38 +124,66 @@ function setupList(listId, username) {
summaryEl.innerHTML = `<b>💸 Łącznie wydano:</b> ${data.total.toFixed(2)} PLN`;
}
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`);
showToast(`Dodano wydatek: ${data.amount.toFixed(2)} PLN`, 'info');
});
socket.on('item_added', data => {
showToast(`${data.added_by} dodał: ${data.name}`);
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center flex-wrap item-not-checked';
li.id = `item-${data.id}`;
let quantityBadge = '';
if (data.quantity && data.quantity > 1) {
quantityBadge = `<span class="badge bg-secondary">x${data.quantity}</span>`;
}
showToast(`${data.added_by} dodał: ${data.name}`, 'info');
li.innerHTML = `
<div class="d-flex align-items-center flex-wrap gap-2">
<input class="large-checkbox" type="checkbox">
<span id="name-${data.id}" class="text-white">${data.name} ${quantityBadge}</span>
</div>
<div class="mt-2 mt-md-0">
<button class="btn btn-sm btn-outline-warning me-1" onclick="editItem(${data.id}, '${data.name}', ${data.quantity || 1})"></button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteItem(${data.id})">🗑</button>
</div>
`;
const item = {
...data,
purchased: false,
not_purchased: false,
not_purchased_reason: '',
note: ''
};
// #### WERSJA Z NAPISAMI ####
// <button class="btn btn-sm btn-outline-warning me-1" onclick="editItem(${data.id}, '${data.name}', ${data.quantity || 1})">✏️ Edytuj</button>
// <button class="btn btn-sm btn-outline-danger" onclick="deleteItem(${data.id})">🗑️ Usuń</button>
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);
updateProgressBar();
toggleEmptyPlaceholder();
updateProgressBar();
if (isOwnFreshShareItem) {
let seconds = 15;
const intervalId = setInterval(() => {
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) {
existing.replaceWith(renderItem(item, window.IS_SHARE));
}
}, 15000);
}
});
socket.on('item_deleted', data => {
@@ -163,12 +191,12 @@ function setupList(listId, username) {
if (li) {
li.remove();
}
showToast('Usunięto produkt');
showToast('Usunięto produkt z listy', 'success');
updateProgressBar();
toggleEmptyPlaceholder();
});
socket.on('progress_updated', function(data) {
socket.on('progress_updated', function (data) {
const progressBar = document.getElementById('progress-bar');
if (progressBar) {
progressBar.style.width = data.percent + '%';
@@ -178,51 +206,41 @@ 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)}%)`;
}
});
socket.on('note_updated', data => {
const itemEl = document.getElementById(`item-${data.item_id}`);
if (itemEl) {
let noteEl = itemEl.querySelector('small');
if (noteEl) {
//noteEl.innerHTML = `[ Notatka: <b>${data.note}</b> ]`;
noteEl.innerHTML = `[ <b>${data.note}</b> ]`;
} else {
const newNote = document.createElement('small');
newNote.className = 'text-danger ms-4';
//newNote.innerHTML = `[ Notatka: <b>${data.note}</b> ]`;
newNote.innerHTML = `[ <b>${data.note}</b> ]`;
const flexColumn = itemEl.querySelector('.d-flex.flex-column');
if (flexColumn) {
flexColumn.appendChild(newNote);
} else {
itemEl.appendChild(newNote);
}
}
}
showToast('Notatka zaktualizowana!');
socket.emit('request_full_list', { list_id: window.LIST_ID });
showToast('Notatka dodana/zaktualizowana', 'success');
});
socket.on('item_edited', data => {
const nameSpan = document.getElementById(`name-${data.item_id}`);
if (nameSpan) {
let quantityBadge = '';
if (data.new_quantity && data.new_quantity > 1) {
quantityBadge = ` <span class="badge bg-secondary">x${data.new_quantity}</span>`;
}
nameSpan.innerHTML = `${data.new_name}${quantityBadge}`;
}
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`);
});
const idx = window.currentItems.findIndex(i => i.id === data.item_id);
if (idx !== -1) {
window.currentItems[idx].name = data.new_name;
window.currentItems[idx].quantity = data.new_quantity;
updateProgressBar();
toggleEmptyPlaceholder();
const newItem = renderItem(window.currentItems[idx], window.IS_SHARE);
const oldItem = document.getElementById(`item-${data.item_id}`);
if (oldItem && newItem) {
oldItem.replaceWith(newItem);
}
}
showToast(`Zaktualizowano produkt: ${data.new_name} (x${data.new_quantity})`, 'success');
updateProgressBar();
toggleEmptyPlaceholder();
});
// --- WAŻNE: zapisz dane do reconnect ---
window.LIST_ID = listId;
window.usernameForReconnect = username;
window.CURRENT_LIST_USERNAME = username;
}
function unmarkNotPurchased(itemId) {
socket.emit('unmark_not_purchased', { item_id: itemId });
}
+309
View File
@@ -0,0 +1,309 @@
document.addEventListener('DOMContentLoaded', function () {
const modal = document.getElementById('massAddModal');
const productList = document.getElementById('mass-add-list');
const sortBar = document.getElementById('sort-bar');
const productCountDisplay = document.getElementById('product-count');
const modalBody = modal?.querySelector('.modal-body');
function normalize(str) {
return str?.trim().toLowerCase() || '';
}
let sortMode = 'popularity';
let limit = 25;
let offset = 0;
let loading = false;
let reachedEnd = false;
let allProducts = [];
let addedProducts = new Set();
function renderSortBar() {
if (!sortBar) return;
sortBar.innerHTML = `
Sortuj: <a href="#" id="sort-popularity" ${sortMode === "popularity" ? 'style="font-weight:bold"' : ''}>Popularność</a> |
<a href="#" id="sort-alphabetical" ${sortMode === "alphabetical" ? 'style="font-weight:bold"' : ''}>Alfabetycznie</a>
`;
document.getElementById('sort-popularity').onclick = (e) => {
e.preventDefault();
if (sortMode !== 'popularity') {
sortMode = 'popularity';
resetAndFetchProducts();
}
};
document.getElementById('sort-alphabetical').onclick = (e) => {
e.preventDefault();
if (sortMode !== 'alphabetical') {
sortMode = 'alphabetical';
resetAndFetchProducts();
}
};
}
function resetAndFetchProducts() {
offset = 0;
reachedEnd = false;
allProducts = [];
productList.innerHTML = '';
fetchProducts(true);
renderSortBar();
if (productCountDisplay) productCountDisplay.textContent = '';
}
async function fetchProducts(reset = false) {
if (loading || reachedEnd) return;
loading = true;
if (!reset) {
const loadingLi = document.createElement('li');
loadingLi.className = 'list-group-item bg-dark text-light loading';
loadingLi.textContent = 'Ładowanie...';
productList.appendChild(loadingLi);
}
try {
const res = await fetch(`/all_products?sort=${sortMode}&limit=${limit}&offset=${offset}`);
const data = await res.json();
const products = data.products || [];
if (products.length < limit) reachedEnd = true;
allProducts = reset ? products : allProducts.concat(products);
const loadingEl = productList.querySelector('.loading');
if (loadingEl) loadingEl.remove();
if (reset && products.length === 0) {
const emptyLi = document.createElement('li');
emptyLi.className = 'list-group-item text-muted bg-dark';
emptyLi.textContent = 'Brak produktów do wyświetlenia.';
productList.appendChild(emptyLi);
} else {
renderProducts(products);
}
offset += limit;
if (productCountDisplay) {
productCountDisplay.textContent = `Wyświetlono ${allProducts.length} z ${data.total_count} pozycji`;
}
const statsEl = document.getElementById('massAddProductStats');
if (statsEl) {
statsEl.textContent = `(${allProducts.length} z ${data.total_count})`;
}
} catch (err) {
const loadingEl = productList.querySelector('.loading');
if (loadingEl) loadingEl.remove();
const errorLi = document.createElement('li');
errorLi.className = 'list-group-item text-danger bg-dark';
errorLi.textContent = 'Błąd ładowania danych';
productList.appendChild(errorLi);
}
loading = false;
}
function getAlreadyAddedProducts() {
const set = new Set();
document.querySelectorAll('#items li').forEach(li => {
if (li.dataset.name) {
set.add(normalize(li.dataset.name));
}
});
return set;
}
function renderProducts(products) {
addedProducts = getAlreadyAddedProducts();
const existingNames = new Set();
document.querySelectorAll('#mass-add-list li').forEach(li => {
const name = li.querySelector('span')?.textContent;
if (name) existingNames.add(normalize(name));
});
products.forEach(product => {
const name = typeof product === "object" ? product.name : product;
const normName = normalize(name);
if (existingNames.has(normName)) return;
existingNames.add(normName);
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center bg-dark text-light';
if (addedProducts.has(normName)) {
const nameSpan = document.createElement('span');
nameSpan.textContent = name;
li.appendChild(nameSpan);
li.classList.add('opacity-50');
const badge = document.createElement('span');
badge.className = 'badge bg-success ms-auto';
badge.textContent = 'Dodano';
li.appendChild(badge);
} else {
const nameSpan = document.createElement('span');
nameSpan.textContent = name;
nameSpan.style.flex = '1 1 auto';
li.appendChild(nameSpan);
const qtyWrapper = document.createElement('div');
qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls';
const minusBtn = document.createElement('button');
minusBtn.type = 'button';
minusBtn.className = 'btn btn-outline-light btn-sm px-2';
minusBtn.textContent = '';
const qty = document.createElement('input');
qty.type = 'number';
qty.min = 1;
qty.value = 1;
qty.className = 'form-control text-center p-1 rounded';
qty.style.width = '50px';
qty.style.margin = '0 2px';
qty.title = 'Ilość';
const plusBtn = document.createElement('button');
plusBtn.type = 'button';
plusBtn.className = 'btn btn-outline-light btn-sm px-2';
plusBtn.textContent = '+';
minusBtn.onclick = () => {
qty.value = Math.max(1, parseInt(qty.value) - 1);
};
plusBtn.onclick = () => {
qty.value = parseInt(qty.value) + 1;
};
qtyWrapper.append(minusBtn, qty, plusBtn);
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-primary ms-4';
btn.textContent = '+';
btn.onclick = () => {
const quantity = parseInt(qty.value) || 1;
socket.emit('add_item', { list_id: LIST_ID, name: name, quantity: quantity });
};
li.append(qtyWrapper, btn);
}
productList.appendChild(li);
});
}
if (modalBody) {
modalBody.addEventListener('scroll', function () {
if (!loading && !reachedEnd && (modalBody.scrollTop + modalBody.clientHeight > modalBody.scrollHeight - 80)) {
fetchProducts(false);
}
});
}
modal.addEventListener('show.bs.modal', function () {
resetAndFetchProducts();
});
renderSortBar();
socket.on('item_added', data => {
document.querySelectorAll('#mass-add-list li').forEach(li => {
const itemName = li.firstChild?.textContent.trim();
if (normalize(itemName) === normalize(data.name) && !li.classList.contains('opacity-50')) {
li.classList.add('opacity-50');
li.querySelectorAll('button').forEach(btn => btn.remove());
const quantityControls = li.querySelector('.quantity-controls');
if (quantityControls) quantityControls.remove();
const badge = document.createElement('span');
badge.className = 'badge bg-success';
badge.textContent = 'Dodano';
const btnGroup = document.createElement('div');
btnGroup.className = 'btn-group btn-group-sm me-2';
btnGroup.role = 'group';
const undoBtn = document.createElement('button');
undoBtn.className = 'btn btn-outline-warning';
undoBtn.innerHTML = '⟳ Cofnij';
const timerBtn = document.createElement('button');
timerBtn.className = 'btn btn-outline-secondary disabled';
let secondsLeft = 15;
timerBtn.textContent = `${secondsLeft}s`;
btnGroup.append(undoBtn, timerBtn);
const rightWrapper = document.createElement('div');
rightWrapper.className = 'd-flex align-items-center gap-2 ms-auto';
rightWrapper.append(btnGroup, badge);
li.appendChild(rightWrapper);
const intervalId = setInterval(() => {
secondsLeft--;
if (secondsLeft > 0) {
timerBtn.textContent = `${secondsLeft}s`;
} else {
clearInterval(intervalId);
btnGroup.remove();
}
}, 1000);
undoBtn.onclick = () => {
clearInterval(intervalId);
btnGroup.remove();
badge.remove();
li.classList.remove('opacity-50');
const qtyWrapper = document.createElement('div');
qtyWrapper.className = 'd-flex align-items-center ms-2 quantity-controls';
const minusBtn = document.createElement('button');
minusBtn.type = 'button';
minusBtn.className = 'btn btn-outline-light btn-sm px-2';
minusBtn.textContent = '';
const qty = document.createElement('input');
qty.type = 'number';
qty.min = 1;
qty.value = 1;
qty.className = 'form-control text-center p-1 rounded';
qty.style.width = '50px';
qty.style.margin = '0 2px';
qty.title = 'Ilość';
const plusBtn = document.createElement('button');
plusBtn.type = 'button';
plusBtn.className = 'btn btn-outline-light btn-sm px-2';
plusBtn.textContent = '+';
minusBtn.onclick = () => {
qty.value = Math.max(1, parseInt(qty.value) - 1);
};
plusBtn.onclick = () => {
qty.value = parseInt(qty.value) + 1;
};
qtyWrapper.append(minusBtn, qty, plusBtn);
li.appendChild(qtyWrapper);
const addBtn = document.createElement('button');
addBtn.className = 'btn btn-sm btn-primary ms-4';
addBtn.textContent = '+';
addBtn.onclick = () => {
const quantity = parseInt(qty.value) || 1;
socket.emit('add_item', {
list_id: LIST_ID,
name: data.name,
quantity: quantity
});
};
li.appendChild(addBtn);
socket.emit('delete_item', { item_id: data.id });
};
}
});
});
});
+118
View File
@@ -0,0 +1,118 @@
// modal_chart.js — final: kopiuje kolory z oryginałów, bez fallbacków i bez debugów
function openChartFullscreen(sourceChartIdOrKey, title) {
const modalEl = document.getElementById("chartFullscreenModal");
const canvas = document.getElementById("chartFullscreenCanvas");
const titleEl = document.getElementById("chartModalTitle");
if (titleEl) titleEl.textContent = title || "Wykres";
// Znajdź wykres źródłowy (po elemencie, id Chart.js lub globalu)
const srcEl = document.getElementById(sourceChartIdOrKey);
const srcChart =
(srcEl && Chart.getChart(srcEl)) ||
Chart.getChart(sourceChartIdOrKey) ||
window[sourceChartIdOrKey] ||
window.expensesChart ||
null;
if (!srcChart) {
bootstrap.Modal.getOrCreateInstance(modalEl).show();
return;
}
// Skopiuj labels i datasets 1:1 (tylko bezpieczne klucze, żeby nie przenosić referencji Chart.js)
const safeDataset = (d) => {
const out = {
// dane i opis
label: d.label,
data: Array.isArray(d.data) ? d.data.slice() : [],
type: d.type,
// kolory / styl — dokładnie z oryginału, jeśli były
backgroundColor: d.backgroundColor,
borderColor: d.borderColor,
borderWidth: d.borderWidth,
borderSkipped: d.borderSkipped,
// stacking / kolejność
stack: d.stack,
order: d.order,
// wszystko co może być ważne dla Twoich barów/konfiguracji
parsing: d.parsing,
indexAxis: d.indexAxis,
};
// usuń klucze undefined (Chart.js lubi czyste configi)
Object.keys(out).forEach((k) => out[k] === undefined && delete out[k]);
return out;
};
const freshData = {
labels: Array.isArray(srcChart.data?.labels) ? srcChart.data.labels.slice() : [],
datasets: (srcChart.data?.datasets || []).map(safeDataset),
};
// Typ wykresu z oryginału (np. "bar")
const chartType = (srcChart.config && srcChart.config.type) || "bar";
// Minimalne, bezpieczne opcje: responsywność + stacking + orientacja
const scx = srcChart.config?.options?.scales?.x || {};
const scy = srcChart.config?.options?.scales?.y || {};
const freshOptions = {
responsive: true,
maintainAspectRatio: false,
// jeżeli oryginał miał pion/poziom, zachowaj
indexAxis: srcChart.config?.options?.indexAxis || "x",
// nie kopiujemy całych pluginów (unikamy referencji) — domyślne legend/tooltip są OK
plugins: {},
scales: {
x: { stacked: !!scx.stacked },
y: { stacked: !!scy.stacked, beginAtZero: scy.beginAtZero !== false },
},
};
// Helper: zniszcz wykres na canvasie modala, jeśli istnieje
const destroyOnCanvas = () => {
if (canvas._chartInstance) {
try { canvas._chartInstance.destroy(); } catch { }
canvas._chartInstance = null;
}
const existing = Chart.getChart(canvas);
if (existing) {
try { existing.destroy(); } catch { }
}
};
destroyOnCanvas();
// Po pokazaniu modala twórz wykres (gdy ma już wymiary)
const onShown = () => {
destroyOnCanvas();
const ctx = canvas.getContext("2d");
canvas._chartInstance = new Chart(ctx, {
type: chartType,
data: freshData,
options: freshOptions,
});
// lekki nudge layoutu
requestAnimationFrame(() => {
canvas._chartInstance.resize();
canvas._chartInstance.update();
});
};
const onHidden = () => { destroyOnCanvas(); };
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
modalEl.addEventListener("shown.bs.modal", onShown, { once: true });
modalEl.addEventListener("hidden.bs.modal", onHidden, { once: true });
modal.show();
}
// Odblokuj ⛶ gdy bazowy wykres gotowy
document.addEventListener("expensesChart:ready", () => {
const b = document.getElementById("openFsBtn");
if (b) b.disabled = false;
});
document.addEventListener("DOMContentLoaded", () => {
const b = document.getElementById("openFsBtn");
if (b && window.expensesChart) b.disabled = false;
});
+24
View File
@@ -0,0 +1,24 @@
window.currentItemId = window.currentItemId ?? null;
window.openNoteModal = function (event, itemId) {
event.stopPropagation();
window.currentItemId = itemId;
const noteEl = document.querySelector(`#info-${itemId} .text-danger b`);
document.getElementById('noteText').value = noteEl
? noteEl.innerText.trim()
: "";
const modal = new bootstrap.Modal(document.getElementById('noteModal'));
modal.show();
};
function submitNote(e) {
e.preventDefault();
const text = document.getElementById('noteText').value;
if (window.currentItemId !== null) {
socket.emit('update_note', { item_id: window.currentItemId, note: text });
const modal = bootstrap.Modal.getInstance(document.getElementById('noteModal'));
modal.hide();
}
}
@@ -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)} </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");
}
});
});
});
@@ -0,0 +1,91 @@
function bindSyncButton(button) {
button.addEventListener('click', function (e) {
e.preventDefault();
const itemId = button.getAttribute('data-item-id');
button.disabled = true;
fetch(`/admin/sync_suggestion/${itemId}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
showToast(data.message, data.success ? 'success' : 'danger');
if (data.success) {
button.innerText = '✅ Zsynchronizowano';
button.classList.remove('btn-outline-primary');
button.classList.add('btn-success');
} else {
button.disabled = false;
}
})
.catch(() => {
showToast('Błąd synchronizacji', 'danger');
button.disabled = false;
});
});
}
function bindDeleteButton(button) {
button.addEventListener('click', function (e) {
e.preventDefault();
const suggestionId = button.getAttribute('data-suggestion-id');
const row = button.closest('tr');
const itemId = button.getAttribute('data-item-id');
const nameBadge = row?.querySelector('.badge.bg-primary');
const itemName = nameBadge?.innerText.trim().toLowerCase();
button.disabled = true;
fetch(`/admin/delete_suggestion/${suggestionId}`, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
showToast(data.message, data.success ? 'success' : 'danger');
if (!data.success || !row) {
button.disabled = false;
return;
}
const isProductRow = typeof itemId === 'string' && itemId !== '';
const cell = row.querySelector('td:last-child');
if (!cell) return;
if (isProductRow) {
cell.innerHTML = `<button class="btn btn-sm btn-outline-light sync-btn" data-item-id="${itemId}">🔄 Synchronizuj</button>`;
const syncBtn = cell.querySelector('.sync-btn');
if (syncBtn) bindSyncButton(syncBtn);
} else {
cell.innerHTML = '<span class="badge rounded-pill bg-warning opacity-75">Usunięto z bazy danych</span>';
}
})
.catch(() => {
showToast('Błąd usuwania sugestii', 'danger');
button.disabled = false;
});
});
}
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll('.sync-btn').forEach(btn => {
const clone = btn.cloneNode(true);
btn.replaceWith(clone);
bindSyncButton(clone);
});
document.querySelectorAll('.delete-suggestion-btn').forEach(btn => {
const clone = btn.cloneNode(true);
btn.replaceWith(clone);
bindDeleteButton(clone);
});
});
@@ -0,0 +1,99 @@
document.addEventListener("DOMContentLoaded", () => {
const analyzeBtn = document.getElementById("analyzeBtn");
if (analyzeBtn) {
analyzeBtn.addEventListener("click", () => analyzeReceipts(LIST_ID));
}
});
async function analyzeReceipts(listId) {
const resultsDiv = document.getElementById("analysisResults");
resultsDiv.innerHTML = `
<div class="text-info d-flex align-items-center gap-2">
<div class="spinner-border spinner-border-sm text-info" role="status"></div>
<span>Trwa analiza paragonów...</span>
</div>`;
const start = performance.now();
try {
const res = await fetch(`/lists/${listId}/analyze`, { method: "POST" });
const data = await res.json();
const duration = ((performance.now() - start) / 1000).toFixed(2);
let html = `<div class="card bg-dark text-white border-secondary p-3">`;
html += `<p class="text-secondary"><small>⏱ Czas analizy OCR: ${duration} sek.</small></p>`;
html += `<p><b>📊 Łącznie wykryto:</b> ${data.total.toFixed(2)} PLN</p>`;
data.results.forEach((r, i) => {
const disabled = r.already_added ? "disabled" : "";
const inputStyle = "form-control d-inline-block bg-dark text-white border-light rounded";
const inputField = `<input type="number" id="amount-${i}" value="${r.amount}" step="0.01" class="${inputStyle}" style="width: 120px;" ${disabled}>`;
const button = r.already_added
? `<span class="badge rounded-pill bg-secondary ms-2">Dodano</span>`
: `<button id="add-btn-${i}" onclick="emitExpense(${i})" class="btn btn-outline-light ms-2"> Dodaj</button>`;
html += `
<div class="mb-2 d-flex align-items-center gap-2 flex-wrap">
<span class="text-light flex-grow-1">${r.filename}</span>
${inputField}
${button}
</div>`;
});
if (data.results.length > 1) {
html += `<button id="addAllBtn" onclick="emitAllExpenses(${data.results.length})" class="btn btn-sm btn-outline-light mt-3 w-100"> Dodaj wszystkie</button>`;
}
html += `</div>`;
resultsDiv.innerHTML = html;
window._ocr_results = data.results;
} catch (err) {
resultsDiv.innerHTML = `<div class="text-danger">❌ Wystąpił błąd podczas analizy.</div>`;
console.error(err);
}
}
function emitExpense(i) {
const r = window._ocr_results[i];
const val = parseFloat(document.getElementById(`amount-${i}`).value);
const btn = document.getElementById(`add-btn-${i}`);
if (!isNaN(val) && val > 0) {
socket.emit('add_expense', {
list_id: LIST_ID,
amount: val,
receipt_filename: r.filename
});
document.getElementById(`amount-${i}`).disabled = true;
if (btn) {
btn.disabled = true;
btn.classList.remove('btn-outline-success');
btn.classList.add('btn-success');
btn.textContent = '✅ Dodano';
}
}
}
function emitAllExpenses(n) {
const btnAll = document.getElementById('addAllBtn');
if (btnAll) {
btnAll.disabled = true;
btnAll.innerHTML = `<span class="spinner-border spinner-border-sm me-2" role="status"></span>Dodawanie...`;
}
for (let i = 0; i < n; i++) {
setTimeout(() => emitExpense(i), i * 150);
}
setTimeout(() => {
if (btnAll) {
btnAll.innerHTML = '✅ Wszystko dodano';
btnAll.classList.remove('btn-success');
btnAll.classList.add('btn-outline-success');
}
}, n * 150 + 300);
}
+57
View File
@@ -0,0 +1,57 @@
(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 () {
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 = 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 sep = baseSrc.includes("?") ? "&" : "?";
cropImage.src = baseSrc + sep + "cb=" + ver;
currentReceiptId = button?.getAttribute("data-receipt-id");
document.querySelectorAll('.cropper-container').forEach(e => e.remove());
if (cropper && cropper.destroy) cropper.destroy();
cropImage.onload = () => { cropper = cropUtils.initCropper(cropImage); };
});
cropModal.addEventListener("hidden.bs.modal", function () {
cropUtils.cleanUpCropper(cropImage, cropper);
cropper = null;
});
saveButton.addEventListener("click", function () {
if (!cropper) return;
spinner.classList.remove("d-none");
cropUtils.handleCrop(currentEndpoint, currentReceiptId, cropper, spinner);
});
}
})();
@@ -0,0 +1,96 @@
(function () {
function initCropper(imgEl) {
return new Cropper(imgEl, {
viewMode: 1,
autoCropArea: 1,
responsive: true,
background: false,
zoomable: true,
movable: true,
dragMode: 'move',
minContainerHeight: 400,
minContainerWidth: 400,
});
}
function cleanUpCropper(imgEl, cropperInstance) {
if (cropperInstance) {
cropperInstance.destroy();
}
if (imgEl) imgEl.src = "";
}
function handleCrop(endpoint, receiptId, cropper, spinner) {
const cropData = cropper.getData();
const imageData = cropper.getImageData();
const scaleX = imageData.naturalWidth / imageData.width;
const scaleY = imageData.naturalHeight / imageData.height;
const width = cropData.width * scaleX;
const height = cropData.height * scaleY;
if (width < 1 || height < 1) {
spinner.classList.add("d-none");
showToast("Obszar przycięcia jest zbyt mały lub pusty", "danger");
return;
}
const maxDim = 2000;
const scale = Math.min(1, maxDim / Math.max(width, height));
const finalWidth = Math.round(width * scale);
const finalHeight = Math.round(height * scale);
const croppedCanvas = cropper.getCroppedCanvas({
width: finalWidth,
height: finalHeight,
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
});
if (!croppedCanvas) {
spinner.classList.add("d-none");
showToast("Nie można uzyskać obrazu przycięcia", "danger");
return;
}
croppedCanvas.toBlob(function (blob) {
if (!blob) {
spinner.classList.add("d-none");
showToast("Nie udało się zapisać obrazu", "danger");
return;
}
const formData = new FormData();
formData.append("receipt_id", receiptId);
formData.append("cropped_image", blob);
fetch(endpoint, {
method: "POST",
body: formData,
})
.then((res) => res.json())
.then((data) => {
spinner.classList.add("d-none");
if (data.success) {
showToast("Zapisano przycięty paragon", "success");
setTimeout(() => location.reload(), 1500);
} else {
showToast("Błąd: " + (data.error || "Nieznany"), "danger");
}
})
.catch((err) => {
spinner.classList.add("d-none");
showToast("Błąd sieci", "danger");
console.error(err);
});
}, "image/webp", 1.0);
}
window.cropUtils = {
initCropper,
cleanUpCropper,
handleCrop,
};
})();
+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();
});
@@ -3,29 +3,28 @@ window.receiptToastShown = window.receiptToastShown || false;
if (!window.receiptUploaderInitialized) {
document.addEventListener("DOMContentLoaded", function () {
const form = document.getElementById("receiptForm");
const input = document.getElementById("receiptInput");
const gallery = document.getElementById("receiptGallery");
const inputCamera = document.getElementById("cameraInput");
const inputGallery = document.getElementById("galleryInput");
const inputPDF = document.getElementById("pdfInput");
const galleryBtn = document.getElementById("galleryBtn");
const galleryBtnText = document.getElementById("galleryBtnText");
const cameraBtn = document.getElementById("cameraBtn");
const progressContainer = document.getElementById("progressContainer");
const progressBar = document.getElementById("progressBar");
const fileLabel = document.getElementById("fileLabel");
const gallery = document.getElementById("receiptGallery");
if (!form || !input || !gallery) return;
if (!form || !gallery) return;
// Zmiana labela po wyborze pliku
if (input && fileLabel) {
input.addEventListener("change", function () {
if (input.files.length > 0) {
fileLabel.textContent = input.files[0].name;
} else {
fileLabel.textContent = "Wybierz zdjęcie paragonu";
}
});
const isDesktop = window.matchMedia("(pointer: fine)").matches;
if (isDesktop) {
if (cameraBtn) cameraBtn.remove();
if (inputCamera) inputCamera.remove();
if (galleryBtnText) galleryBtnText.textContent = " Dodaj paragon";
}
form.addEventListener("submit", function (e) {
e.preventDefault();
const file = input.files[0];
function handleFileUpload(inputElement) {
const file = inputElement.files[0];
if (!file) {
showToast("Nie wybrano pliku!", "warning");
return;
@@ -56,31 +55,35 @@ if (!window.receiptUploaderInitialized) {
progressContainer.style.display = "none";
progressBar.style.width = "0%";
progressBar.textContent = "";
input.value = "";
if (fileLabel) {
fileLabel.textContent = "Wybierz zdjęcie paragonu";
}
inputElement.value = "";
window.receiptToastShown = false;
};
xhr.onreadystatechange = function () {
if (xhr.readyState === XMLHttpRequest.DONE) {
if (xhr.status === 200) {
try {
const res = JSON.parse(xhr.responseText);
if (res.success && res.url) {
if (xhr.status === 200 && res.success && res.url) {
fetch(window.location.href)
.then(response => response.text())
.then(html => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const newGallery = doc.getElementById("receiptGallery");
if (newGallery) {
gallery.innerHTML = newGallery.innerHTML;
lightbox.destroy();
lightbox = GLightbox({
selector: '.glightbox'
});
if (typeof lightbox !== "undefined") {
lightbox.destroy();
}
lightbox = GLightbox({ selector: ".glightbox" });
const analysisBlock = document.getElementById("receiptAnalysisBlock");
if (analysisBlock) {
analysisBlock.classList.remove("d-none");
}
if (!window.receiptToastShown) {
showToast("Wgrano paragon", "success");
@@ -89,16 +92,21 @@ if (!window.receiptUploaderInitialized) {
}
});
} else {
showToast(res.message || "Błąd podczas wgrywania.", "danger");
const errorMessage = res.error || res.message || "Błąd podczas wgrywania.";
showToast(errorMessage, "danger");
}
} else {
showToast("Błąd serwera. Spróbuj ponownie.", "danger");
} catch (err) {
showToast("Błąd serwera (nieprawidłowa odpowiedź).", "danger");
}
}
};
xhr.send(formData);
});
}
inputCamera?.addEventListener("change", () => handleFileUpload(inputCamera));
inputGallery?.addEventListener("change", () => handleFileUpload(inputGallery));
inputPDF?.addEventListener("change", () => handleFileUpload(inputPDF));
});
window.receiptUploaderInitialized = true;
+12
View File
@@ -0,0 +1,12 @@
document.addEventListener("DOMContentLoaded", function () {
new TomSelect("#categories", {
plugins: ['remove_button'],
maxItems: 1,
placeholder: 'Wybierz jedną kategorie...',
create: false,
sortField: {
field: "text",
direction: "asc"
}
});
});
@@ -0,0 +1,35 @@
document.addEventListener('DOMContentLoaded', () => {
const checkboxes = document.querySelectorAll('.list-checkbox');
const totalEl = document.getElementById('listsTotal');
const selectAllBtn = document.getElementById('selectAllBtn');
const deselectAllBtn = document.getElementById('deselectAllBtn');
function updateTotal() {
let total = 0;
checkboxes.forEach(cb => {
const row = cb.closest('tr');
if (cb.checked && row.style.display !== 'none') {
total += parseFloat(cb.dataset.amount);
}
});
totalEl.textContent = total.toFixed(2) + ' PLN';
}
selectAllBtn.addEventListener('click', () => {
checkboxes.forEach(cb => cb.checked = true);
updateTotal();
selectAllBtn.style.display = 'none';
deselectAllBtn.style.display = 'inline-block';
});
deselectAllBtn.addEventListener('click', () => {
checkboxes.forEach(cb => cb.checked = false);
updateTotal();
deselectAllBtn.style.display = 'none';
selectAllBtn.style.display = 'inline-block';
});
checkboxes.forEach(cb => {
cb.addEventListener('change', updateTotal);
});
});
+14
View File
@@ -0,0 +1,14 @@
document.addEventListener("DOMContentLoaded", () => {
const select = document.getElementById("monthSelect");
if (!select) return;
select.addEventListener("change", () => {
const month = select.value;
const url = new URL(window.location.href);
if (month) {
url.searchParams.set("m", month);
} else {
url.searchParams.delete("m");
}
window.location.href = url.toString();
});
});
@@ -0,0 +1,17 @@
document.addEventListener('DOMContentLoaded', function () {
const showAllCheckbox = document.getElementById('showAllLists');
if (!showAllCheckbox) return;
const params = new URLSearchParams(window.location.search);
if (!params.has('show_all')) {
params.set('show_all', 'true');
window.history.replaceState({}, '', `${window.location.pathname}?${params.toString()}`);
}
showAllCheckbox.checked = params.get('show_all') === 'true';
showAllCheckbox.addEventListener('change', function () {
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('show_all', showAllCheckbox.checked ? 'true' : 'false');
window.location.search = urlParams.toString();
});
});
+146
View File
@@ -0,0 +1,146 @@
let didReceiveFirstFullList = false;
// --- Automatyczny reconnect po powrocie do karty/przywróceniu internetu ---
function reconnectIfNeeded() {
if (!socket.connected) {
socket.connect();
}
}
document.addEventListener("visibilitychange", function () {
if (!document.hidden) {
reconnectIfNeeded();
}
});
window.addEventListener("focus", function () {
reconnectIfNeeded();
});
window.addEventListener("online", function () {
reconnectIfNeeded();
});
// --- Blokowanie checkboxów na czas reconnect ---
function disableCheckboxes(disable) {
document.querySelectorAll('#items input[type="checkbox"]').forEach(cb => {
cb.disabled = disable;
});
}
// --- Toasty przy rozłączeniu i połączeniu ---
let firstConnect = true;
let wasReconnected = false; // flaga do kontrolowania toasta
socket.on('connect', function () {
if (!firstConnect) {
//showToast('Połączono z serwerem!', 'info');
disableCheckboxes(true);
wasReconnected = true;
if (window.LIST_ID && window.usernameForReconnect) {
socket.emit('join_list', { room: window.LIST_ID, username: window.usernameForReconnect });
}
}
firstConnect = false;
});
socket.on('disconnect', function (reason) {
//showToast('Utracono połączenie z serwerem...', 'warning');
disableCheckboxes(true);
});
socket.off('joined_confirmation');
socket.on('joined_confirmation', function (data) {
if (wasReconnected) {
showToast(`Lista: ${data.list_title} ponownie dołączono.`, 'info');
wasReconnected = false;
}
if (window.LIST_ID) {
socket.emit('request_full_list', { list_id: window.LIST_ID });
}
});
socket.on('user_joined', function (data) {
showToast(`${data.username} dołączył do listy`, 'info');
});
socket.on('user_left', function (data) {
showToast(`${data.username} opuścił listę`, 'warning');
});
socket.on('user_list', function (data) {
if (data.users.length > 0) {
const userList = data.users.join(', ');
showToast(`Obecni: ${userList}`, 'info');
}
});
socket.on('receipt_added', function (data) {
const gallery = document.getElementById("receiptGallery");
if (!gallery) return;
const alert = gallery.querySelector(".alert");
if (alert) {
alert.remove();
}
const existing = Array.from(gallery.querySelectorAll("img")).find(img => img.src === data.url);
if (!existing) {
const col = document.createElement("div");
col.className = "col-6 col-md-4 col-lg-3 text-center";
col.innerHTML = `
<a href="${data.url}" class="glightbox" data-gallery="receipt-gallery">
<img src="${data.url}" class="img-fluid rounded shadow-sm border border-secondary" style="max-height: 200px; object-fit: cover;">
</a>
`;
gallery.appendChild(col);
lightbox.reload();
}
});
socket.on("items_reordered", data => {
if (data.list_id !== window.LIST_ID) return;
if (window.currentItems) {
window.currentItems = data.order.map(id =>
window.currentItems.find(item => item.id === id)
).filter(Boolean);
updateListSmoothly(window.currentItems);
//showToast('Kolejność produktów zaktualizowana', 'info');
}
});
socket.on('full_list', function (data) {
const itemsContainer = document.getElementById('items');
const oldItems = Array.from(
itemsContainer.querySelectorAll('li[data-name], li[id^="item-"]')
);
const isDifferent = isListDifferent(oldItems, data.items);
window.currentItems = data.items;
updateListSmoothly(data.items);
if (typeof window.syncSortModeUI === 'function') {
window.syncSortModeUI();
}
toggleEmptyPlaceholder();
if (didReceiveFirstFullList && isDifferent) {
showToast('Lista została zaktualizowana', 'info');
}
didReceiveFirstFullList = true;
});
socket.on('item_marked_not_purchased', data => {
socket.emit('request_full_list', { list_id: window.LIST_ID });
});
socket.on('item_unmarked_not_purchased', data => {
socket.emit('request_full_list', { list_id: window.LIST_ID });
});
+105
View File
@@ -0,0 +1,105 @@
let sortable = null;
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 (window.isSorting) return;
const itemsContainer = document.getElementById('items');
const listId = window.LIST_ID;
if (!itemsContainer || !listId) return;
window.isSorting = true;
syncSortModeUI();
setTimeout(() => {
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:not(.drag-handle)',
preventOnFilter: false,
onEnd: () => {
const order = Array.from(itemsContainer.children)
.map(li => parseInt(li.id.replace('item-', ''), 10))
.filter(id => !isNaN(id));
fetch('/reorder_items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ list_id: listId, order })
}).then(() => {
showToast('Zapisano nową kolejność', 'success');
if (window.currentItems) {
window.currentItems = order
.map(id => window.currentItems.find(item => item.id === id))
.filter(Boolean);
updateListSmoothly(window.currentItems);
}
});
}
});
}, 50);
}
function disableSortMode() {
if (sortable) {
sortable.destroy();
sortable = null;
}
window.isSorting = false;
syncSortModeUI();
}
function toggleSortMode() {
if (window.isSorting) {
disableSortMode();
} else {
enableSortMode();
}
}
window.toggleSortMode = toggleSortMode;
window.syncSortModeUI = syncSortModeUI;
document.addEventListener('DOMContentLoaded', () => {
window.isSorting = false;
syncSortModeUI();
});
+28
View File
@@ -0,0 +1,28 @@
document.addEventListener("DOMContentLoaded", function () {
const searchInput = document.getElementById("search-table");
const clearButton = document.getElementById("clear-search");
const rows = document.querySelectorAll("table tbody tr");
if (!searchInput || !rows.length) return;
function filterTable(query) {
const q = query.toLowerCase();
rows.forEach(row => {
const rowText = row.textContent.toLowerCase();
row.style.display = rowText.includes(q) ? "" : "none";
});
}
searchInput.addEventListener("input", function () {
filterTable(this.value);
});
if (clearButton) {
clearButton.addEventListener("click", function () {
searchInput.value = "";
filterTable(""); // Pokaż wszystko
searchInput.focus();
});
}
});
+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);
});
});
@@ -1,4 +1,4 @@
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', function () {
var resetPasswordModal = document.getElementById('resetPasswordModal');
resetPasswordModal.addEventListener('show.bs.modal', function (event) {
var button = event.relatedTarget;
+9
View File
@@ -0,0 +1,9 @@
/*!
* Cropper.js v1.6.2
* https://fengyuanchen.github.io/cropperjs
*
* Copyright 2015-present Chen Fengyuan
* Released under the MIT license
*
* Date: 2024-04-21T07:43:02.731Z
*/.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
+1
View File
@@ -0,0 +1 @@
.sortable thead th:not(.no-sort){cursor:pointer}.sortable thead th:not(.no-sort)::after,.sortable thead th:not(.no-sort)::before{transition:color .1s ease-in-out;font-size:1.2em;color:rgba(0,0,0,0)}.sortable thead th:not(.no-sort)::after{margin-left:3px;content:"▸"}.sortable thead th:not(.no-sort):hover::after{color:inherit}.sortable thead th:not(.no-sort)[aria-sort=descending]::after{color:inherit;content:"▾"}.sortable thead th:not(.no-sort)[aria-sort=ascending]::after{color:inherit;content:"▴"}.sortable thead th:not(.no-sort).indicator-left::after{content:""}.sortable thead th:not(.no-sort).indicator-left::before{margin-right:3px;content:"▸"}.sortable thead th:not(.no-sort).indicator-left:hover::before{color:inherit}.sortable thead th:not(.no-sort).indicator-left[aria-sort=descending]::before{color:inherit;content:"▾"}.sortable thead th:not(.no-sort).indicator-left[aria-sort=ascending]::before{color:inherit;content:"▴"}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 280 B

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More